Webhooks
Receive email events at your own endpoint instead of polling — signing, retries, and the delivery guarantees.
Webhooks push email events to an HTTPS endpoint you control. POST /api/v1/emails
returns 202 Accepted and hands the message to a queue, so the outcome —
delivered, bounced, opened — arrives later. Webhooks are how you learn it
without polling GET /api/v1/emails/{id}.
Events
| Event | When |
|---|---|
email.sent | We handed the message to the delivery provider |
email.delivered | The receiving server accepted it |
email.delivery_delayed | Temporary failure; the provider is still retrying |
email.bounced | Permanently rejected |
email.complained | Marked as spam |
email.opened | Tracking pixel loaded |
email.clicked | A tracked link was followed |
email.unsubscribed | The recipient opted out |
email.suppressed | A send was refused by your suppression list |
email.failed | We could not send it at all |
email.canceled | A scheduled send was cancelled before it went out |
webhook.test | Synthetic, from POST /api/v1/webhooks/{id}/test |
Subscribe to webhook.test as well as the events you want. It lets you verify
your signature check works before any real mail depends on it.
The payload
{
"id": "whd_44b1e0",
"type": "email.bounced",
"version": "1",
"created_at": "2026-08-10T09:00:02.000Z",
"occurred_at": "2026-08-10T09:00:01.500Z",
"data": {
"event_id": "evt_88c3a1",
"message": {
"id": "msg_3aF9c1",
"to": "[email protected]",
"from": "Acme <[email protected]>",
"subject": "Your receipt",
"template": "receipt",
"status": "bounced",
"tags": [{ "name": "order_id", "value": "1234" }],
"source": "api",
"created_at": "2026-08-10T08:59:00.000Z",
"sent_at": "2026-08-10T08:59:01.000Z"
},
"detail": { "type": "Permanent", "diagnostic": "550 5.1.1 unknown recipient" }
}
}detail carries a small, normalised set of fields per event type — the same
shape whichever delivery provider produced it. Provider-specific payloads are
deliberately not forwarded.
Verifying a delivery
Every request carries three headers:
| Header | Meaning |
|---|---|
webhook-id | Unique per delivery. Stable across retries — dedupe on this |
webhook-timestamp | Unix seconds |
webhook-signature | v1,<base64> — sometimes several, space-separated |
The signature is HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body},
keyed by your secret. This is the Standard Webhooks
scheme, so any off-the-shelf verifier works:
import { Webhook } from "standardwebhooks";
export async function POST(req: Request) {
// The RAW body. Parsing and re-serialising changes the bytes and the
// signature will never match — the most common integration mistake.
const body = await req.text();
const wh = new Webhook(process.env.SENDANDRETAIN_WEBHOOK_SECRET!);
let event;
try {
event = wh.verify(body, Object.fromEntries(req.headers));
} catch {
return new Response("bad signature", { status: 400 });
}
await enqueue(event); // 2xx fast; do the work asynchronously
return new Response(null, { status: 204 });
}Your secret looks like whsec_…. It is shown once, when you create the
endpoint or rotate its secret. If you lose it, rotate — there is no endpoint
that returns it again.
Rotating without downtime
POST /api/v1/webhooks/{id}/rotate-secret mints a new secret and keeps the old
one valid for a grace window (24h by default). During the window every delivery
carries both signatures, so deployed-and-not-yet-deployed consumers both
verify. Deploy the new secret whenever you like, then let the old one expire.
Delivery guarantees
At-least-once, unordered. Both halves matter:
- Duplicates happen. Retries, and our own reconciliation sweep, can deliver
the same event twice.
webhook-idis stable across every attempt — store it and no-op on a repeat. - Order is not guaranteed. Delivery providers themselves report events out
of order (an
openedbefore adeliveredis routine), deliveries run concurrently, and a retried delivery lands behind newer ones. Sort onoccurred_at, never on arrival.
For message status specifically, treat it as a ladder that only ever moves forward, and ignore anything that would move it back:
queued → sending → sent → delivered → opened → clicked
↘ bounced | complained | failedThat two-line rule turns "unordered" from a caveat into something you can code against.
Retries
A delivery is retried on any non-2xx, a timeout (10s), or a connection failure:
| Attempt | Wait |
|---|---|
| 2 | 5s |
| 3 | 30s |
| 4 | 2m |
| 5 | 10m |
| 6 | 45m |
| 7 | 3h |
| 8 | 6h |
| — | then 12h before giving up |
Eight attempts over roughly 22 hours. Return 2xx quickly and do slow work
asynchronously; a handler that takes longer than 10s reads as a failure.
Two responses are special:
410 Gone— we stop immediately and disable the endpoint. Use it when an integration is retired.- Sustained failure — after 20 consecutive failed deliveries and 24 hours
with no success, the endpoint is disabled automatically. Re-enable it with
PATCH /api/v1/webhooks/{id}once it is fixed; that also clears the counter.
Redirects are not followed. A 3xx counts as a failure — point the endpoint
at its final URL.
When something does not arrive
GET /api/v1/webhooks/{id}/deliveries is the log: every attempt, its status
code, how long it took, and the first 2 KB of your own response body. That
snippet is usually the whole answer.
To resend one, POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay. It
replays the original payload byte-for-byte with the same webhook-id, so a
consumer that already processed it will dedupe it away — replay is for
deliveries you never processed successfully.
Endpoint requirements
- HTTPS, on the default port.
- Publicly resolvable. Private ranges, localhost, link-local and carrier-grade NAT addresses are refused — at save time and again before every delivery, so a hostname whose DNS changes later stops receiving.
- No credentials in the URL. Authenticate with the signature instead.
For local development, use a tunnel (ngrok, Cloudflare Tunnel) rather than a private address.