Concepts

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

EventWhen
email.sentWe handed the message to the delivery provider
email.deliveredThe receiving server accepted it
email.delivery_delayedTemporary failure; the provider is still retrying
email.bouncedPermanently rejected
email.complainedMarked as spam
email.openedTracking pixel loaded
email.clickedA tracked link was followed
email.unsubscribedThe recipient opted out
email.suppressedA send was refused by your suppression list
email.failedWe could not send it at all
email.canceledA scheduled send was cancelled before it went out
webhook.testSynthetic, 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:

HeaderMeaning
webhook-idUnique per delivery. Stable across retries — dedupe on this
webhook-timestampUnix seconds
webhook-signaturev1,<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-id is 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 opened before a delivered is routine), deliveries run concurrently, and a retried delivery lands behind newer ones. Sort on occurred_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 | failed

That 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:

AttemptWait
25s
330s
42m
510m
645m
73h
86h
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.

On this page