Skip to content

Email webhooks explained: the events, the payload, and verifying the signature

Published Updated 7 min readBy the Rasket team

A bell shape emitting concentric rings towards the outline of a server rack, drawn as white and violet outlines on black.

What an email webhook is

An email webhook is an HTTPS request the sending platform makes to an endpoint of yours, carrying one event about one message. It is the API calling you instead of you calling the API, and it exists because the interesting part of a send happens after the request that caused it has returned.

A 200 from the send endpoint means the message was accepted. Whether it was delivered, delayed, refused, reported as spam, opened or clicked is decided minutes or hours later by a mail server you do not control. Polling for that is expensive and slow; email delivery events pushed to you are neither.

Webhooks are the reason an id is worth storing

Every event carries the message’s own email_id. Store that id against the order, the invitation or the password reset that caused the send, and a delivery timeline per record is a join rather than a project.

The events, and what fires each one

Subscribe to the ones you will act on rather than to everything. An endpoint that receives open and click events for every broadcast is an endpoint doing a lot of writing for data you may never read.

The email events and what causes each one
EventWhat fires it
email.sentThe message was handed to the mail infrastructure.
email.scheduledA send with a future time was accepted and parked.
email.deliveredThe receiving server accepted the message.
email.delivery_delayedA temporary problem — a full mailbox, a server that is not answering. Delivery is still being attempted.
email.bouncedThe message was refused. The payload says whether that is permanent, transient or undetermined.
email.complainedThe recipient reported the message as spam.
email.openedA tracking pixel in the message was loaded.
email.clickedA tracked link in the message was followed.
email.failedThe send could not be attempted at all.
email.suppressedThe send was stopped because the address is on this team's suppression list.
email.canceledA scheduled send was cancelled before it went out.
email.receivedMail arrived at a domain you receive on.

There are events for domains, suppressions, contacts and automation runs too; the events guide lists every one with its payload. The twelve above are the ones about a message.

What a payload looks like

Every event has the same outer shape — a type, a timestamp and a data object — and data carries the message’s identity plus whatever is specific to this event. Here is a bounce.

