Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/landing/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ RESEND_API_KEY=replace-me-with-resend-api-key
# Create at https://resend.com/segments → copy the ID
RESEND_SEGMENT_ID=

# Resend webhook signing secret for /api/resend-webhook
RESEND_WEBHOOK_SECRET=

# Paddle Billing sandbox checkout
# Server-side secret. In production, set PADDLE_ENVIRONMENT=production and use the live key.
PADDLE_ENVIRONMENT=sandbox
Expand Down
18 changes: 11 additions & 7 deletions apps/landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Built with React 19, TypeScript, Tailwind CSS 4, and Framer Motion. Deployed on
| UI | Radix UI primitives |
| API | Vercel Serverless Functions |
| Email | Resend |
| Analytics | PostHog |
| Fonts | Satoshi, Inter, JetBrains Mono |

## Getting Started
Expand All @@ -28,15 +29,16 @@ cp apps/landing/.env.example apps/landing/.env.local

Fill in your environment variables:

| Variable | Required | Description |
| ------------------- | -------- | ----------------------------------------- |
| `RESEND_API_KEY` | Yes | API key from [Resend](https://resend.com) |
| `RESEND_SEGMENT_ID` | No | Segment ID to group waitlist contacts |
| Variable | Required | Description |
| ----------------------- | -------- | ------------------------------------------------ |
| `RESEND_API_KEY` | Yes | API key from [Resend](https://resend.com) |
| `RESEND_SEGMENT_ID` | No | Segment ID to group waitlist contacts |
| `RESEND_WEBHOOK_SECRET` | Yes | Signing secret for Resend event webhook delivery |

PostHog analytics is optional in local development. Production should set the public project key
and host so landing pageviews, explicit CTA/demo/waitlist events, and privacy-masked session
replays are captured. Replay masks all inputs and text, blocks private selectors, and strips network
bodies/headers before capture.
and host so landing pageviews, explicit CTA/demo/waitlist events, UTM attribution, Resend webhook
events, and privacy-masked session replays are captured. Replay masks all inputs and text, blocks
private selectors, and strips network bodies/headers before capture.

| Variable | Required | Description |
| ------------------- | -------- | --------------------------------------------------- |
Expand Down Expand Up @@ -88,6 +90,7 @@ functions locally — no separate backend needed.
```
├── api/
│ ├── paddle-checkout.ts # Vercel serverless — Paddle transaction checkout
│ ├── resend-webhook.ts # Vercel serverless — Resend event webhook
│ └── waitlist.ts # Vercel serverless — Resend waitlist signup
├── src/
│ ├── components/
Expand All @@ -113,6 +116,7 @@ set the Vercel project Root Directory to `apps/landing` so Vercel reads this pac
`package.json`, `vite.config.ts`, `api/`, and `vercel.json`.

- `api/waitlist.ts` runs as a serverless function
- `api/resend-webhook.ts` receives Resend delivery/open/click/unsubscribe events for PostHog
- SPA is served as static output from `vite build`
- Domain redirects configured in `vercel.json` (www + .ai variants → memrynote.com)

Expand Down
26 changes: 17 additions & 9 deletions apps/landing/api/paddle-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ function getPostHogClient(): PostHog | null {
return new PostHog(key, { host })
}

async function captureCheckoutInitiated(transactionId: string, plan: string, cadence: string) {
const posthog = getPostHogClient()
if (!posthog) return

try {
posthog.capture({
distinctId: transactionId,
event: 'checkout_initiated',
properties: { plan, cadence, transactionId }
})
await posthog.shutdown()
} catch {
console.error('[paddle-checkout] PostHog capture failed')
}
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
Expand Down Expand Up @@ -74,15 +90,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
checkout: checkoutUrl ? { url: checkoutUrl } : undefined
})

const posthog = getPostHogClient()
if (posthog) {
posthog.capture({
distinctId: transaction.id,
event: 'checkout_initiated',
properties: { plan: intent.plan, cadence: intent.cadence, transactionId: transaction.id }
})
await posthog.shutdown()
}
await captureCheckoutInitiated(transaction.id, intent.plan, intent.cadence)

return res.status(200).json({
environment: paddleEnvironment,
Expand Down
45 changes: 45 additions & 0 deletions apps/landing/api/resend-webhook-support.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'

import { toMarketingEmailEvent } from './resend-webhook-support.ts'

describe('resend webhook PostHog mapping', () => {
it('maps clicked email webhooks without raw recipient, IP, or query secrets', () => {
assert.deepEqual(
toMarketingEmailEvent({
type: 'email.clicked',
created_at: '2026-05-20T12:00:00.000Z',
data: {
broadcast_id: 'broadcast_123',
email_id: 'email_123',
to: ['Private@Example.com'],
subject: 'MemryNote ships end of June',
click: {
ipAddress: '203.0.113.10',
link: 'https://memrynote.com/?utm_source=waitlist&utm_medium=email&utm_campaign=waitlist_01_launch_plain&utm_content=primary_cta&token=secret#waitlist',
timestamp: '2026-05-20T12:01:00.000Z',
userAgent: 'Mozilla/5.0'
}
}
}),
{
distinctId:
'marketing_email:8172a023f8733c1c6377deccd97aefc669393f2a8f077b5bee2d1682d9bc307e',
event: 'marketing_email_clicked',
properties: {
broadcast_id: 'broadcast_123',
campaign_path: '/',
click_host: 'memrynote.com',
email_id: 'email_123',
resend_event_type: 'email.clicked',
subject: 'MemryNote ships end of June',
utm_campaign: 'waitlist_01_launch_plain',
utm_content: 'primary_cta',
utm_medium: 'email',
utm_source: 'waitlist'
},
timestamp: '2026-05-20T12:00:00.000Z'
}
)
})
})
103 changes: 103 additions & 0 deletions apps/landing/api/resend-webhook-support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { createHash } from 'node:crypto'

type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }

export type PostHogMarketingEmailEvent = {
distinctId: string
event: string
properties: Record<string, JsonValue>
timestamp?: string
}

export type ResendWebhookPayload = {
type: string
created_at?: string
data?: Record<string, unknown>
}

const EVENT_NAME_BY_TYPE: Record<string, string> = {
'email.sent': 'marketing_email_sent',
'email.delivered': 'marketing_email_delivered',
'email.opened': 'marketing_email_opened',
'email.clicked': 'marketing_email_clicked',
'email.bounced': 'marketing_email_bounced',
'email.complained': 'marketing_email_complained',
'email.delivery_delayed': 'marketing_email_delivery_delayed',
'contact.unsubscribed': 'marketing_email_unsubscribed'
}
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const

function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value ? value : undefined
}

function firstString(value: unknown): string | undefined {
if (Array.isArray(value)) return stringValue(value[0])
return stringValue(value)
}

function hashRecipient(value: string): string {
return createHash('sha256').update(value.trim().toLowerCase()).digest('hex')
}

function setIfPresent(
properties: Record<string, JsonValue>,
key: string,
value: string | undefined
): void {
if (value) properties[key] = value
}

function addClickProperties(
properties: Record<string, JsonValue>,
data: Record<string, unknown>
): void {
const click = data.click
if (!click || typeof click !== 'object') return

const link = stringValue((click as Record<string, unknown>).link)
if (!link) return

try {
const url = new URL(link)
properties.click_host = url.host
properties.campaign_path = url.pathname || '/'

for (const key of UTM_KEYS) {
const value = url.searchParams.get(key)
if (value) properties[key] = value.slice(0, 120)
}
} catch {
return
}
}

export function toMarketingEmailEvent(
payload: ResendWebhookPayload
): PostHogMarketingEmailEvent | null {
const event = EVENT_NAME_BY_TYPE[payload.type]
const data = payload.data
if (!event || !data) return null

const recipient = firstString(data.to) ?? stringValue(data.email)
const distinctId = recipient
? `marketing_email:${hashRecipient(recipient)}`
: `marketing_email_event:${stringValue(data.email_id) ?? payload.type}`

const properties: Record<string, JsonValue> = {
resend_event_type: payload.type
}

setIfPresent(properties, 'broadcast_id', stringValue(data.broadcast_id))
setIfPresent(properties, 'email_id', stringValue(data.email_id))
setIfPresent(properties, 'template_id', stringValue(data.template_id))
setIfPresent(properties, 'subject', stringValue(data.subject))
addClickProperties(properties, data)

return {
distinctId,
event,
properties,
timestamp: payload.created_at
}
}
90 changes: 90 additions & 0 deletions apps/landing/api/resend-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { PostHog } from 'posthog-node'
import { Resend } from 'resend'
import type { VercelRequest, VercelResponse } from '@vercel/node'

import { toMarketingEmailEvent, type ResendWebhookPayload } from './resend-webhook-support.js'

function getPostHogClient(): PostHog | null {
const key = process.env.POSTHOG_API_KEY
const host = process.env.POSTHOG_HOST
if (!key || !host) return null
return new PostHog(key, { host })
}

function getHeaderValue(req: VercelRequest, name: string): string | undefined {
const value = req.headers[name.toLowerCase()]
if (Array.isArray(value)) return value[0]
return typeof value === 'string' && value ? value : undefined
}

async function readRawBody(req: VercelRequest): Promise<string> {
if (typeof req.body === 'string') return req.body
if (req.body && typeof req.body === 'object') return JSON.stringify(req.body)

const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}

return Buffer.concat(chunks).toString('utf8')
}

async function verifyResendWebhook(req: VercelRequest): Promise<ResendWebhookPayload> {
const apiKey = process.env.RESEND_API_KEY
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
if (!apiKey) throw new Error('Missing RESEND_API_KEY')
if (!webhookSecret) throw new Error('Missing RESEND_WEBHOOK_SECRET')

const resend = new Resend(apiKey)
const payload = await readRawBody(req)
const id = getHeaderValue(req, 'svix-id')
const timestamp = getHeaderValue(req, 'svix-timestamp')
const signature = getHeaderValue(req, 'svix-signature')
if (!id || !timestamp || !signature) throw new Error('Missing Resend webhook signature')

const verifiedPayload = await resend.webhooks.verify({
payload,
headers: {
id,
timestamp,
signature
},
webhookSecret
})

return verifiedPayload as unknown as ResendWebhookPayload
}

function parseEventTimestamp(value: string | undefined): Date | undefined {
if (!value) return undefined
const timestamp = new Date(value)
return Number.isNaN(timestamp.valueOf()) ? undefined : timestamp
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}

let payload: ResendWebhookPayload
try {
payload = await verifyResendWebhook(req)
} catch {
return res.status(400).json({ error: 'Invalid webhook' })
}

const event = toMarketingEmailEvent(payload)
const posthog = getPostHogClient()

if (event && posthog) {
posthog.capture({
distinctId: event.distinctId,
event: event.event,
properties: event.properties,
timestamp: parseEventTimestamp(event.timestamp)
})
await posthog.shutdown()
}

return res.status(200).json({ received: true })
}
25 changes: 25 additions & 0 deletions apps/landing/api/waitlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'

import { normalizeWaitlistAttribution } from './waitlist.ts'

describe('waitlist attribution', () => {
it('keeps only safe campaign fields for server-side signup capture', () => {
assert.deepEqual(
normalizeWaitlistAttribution({
utm_source: 'waitlist',
utm_medium: 'email',
utm_campaign: 'waitlist_01_launch_plain',
utm_content: 'primary_cta',
email: 'private@example.com',
token: 'secret'
}),
{
utm_source: 'waitlist',
utm_medium: 'email',
utm_campaign: 'waitlist_01_launch_plain',
utm_content: 'primary_cta'
}
)
})
})
Loading
Loading