Skip to main content

Guides

Webhooks

Receive delivery events — delivered, opened, clicked, bounced — normalized across every provider, with signatures verified.


Sending is half the story. Providers also report back — delivery confirmations, opens, clicks, bounces, spam complaints — via webhooks. postboi/webhooks receives those the same way postboi sends: one normalized shape, any provider. Point your provider’s webhook at an endpoint, hand postboi the request, and get typed events out — signature verification included.

import { receive } from 'postboi/webhooks'

export async function POST(request: Request) {
	const events = await receive(request)

	for (const event of events) {
		if (event.type === 'opened') {
			console.log(`${event.email} opened in ${event.client?.name} on ${event.client?.device}`)
		}
		if (event.type === 'bounced' && event.bounce?.category === 'hard') {
			await suppress(event.email)
		}
	}

	return Response.json({ received: events.length })
}
import { receive } from 'postboi/webhooks'

export async function POST(request: Request) {
	const events = await receive(request)

	for (const event of events) {
		if (event.type === 'opened') {
			console.log(`${event.email} opened in ${event.client?.name} on ${event.client?.device}`)
		}
		if (event.type === 'bounced' && event.bounce?.category === 'hard') {
			await suppress(event.email)
		}
	}

	return Response.json({ received: events.length })
}

Like mail(), receive() is zero-config: the provider comes from POSTBOI_PROVIDER / postboi.config.ts / a POSTBOI_TOKEN, and the signing secret from the provider’s <PROVIDER>_WEBHOOK_SECRET env var. Both can be passed explicitly:

const events = await receive(request, { provider: 'resend', secret: RESEND_WEBHOOK_SECRET })
const events = await receive(request, { provider: 'resend', secret: RESEND_WEBHOOK_SECRET })

SvelteKit

On SvelteKit, use the ready-made handler from postboi/kit — it verifies, normalizes, calls your handler once per event, and answers the provider correctly (200 on success, 401 on a bad signature, 400 on a bad payload, 500 when your handler throws so the provider retries):

// src/routes/webhooks/email/+server.ts
import { webhook } from 'postboi/kit'

export const POST = webhook(async (event) => {
	if (event.type === 'opened') {
		console.log(`${event.email} opened in ${event.client?.name}`)
	}
})
// src/routes/webhooks/email/+server.ts
import { webhook } from 'postboi/kit'

export const POST = webhook(async (event) => {
	if (event.type === 'opened') {
		console.log(`${event.email} opened in ${event.client?.name}`)
	}
})

The event shape

Every provider’s payload normalizes to a WebhookEvent:

interface WebhookEvent {
	type: 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained'
	    | 'opened' | 'clicked' | 'unsubscribed' | 'failed'
	provider: string      // "resend", "postmark", …
	message_id?: string   // matches the id send() returned, where the provider allows
	email?: string        // the recipient this event is about
	timestamp?: Date
	subject?: string
	tags?: Array<string>
	url?: string          // clicked events — the link
	bounce?: { category: 'hard' | 'soft' | 'suppressed' | 'unknown'; detail?: string }
	client?: EmailClient  // opened/clicked — see below
	ip?: string
	raw: unknown          // the untouched provider payload
}
interface WebhookEvent {
	type: 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained'
	    | 'opened' | 'clicked' | 'unsubscribed' | 'failed'
	provider: string      // "resend", "postmark", …
	message_id?: string   // matches the id send() returned, where the provider allows
	email?: string        // the recipient this event is about
	timestamp?: Date
	subject?: string
	tags?: Array<string>
	url?: string          // clicked events — the link
	bounce?: { category: 'hard' | 'soft' | 'suppressed' | 'unknown'; detail?: string }
	client?: EmailClient  // opened/clicked — see below
	ip?: string
	raw: unknown          // the untouched provider payload
}

Who opened it, and on what

On opens and clicks, most providers report the recipient’s user-agent. postboi parses it locally (a pure function — no lookup service, nothing leaves your server) into:

interface EmailClient {
	name?: string   // "Apple Mail", "Gmail", "Outlook", "Chrome", …
	os?: string     // "iOS", "macOS", "Windows", …
	device: 'desktop' | 'mobile' | 'tablet' | 'unknown'
	user_agent: string
}
interface EmailClient {
	name?: string   // "Apple Mail", "Gmail", "Outlook", "Chrome", …
	os?: string     // "iOS", "macOS", "Windows", …
	device: 'desktop' | 'mobile' | 'tablet' | 'unknown'
	user_agent: string
}