{  "type": "email.bounced",  "created_at": "2026-09-09T10:16:44.902Z",  "data": {    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",    "from": "Acme <orders@send.acme.example>",    "to": ["ronald.williams@example.com"],    "subject": "Your order has shipped",    "message_id": "<01000199a3c4d5e6-7f8a9b0c@send.acme.example>",    "created_at": "2026-09-09T10:14:02.118Z",    "tags": { "order": "1042" },    "bounce": {      "type": "Permanent",      "subType": "General",      "message": "smtp; 550 5.1.1 The email account that you tried to reach does not exist."    }  }}

The HTML body is not in there, and deliberately: an event carrying a full message would be a delivery that times out. Fetch the message by id when you need more than the event carries. tags is the field most people underuse — whatever you attach at send time comes back on every event about that message, which is how an event finds its way to the right row without a lookup.

Verifying the signature

Your endpoint is a public URL, so anything on the internet can POST JSON to it. The signature is what makes the difference between an event and a claim. Three headers travel with every delivery.

svix-id: msg_2Yk1QpZ8s3XvL0nRsvix-timestamp: 1789041404svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
  • svix-id — the event’s own identifier, stable across retries and replays.
  • svix-timestamp — Unix seconds. Anything more than five minutes out is refused, which is what stops somebody replaying a delivery they captured last week.
  • svix-signature — one or more signatures, space separated. Any one matching is enough, which is what makes rotating a secret survivable.

Webhook signature verification is the same five steps in every language, and it is worth reading once even if a library does it for you.

verify(rawBody, headers, secret) -> payload
  1. require svix-id, svix-timestamp, svix-signature  2. reject if |now - timestamp| > 300 s  3. expected = base64(hmac_sha256(       base64decode(secret without the whsec_ prefix),       `${id}.${timestamp}.${rawBody}`     ))  4. for each "v1,<sig>" in svix-signature (space separated):       constant-time compare against expected  5. any match -> JSON.parse(rawBody); none -> throw

Step three is the one that catches people. The signature covers the id, the timestamp and the raw body, joined by full stops. If your framework parsed the JSON before you got to it, the string you hash is not the string that was signed, and nothing will ever match. Read the raw bytes first, verify, and parse afterwards.

import { verify } from "@rasket/webhook-verify";
export async function POST(request: Request): Promise<Response> {  const rawBody = await request.text(); // the bytes we signed
  let event;  try {    event = verify(rawBody, request.headers, process.env.RASKET_WEBHOOK_SECRET);  } catch {    return new Response("invalid signature", { status: 400 });  }
  // Deduplicate on the event's own id, which is stable across retries and replays.  const eventId = request.headers.get("svix-id");  const inserted = await recordOnce(eventId, event);  if (inserted) await enqueue(event);
  return new Response(null, { status: 200 });}

The verifier is its own package on purpose: it imports node:crypto and nothing else, so an endpoint that only needs to check a signature does not have to install a client library to do it.

Retries, duplicates and order

Anything that is not a 2xx within ten seconds is a failed delivery. We try again up to ten times, spread over roughly a day with an increasing delay and jitter on each, and a redirect counts as a failure because we do not follow one.

The consequence is that duplicates are normal. A handler that did its work and then timed out will be sent the same event again, because we never saw the answer. Deduplicate on svix-id, write the event row inside a transaction with a unique constraint on it, and make everything downstream safe to run twice anyway.

Order is not guaranteed either. Two events about the same message can arrive in the wrong sequence, and after a retry they frequently do. Do not write a state machine expecting sent, then delivered, then opened. Write each event with its own timestamp and derive the current state from the rows.

Answer first, work later

The single most useful shape for a handler is: verify, insert, answer 2xx, enqueue. A handler that renders a PDF before answering will be delivered the same event again while it is still rendering, and you will have two PDFs and a confusing bug report.

Replaying an event you lost

After the tenth attempt we stop and mark the event failed. It is not gone: the exact payload we signed, every attempt we made, the status code that came back and the first part of your response all stay readable in the dashboard and through the API. When the handler is fixed, replay it — a replay carries the identical payload under the same event id, which is exactly why deduplicating on that id has to be idempotent rather than a rejection.

Events that arrive while an endpoint is switched off are parked rather than dropped, and can be delivered as a backlog once it is on again. That is the difference between a deployment window and a hole in your data.

Building a delivery timeline

The shape that repeats across every product doing this well is two tables. One row per event, keyed by the event id, holding the type, the message id, the timestamp and the raw payload. One row per message in your own domain — the order, the invitation — holding the message id you stored at send time.

Everything a support conversation needs is then a join. Did the receipt arrive? Select the events for that order’s message id, ordered by their own timestamps. Did it bounce, and permanently? The bounce type is in the payload you kept. Was it opened? If you enabled tracking, the event is there.

Two more things worth wiring the day you build this. Act on email.bounced and email.complained immediately — those belong on the suppression list, and the bounce article covers which is which. And alert on the failure rate of your own endpoint, because a webhook that has been returning 500 for a week is a week of missing history nobody noticed. The webhooks page has the retry schedule and the replay controls, and the reference documents every route.

Frequently asked questions

Why must I read the raw body before parsing it?

Because the signature is computed over the exact bytes that were sent. Parsing JSON into an object and serialising it again is not the identity function: key order changes, whitespace changes, number formatting can change. Verify against the raw string or buffer first, and parse only after the signature matches.

What happens if my endpoint is down?

The delivery is retried. Rasket makes up to ten attempts spread over roughly a day with an increasing delay and jitter on each, and anything that is not a 2xx within ten seconds counts as a failure. After the tenth attempt the event is marked failed, and it is still readable and replayable afterwards.

Can the same event arrive twice?

Yes, and you should assume it will. A handler that timed out after doing its work still gets retried, because we never saw the 2xx. Deduplicate on the svix-id header, which is stable across retries and across a manual replay, and make the handler safe to run twice.

Do events arrive in order?

No. Two events about the same message can be delivered out of order, and after a retry they frequently are. Treat your handler as a fold over a stream rather than a state machine expecting a sequence: write each event with its own timestamp and derive the current state from the rows rather than from the arrival order.

How do I rotate a signing secret without dropping events?

Rotate and verify against both secrets for a window. The signature header can carry more than one signature, and a delivery is valid if any one of them matches, which is what makes an overlap possible. Once nothing in flight is signed with the old secret, stop accepting it.

What should my handler return?

A 2xx, as fast as it can. Store the event, answer, and do the real work afterwards in a queue or a background job. A redirect counts as a failure because we do not follow one, and a handler that does ten seconds of work before answering will be retried while it is still working.

Is the payload the whole message?

No. An event carries the message's id, its addresses, its subject, its tags and whatever is specific to the event — a bounce type, a clicked link, a delay reason. The HTML body is not in an event. Fetch the message by id if you need more than the event carries.

Sources

  1. RFC 2104: HMAC — Keyed-Hashing for Message AuthenticationIETF, read 2026-09-16
  2. Standard WebhooksStandard Webhooks, read 2026-09-16
  3. How to verify webhook payloadsSvix, read 2026-09-16
  • WebhooksEvery delivery, bounce, complaint, open and click posted to your endpoint, signed with a timestamp, retried on failure and replayable from the dashboard.
  • EventsEvery event a webhook can carry, with one real payload each.
  • WebhooksPayloads, signature verification, retries and replay.
  • Receive email with a webhook: inbound parsingPoint a domain's MX record at Rasket and incoming mail arrives as a signed JSON event: publishing the record, reading the payload, attachments and routing.
  • Hard bounce vs soft bounce: what to do with eachA hard bounce is permanent, a soft bounce is temporary. What causes each, what the reply code tells you, when to retry, and when to suppress an address.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.