Skip to main content
Guides File 044

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'
import { mail } from 'postboi'

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 mail.suppressions.add(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 })

One line per framework

You rarely need to call receive() yourself: webhook() wraps it in the response contract providers expect (200 on success, 401 on a bad signature, 400 on a bad payload, 500 when your handler throws so the provider retries) and takes the request in whichever shape your framework hands it over. A web Request (Next.js, Workers, plain fetch handlers) and a context object carrying .request (SvelteKit, Astro, React Router) both work, so the same line is the whole endpoint in all of them:

// Next.js app/webhooks/route.ts · an Astro APIRoute · a React Router action ·
// a SvelteKit +server.ts · a Worker fetch branch — identical in each:
import { webhook } from 'postboi/webhooks'

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

Hono keeps the raw request one level deeper, so unwrap it in place:

app.post('/webhooks', (c) => webhook(handler)(c.req.raw))

Express and plain node:http

Express is where webhook endpoints classically break: signatures verify over the request’s exact raw bytes, and a body parser mounted in front of the route rewrites them. Verification then fails forever, with nothing to say why. webhook.node() reads the raw stream itself, so there’s no parser to misconfigure:

app.post('/webhooks', webhook.node(async (event) => {
	console.log(`${event.type}: ${event.email}`)
}))

A global express.urlencoded() is fine (webhook bodies are JSON, so it never touches them). Just don’t mount a JSON parser ahead of this route.

On SvelteKit, postboi/kit re-exports webhook() with the RequestEvent type already narrowed, so imports stay consistent with mail and action from the same module.

The event shape

Every provider’s payload normalizes to a WebhookEvent:

interface WebhookEvent {
	type: 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained'
	    | 'opened' | 'clicked' | 'unsubscribed' | 'resubscribed' | 'failed' | 'received'
	    | 'test_received' | 'test_completed'  // Postboi hosted testing, see below
	    | 'viewed' | 'view_viewed'            // Postboi web versions, see below
	    | 'draft_created' | 'draft_sent'      // Postboi agent mailboxes, see below
	provider: string      // "resend", "postmark", …
	message_id?: string   // matches the id send() returned, where the provider allows
	email?: string        // the recipient this event is about
	channel?: Channel     // "sms" | "whatsapp"; absent means email
	phone?: string        // sms/whatsapp: the number, in E.164 — never in `email`
	timestamp?: Date
	subject?: string
	tags?: Array<string>
	url?: string          // clicked events: the link
	bounce?: { category: 'hard' | 'soft' | 'suppressed' | 'filtered' | 'unknown'; detail?: string }
	client?: EmailClient  // opened/clicked, see below
	ip?: string
	body?: { html?: string; text?: string }  // received events: the message
	keyword?: 'stop' | 'start' | 'help'      // sms/whatsapp: the keyword an inbound text was
	form?: { id: string; name: string }      // Postboi: the form a submission was filed under
	fields?: Array<[string, string]>         // Postboi: the submission's fields, in order
	test?: TestingRun                        // test_* events: the test run
	view?: ViewViewed                        // view_viewed events: the page and its reader
	draft?: MailboxDraftEvent                // draft_* events: the mailbox and its draft
	dedupe_key?: string   // the same for a redelivery of this event, see below
	raw: unknown          // the untouched provider payload
}

Providers deliver at least once, and several sign nothing that would tell a replay from the first delivery, so store dedupe_key and skip an event you’ve already handled. It’s built from the provider, the message, the type and the recipient (and the time and link for an open, a click or a delay, which happen more than once), and set on every event that names a message, by receive() and poll() alike.

On the Postboi provider, every event about a form submission, sent through clicked, names the form and carries the fields it was submitted with, so a handler that files submissions somewhere else has the data rather than an email to scrape it from.

Mail coming back

received is the one event that isn’t about a send: someone wrote to your sending address or your reply subdomain. It reads the other way round from the rest: email is the person who wrote to you, and message_id is the send they were replying to, when we can tell. Lettermint’s inbound routes and Sequenzy’s tracked replies arrive the same way:

export const POST = webhook(async (event) => {
	if (event.type === 'received') {
		await open_ticket({
			from: event.email ?? event.phone, // a number when they wrote over WhatsApp
			subject: event.subject,
			body: event.body?.text ?? event.body?.html,
			about: event.message_id, // the send being answered, if any
		})
	}
})

On WhatsApp via Meta’s Cloud API it is a message to your number: phone is the person, body.text is what they said, and message_id is the send they replied to when they used WhatsApp’s reply. Providers without inbound never emit it.

Test runs finishing

