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.
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:
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,
Remix) both work, so the same line is the whole endpoint in all of them:
Hono keeps the raw request one level deeper â unwrap it in place:
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:
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:
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:
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.
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. 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). Twilio sets its callbacks per message, so it is polled.
Both read an inbound reply for one thing: a reply that is an opt-out keyword is an unsubscribed event for the number that sent it.
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:
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:
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.
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:
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 â poll again soon.
Credentials resolve like sending: explicit options, else the providerâs env vars.
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:
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:
Custom providers
receive() accepts a custom adapter for anything postboi doesnât cover: implement verify and normalize and pass it as provider: