Theme

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

ProviderHow
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

StatusWhen
200Understood — including an event type we don't act on. Retrying a no-op helps nobody.
401Verification failed.
404No adapter by that name.
400The body isn't parseable. A retry of the same bytes won't fix it.
500Genuinely 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:

TypeEffect
deliveredThe send is marked delivered.
bouncedRecorded 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.
complainedSuppresses the person. Reason complaint.
unsubscribedSuppresses the person.
suppressedThe 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:

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