Hosted email tests report back too, on the Postboi provider. test_received fires when a run’s email is in and its report is ready; test_completed fires once per run, when every screenshot has settled. Neither is about a send, so email and message_id stay unset and the run itself is in test, exactly as GET /v1/testing/{id} answers it:

export const POST = webhook(async (event) => {
	if (event.type === 'test_completed' && event.test) {
		const { report, screenshots, url } = event.test
		await notify(`${report?.status}: ${screenshots?.ready} of ${screenshots?.total} captures, ${url}`)
	}
})

They are separate types rather than a delivered with extra fields, so a handler that counts deliveries never counts a test. Image paths on test.previews are relative to the API and take the same bearer token; hosted_test and postboi testing download fetch them for you.

Web versions being read

Two more Postboi provider events come from web versions, the pages behind “View in browser” links. viewed (email.viewed on the wire) is someone opening the web version of a send made with web_version: true: message_id, the recipient in email, and the browser in client. It is deliberately not opened: a page in a browser says nothing about the inbox, so a handler that counts opens never counts it.

view_viewed (view.viewed) is a reader Postboi could identify opening a published view: through a data feed’s record, a sealed link or the opt-in reader param. It isn’t about a send, so email and message_id stay unset, and the page and the reader are in view:

export const POST = webhook(async (event) => {
	if (event.type === 'view_viewed' && event.view) {
		const { slug, params, reader } = event.view
		// reader.kind: 'record' | 'sealed' | 'param'; only a param's id is the reader's
		await track(reader.kind === 'param' ? reader.id : reader.data?.user_id, 'viewed_web_version', { slug, ...params })
	}
})

A view nobody can be told apart for sends nothing; views.stats() counts it.

Mailbox drafts

Two Postboi provider events come from agent mailboxes. draft_created (mailbox.draft.created) is a draft waiting for a person: one the agent wrote, or a send the mailbox’s review policy held (draft.held says which). draft_sent (mailbox.draft.sent) is that draft going out, with the Sent log’s id in message_id. The mailbox and the draft are in draft:

export const POST = webhook(async (event) => {
	if (event.type === 'draft_created' && event.draft) {
		const { mailbox, draft } = event.draft
		await notify_reviewer(`${mailbox.address} wants to send "${draft.subject}" to ${draft.to.join(', ')}`)
	}
})

Text messages

The same events cover SMS: channel says "sms" (absent still means email), and the number is in phone, never in email, so a handler that reads event.email is never handed a phone number. A text that reached the handset is delivered; one the carrier gave up on is failed, with the carrier’s code in bounce.detail; one it is still retrying is delayed. A failed text a carrier blocked as spam has bounce.category: 'filtered' (Twilio’s 30007, Telnyx’s spam codes, Vonage’s 1210, Sinch’s 15 and 62, Plivo’s 30, Bandwidth’s 4470 and 4770): the number is fine and the message or the sender is the problem, so resending it unchanged won’t help. Which way they arrive depends on the provider. The SMS Works pushes account-wide delivery reports, so it is a receive() provider like any of the email ones: provider: 'smsworks', verified with SMSWORKS_WEBHOOK_SECRET as ?token=… (see the table below). PureSMS pushes signed receipts (provider: 'puresms'). Twilio does either: it pushes signed status callbacks to receive() when a send says where (status_callback), and can be polled with nothing set up.

All of them read what people text back the same way, through the keyword rules: STOP is unsubscribed and START resubscribed for the number that sent it, and anything else is received, with their words in body.text and keyword: 'help' on a HELP.

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
}

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
Lettermint Signed (HMAC timestamp+body, 5-minute replay window) LETTERMINT_WEBHOOK_SECRET, the webhook’s signing secret
Unosend Signed (HMAC of body) UNOSEND_WEBHOOK_SECRET, the whsec_… signing secret
Sequenzy Signed (HMAC timestamp+body, 5-minute replay window) SEQUENZY_WEBHOOK_SECRET, the endpoint’s whsec_… signing secret; several comma-separated during a rotation
Meta (WhatsApp Cloud API) Signed (X-Hub-Signature-256, HMAC of body) META_WEBHOOK_SECRET, the app secret from the app’s Basic Settings, plus META_WEBHOOK_VERIFY_TOKEN for the handshake
Loops Signed (standard-webhooks HMAC) LOOPS_WEBHOOK_SECRET, the whsec_… signing secret from Settings → Webhooks
AhaSend Signed (standard-webhooks HMAC, keyed with the literal secret) AHASEND_WEBHOOK_SECRET, the webhook’s secret exactly as shown when it was created
Customer.io Signed (HMAC timestamp+body, 5-minute replay window) CUSTOMERIO_WEBHOOK_SECRET, the reporting webhook’s signing key
SocketLabs Secret key in the body SOCKETLABS_WEBHOOK_SECRET, the endpoint’s secret key. Set it before pressing Validate, since the Validation handshake is verified like any other request and then echoed back by webhook() for you
Postmark, Brevo, SparkPost, Mailjet, ZeptoMail, Elastic Email, Plunk, SMTP2GO, Postal, Infobip, SendPulse 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)
Twilio (SMS, WhatsApp) Signed (X-Twilio-Signature, HMAC-SHA1 of the URL and the POST parameters) TWILIO_WEBHOOK_SECRET, or else TWILIO_AUTH_TOKEN as it is: Twilio signs with the auth token. The URL is signed, so behind a proxy or a tunnel pass receive(request, { url }) with the one Twilio calls, query string included. webhook() answers with empty TwiML, as Twilio’s inbound webhook expects
Telnyx (SMS) Signed (Ed25519 of timestamp and body, 5-minute replay window) TELNYX_WEBHOOK_SECRET, the account’s public key from Mission Control (Keys & Credentials)
Vonage (SMS) Signed (HS256 JWT in Authorization, with payload_hash over the body) VONAGE_WEBHOOK_SECRET, the signature secret from the dashboard’s settings
Plivo (SMS) Signed (V2: HMAC-SHA256 of the URL and a nonce, not the body) PLIVO_WEBHOOK_SECRET, or PLIVO_AUTH_TOKEN
Sinch (SMS) HMAC-SHA256 over the body, nonce and timestamp when the plan has HMAC on, else Basic credentials in the callback URL SINCH_WEBHOOK_SECRET, the HMAC secret or the username:password in the URL
Bandwidth (SMS) Basic credentials on the application’s callback (Bandwidth doesn’t sign) BANDWIDTH_WEBHOOK_SECRET, the application’s username:password
PureSMS (SMS) Signed (HMAC-SHA256 of timestamp and body). The timestamp’s age isn’t checked, because PureSMS retries for up to a day PURESMS_WEBHOOK_SECRET, the secret you set on the endpoint (Settings → Webhooks). PureSMS treats it as optional; receive() doesn’t
The SMS Works (SMS) Shared secret SMSWORKS_WEBHOOK_SECRET, a token you make up, as ?token=… on the delivery-report URL (Delivery Reports → Webhook Configuration), or the basic-auth password on a reply-number webhook
Amazon SES, Scaleway Shared secret over SNS SES_WEBHOOK_SECRET / SCALEWAY_WEBHOOK_SECRET as ?token=… on the subscription URL; SNS subscription handshakes confirm automatically
Azure Communication Services Shared secret over Event Grid AZURE_WEBHOOK_SECRET as ?token=… on the subscription’s endpoint URL (Event Grid keeps the query string secret); the subscription validation handshake is completed automatically. Engagement reports name the message but not the recipient, so opens and clicks carry message_id and no email

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

Meta’s endpoint handshake

Meta checks an endpoint is yours before it subscribes it: saving the callback URL in the app dashboard sends a GET with hub.mode=subscribe, the verify token you typed into the same form, and a hub.challenge it expects back as the response body. webhook() answers that on a GET (route both methods to the same handler) and compares the token with META_WEBHOOK_VERIFY_TOKEN (or { verify_token }), timing-safe and fail-closed like everything else here: no configured token is a 401, never a stranger’s subscription confirmed. { verify: false } doesn’t reach it, for the same reason: a handshake has no payload to trust, only a URL anyone could have found.

// SvelteKit src/routes/webhooks/whatsapp/+server.ts · Next.js route.ts
const handle = webhook(async (event) => { … }, { provider: 'meta' })
export { handle as GET, handle as POST }

// Express: app.all('/webhooks/whatsapp', webhook.node(handler, { provider: 'meta' }))
// Hono:    app.on(['GET', 'POST'], '/webhooks/whatsapp', (c) => handle(c.req.raw))

Name the provider: the zero-config default is your email provider, and the endpoint Meta calls is never the one your email provider calls. (A project with no email provider at all does fall through to POSTBOI_WHATSAPP_PROVIDER=meta, so a WhatsApp-only app needs nothing here.)

Calling receive() yourself? handshake(request, options) from the same module is the piece webhook() uses: it returns the challenge to echo (send it as 200, plain text), or undefined for a request that isn’t a handshake, and throws WebhookVerificationError on a bad token. The verify token is separate from the app secret on purpose. It travels in a query string, and query strings end up in access logs.

Providers that don’t push: poll()

