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, so 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:
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:
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, so 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: