# Webhooks

How sending providers report delivery, bounces, complaints and suppression back to your Mimeo — verification, the internal event vocabulary, and idempotency.

How a sending provider tells your Mimeo 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](https://mimeohq.com/docs/api/providers/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](https://mimeohq.com/docs/api/providers/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 `delivered` arriving after a `bounced` for 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.

## If a provider lacks them

A provider without webhooks would be polled instead. 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](https://mimeohq.com/docs/reference/write-your-own-adapter) · [Postmark API notes](https://mimeohq.com/docs/api/providers/postmark) · [Resend API notes](https://mimeohq.com/docs/api/providers/resend)
