Webhooks
How a sending provider tells your instance what happened to a message it took — delivered, bounced, complained, suppressed.
POST /webhooks/email/:provider
One route per configured adapter. Verification belongs to the adapter, because how a provider proves a request is its own differs per provider.
Verification
| Provider | How |
|---|---|
| Resend | Svix signature headers — svix-id, svix-timestamp, svix-signature — HMAC-SHA256 over id.timestamp.body, with a five-minute freshness window. Set the signing secret under Settings → Provider → Credentials. |
| Postmark | Postmark can't sign, so the claim is its webhook secret in the URL: ?secret=…, compared in constant time against provider.postmark.webhook_secret — set under Settings → Provider → Credentials. |
| Providers that can't sign | A shared secret in the URL: ?secret=…, compared in constant time against provider.webhook_secret. |
A provider that signs does not fall back to the shared secret. A signature is a much stronger claim, and accepting the weaker one from a provider that offers the stronger would undo the point of checking.
Responses
| Status | When |
|---|---|
200 | Understood — including an event type we don't act on. Retrying a no-op helps nobody. |
401 | Verification failed. |
404 | No adapter by that name. |
400 | The body isn't parseable. A retry of the same bytes won't fix it. |
500 | Genuinely failed to process. Please retry. |
The internal vocabulary
Adapters normalize their provider's own event names into five, and nothing above the adapter ever sees a provider's vocabulary:
| Type | Effect |
|---|---|
delivered | The send is marked delivered. |
bounced | Recorded with a bounce_kind. A hard bounce suppresses the person; a soft one (a full mailbox) does not — that's a try-again, not a dead address. |
complained | Suppresses the person. Reason complaint. |
unsubscribed | Suppresses the person. |
suppressed | The provider suppressed them on its side. Suppresses locally too. |
A suppression from a webhook is recorded with source
provider_webhook, which stops it being pushed straight back to the
provider that just reported it.
At-least-once and unordered
Assumed of every provider, whatever they promise. Handling is built for it:
- Every write is set-if-not-already. The same event twice leaves exactly what it left the first time — no double timeline entry, no second unsubscribe.
- A weaker fact never overwrites a stronger one. A
deliveredarriving after abouncedfor the same message is ignored: a message that bounced doesn't become delivered because a stale event turned up late. - Attribution is by provider message id. Never by address alone — that would attribute an event to whichever message happened to be most recent.
Not every provider has them
Bento has no webhooks; suppression is polled nightly instead, so a Bento-side unsubscribe can take until the next sync to appear. Either way the local database is the authority and the standing guard checks it before every send — a provider without webhooks is slower to reconcile, not less safe.
See also: Write your own adapter · Postmark API notes · Resend API notes