So event.client answers “opened in Apple Mail on an iPhone” out of the box. Two honest caveats: proxied opens (Gmail, Yahoo fetch the pixel on the recipient’s behalf) identify the mailbox provider but hide the device, and Apple Mail Privacy Protection means open events generally are an approximation, whatever the provider.

Verification

Verification is fail-closed: if no secret is configured, receive() throws rather than silently accepting unauthenticated requests. Every comparison is timing-safe, and schemes with timestamps get replay protection.

Providers fall into three camps:

Provider Scheme Secret to set
the Postboi provider Signed (standard-webhooks HMAC) POSTBOI_WEBHOOK_SECRET — the whsec_… from the dashboard
Resend Signed (Svix HMAC) RESEND_WEBHOOK_SECRET — the whsec_… from the dashboard
SendGrid Signed (ECDSA P-256) SENDGRID_WEBHOOK_SECRET — the Signed Event Webhook public key
Mailgun Signed (HMAC timestamp+token) MAILGUN_WEBHOOK_SECRET — the webhook signing key
MailerSend Signed (HMAC of body) MAILERSEND_WEBHOOK_SECRET
Mailtrap Signed (HMAC of body) MAILTRAP_WEBHOOK_SECRET
Mandrill Signed (HMAC of URL + params) MANDRILL_WEBHOOK_SECRET; behind a proxy also set MANDRILL_WEBHOOK_URL to the exact configured URL
MailPace Signed (Ed25519) MAILPACE_WEBHOOK_SECRET — the public verification key
Postmark, Brevo, SparkPost, Mailjet, ZeptoMail, Elastic Email, Plunk Shared secret <PROVIDER>_WEBHOOK_SECRET — a token you make up; put the same value in the webhook URL as ?token=… (or the provider’s auth-header option)
Amazon SES, Scaleway Shared secret over SNS SES_WEBHOOK_SECRET / SCALEWAY_WEBHOOK_SECRET as ?token=… on the subscription URL; SNS subscription handshakes confirm automatically

SMTP, Microsoft 365 and Cloudflare don’t emit delivery-event webhooks — receive() throws webhooks_not_supported for them.

To skip verification deliberately (a local experiment, a payload replay), pass { verify: false } — it’s always an explicit opt-out, never a fallback.

Testing without a provider

You don’t need a tunnel or a real provider to test your handler. mock_event builds a normalized event; mock_request builds a full, correctly signed HTTP request:

import { receive, mock_request, mock_event } from 'postboi/webhooks'

// Unit-test handler logic directly:
await my_handler(mock_event('opened', { email: 'user@example.com' }))

// Or drive the whole path, signature verification included:
const { request, secret } = await mock_request({ provider: 'resend', type: 'clicked' })
const events = await receive(request, { provider: 'resend', secret })
// events[0].url === "https://example.com/pricing"
import { receive, mock_request, mock_event } from 'postboi/webhooks'

// Unit-test handler logic directly:
await my_handler(mock_event('opened', { email: 'user@example.com' }))

// Or drive the whole path, signature verification included:
const { request, secret } = await mock_request({ provider: 'resend', type: 'clicked' })
const events = await receive(request, { provider: 'resend', secret })
// events[0].url === "https://example.com/pricing"

Custom providers

receive() accepts a custom adapter for anything postboi doesn’t cover — implement verify and normalize and pass it as provider:

import { receive, type WebhookAdapter } from 'postboi/webhooks'

const my_adapter: WebhookAdapter = {
	provider: 'internal',
	verify({ headers, secret }) {
		if (headers.get('x-api-key') !== secret) throw new Error('nope')
	},
	normalize(body) {
		const payload = JSON.parse(body)
		return [{ type: 'delivered', provider: 'internal', email: payload.rcpt, raw: payload }]
	},
}

const events = await receive(request, { provider: my_adapter, secret: INTERNAL_KEY })
import { receive, type WebhookAdapter } from 'postboi/webhooks'

const my_adapter: WebhookAdapter = {
	provider: 'internal',
	verify({ headers, secret }) {
		if (headers.get('x-api-key') !== secret) throw new Error('nope')
	},
	normalize(body) {
		const payload = JSON.parse(body)
		return [{ type: 'delivered', provider: 'internal', email: payload.rcpt, raw: payload }]
	},
}

const events = await receive(request, { provider: my_adapter, secret: INTERNAL_KEY })