SMTP, Microsoft 365 and Cloudflare Email Service don’t emit delivery-event webhooks: receive() throws webhooks_not_supported and points here. Gmail has no delivery events at all, and Alibaba Direct Mail, HubSpot, Iterable, JetEmail, Klaviyo, Lettr, MailChannels, Maileroo, Netcore, OneSignal, Primitive and Yandex Cloud Postbox have none postboi receives yet, so receive() throws the same for them. Twilio is here for a different reason: its status callbacks are set per message, at send time, so polling is what makes SMS and WhatsApp delivery receipts work with nothing configured and no public endpoint. (Meta’s Cloud API pushes a real webhook, so WhatsApp via Meta is receive() above.) Each still has somewhere the events can be fetched from, and poll() fetches what’s new since the last call, returning the same normalized events plus an opaque cursor to persist:

import { poll } from 'postboi/webhooks'

const { events, cursor } = await poll({ provider: 'microsoft365', cursor: saved })
for (const event of events) handle(event)
await save(cursor) // hand it back next time — polls are incremental

Run it on whatever schedule suits you (a cron, a queue worker); more: true in the result means the provider had more ready than one call returned, so poll again soon. Credentials resolve like sending: explicit options, else the provider’s env vars.

Provider Where events come from Setup beyond the send credentials
Microsoft 365 The Graph message-trace API Grant the app registration the ExchangeMessageTrace.Read.All application permission (admin consent) and provision a service principal for Microsoft app id 8bd644d1-64a1-4d4b-ae52-2e0cbf64e373. send() mints the internet Message-ID it returns, and trace events carry the same id, so events correlate to sends. Tenant rate limit: 100 trace queries per 5 minutes.
Cloudflare A Queue fed by an Email Sending event subscription, pulled over HTTP The first poll auto-creates a postboi-email-events queue with an HTTP-pull consumer (the API token needs Queues edit access). Add the event subscription once (Queues → the queue → Subscriptions → Email Sending) and the poll self-heals when the first event lands; or provision everything yourself and set CLOUDFLARE_QUEUE_ID. Give that queue no other consumer.
Twilio (SMS + WhatsApp) The Message resource, listed over the last 24 hours Nothing. TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are the send credentials. One row covers both channels: event.channel is "sms" or "whatsapp" and the number is in event.phone, never event.email. WhatsApp’s read receipt arrives as opened; an undeliverable message is failed with the carrier’s code in bounce.detail (a text message has no bounce classification, since there’s no mailbox to be unavailable). The window is trailing rather than incremental, because Twilio filters by DateSent while what changes is the status: the cursor remembers the last status per message and emits only on a change. Inbound replies go through the keyword rules: STOP is unsubscribed, START resubscribed, anything else received. To be told instead of asking, use the twilio webhook adapter.
SMTP The return-path’s bounce mailbox, read over POP3, parsing RFC 3464 delivery status notifications Set POP3_HOST, POP3_USER, POP3_PASS (and POP3_PORT/POP3_SECURE if not 995/implicit TLS) for the mailbox your bounce address delivers to. Messages are left on the server by default; pass { options: { delete: '1' } } to remove processed mail. parse_dsn is exported on its own too, if bounces already reach you some other way (an inbound route, for instance).

Two honest caveats. Cloudflare pulls are acked in-call, and SMTP deletion is irreversible: persist the returned events before doing anything that can fail. And SMTP only ever reports what the DSN says. Servers that bounce in prose instead of RFC 3464 parse to nothing (the raw mail stays in the mailbox as the escape hatch).

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"

// WhatsApp via Meta, a person writing back to your number:
const wa = await mock_request({ provider: 'meta', type: 'received' })
// (await receive(wa.request, { provider: 'meta', secret: wa.secret }))[0].body.text

// A hosted test run whose screenshots have all settled:
const done = await mock_request({ provider: 'postboi', type: 'test_completed' })
// (await receive(done.request, { provider: 'postboi', secret: done.secret }))[0].test.screenshots.state === "done"

// A reader opening a published view, identified by a data feed's record:
const view = await mock_request({ provider: 'postboi', type: 'view_viewed' })
// (await receive(view.request, { provider: 'postboi', secret: view.secret }))[0].view.reader.kind === "record"

// Text-message providers put the number in `phone`:
const sms = await mock_request({ provider: 'smsworks', type: 'failed' })
// (await receive(sms.request, { provider: 'smsworks', secret: sms.secret }))[0].phone === "+447700900123"

Polling providers get the same treatment: mock_poll builds a realistic poll() result. Each fixture runs through the adapter’s real normalization, so it can’t drift:

import { mock_poll } from 'postboi/webhooks'

const { events } = await mock_poll({ provider: 'smtp', type: 'bounced' })
// events[0].bounce.category === "hard"

// Multi-channel providers take the channel too:
const wa = await mock_poll({ provider: 'twilio', type: 'opened', channel: 'whatsapp' })
// wa.events[0].phone === "+15557770006"

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 })