Resend webhooks
See exactly when an email bounced, opened, or was marked as spam.
Get a test URL
claude mcp add --transport http webhook-studio https://webhook-studio.com/mcp \
--header "Authorization: Bearer $(curl -s -X POST https://webhook-studio.com/api/start | jq -r .api_token)"Point Resend at this URL
- Create a test endpoint below and copy its URL.
- In the Resend dashboard, open Webhooks → Add Webhook.
- Paste the Webhook Studio URL and select the events you want (email.delivered, email.bounced are the usual first two).
- Copy the signing secret — it starts with whsec_.
- Send yourself a test email through Resend and watch the lifecycle events land in the Inbox.
What a Resend delivery looks like
A representative body, using Resend’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.
{
"type": "email.delivered",
"created_at": "2026-07-21T09:14:02.114Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"created_at": "2026-07-21T09:13:58.226Z",
"from": "notifications@example.com",
"to": [
"alex@example.com"
],
"subject": "Your receipt from Example",
"tags": [
{
"name": "category",
"value": "receipt"
}
]
}
}Delivery headers
| Header | What it carries |
|---|---|
svix-signature | Space-delimited "v1,<base64>" signatures. Multiple entries appear during a secret rotation; any match is valid. |
svix-id | Unique delivery id, stable across retries — the idempotency key, and part of the signed string. |
svix-timestamp | Unix seconds, also signed. Compare against your clock to bound replay attacks. |
User-Agent | Svix-Webhooks/<version> — Resend delivers through Svix, like Clerk. |
Event types
The events worth handling first. See Resend’s own documentation for the complete list.
| Event | Fires when |
|---|---|
email.sent | The API call succeeded and delivery will be attempted. Not proof of delivery. |
email.delivered | The recipient's mail server accepted the message. This is the real success signal. |
email.bounced | The recipient's server permanently rejected it. Stop sending to this address. |
email.delivery_delayed | A temporary failure; Resend will keep trying. |
email.complained | Delivered, then marked as spam. Treat as an unsubscribe — it damages sender reputation. |
email.opened | The recipient opened the message. Noisy: prefetching mail clients inflate this. |
email.clicked | The recipient clicked a link. |
email.failed | Sending failed outright due to an error. |
suppression.added | An address was added to the suppression list after a hard bounce, complaint, or manually. |
contact.created | A contact was added to an audience. |
Verify the svix-signature header
Resend signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.
import express from 'express';
import crypto from 'crypto';
const app = express();
app.post('/webhooks/resend', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('svix-id');
const ts = req.get('svix-timestamp');
const header = req.get('svix-signature') ?? '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
// whsec_<base64> — decode the part after the prefix to get the key bytes.
const key = Buffer.from(process.env.RESEND_WEBHOOK_SECRET.split('_')[1], 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${ts}.${req.body}`)
.digest('base64');
const ok = header.split(' ').some((part) => {
const sig = part.startsWith('v1,') ? part.slice(3) : null;
if (!sig) return false;
const a = Buffer.from(expected, 'base64');
const b = Buffer.from(sig, 'base64');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(401);
res.sendStatus(200);
const event = JSON.parse(req.body);
// if (event.type === 'email.bounced') suppress(event.data.to)
});Retries and delivery guarantees
Resend delivers through Svix. A non-2xx response is retried at 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, and if every attempt to an endpoint fails across a 5-day window the endpoint is disabled. Delivery is explicitly at-least-once and not ordered — email.delivered can arrive before the email.sent it follows — so deduplicate on svix-id and sort by the payload's own created_at when sequence matters.
Common pitfalls
email.sent only means the API accepted the request. The message can still bounce. Any "delivered" state in your UI should be driven by email.delivered, and a bounce can arrive seconds or minutes later.
Resend does not guarantee ordering, so email.opened can land before email.delivered. Sort on the created_at in the payload rather than on receipt time, or your lifecycle view will show impossible sequences.
As with Clerk, the HMAC key is the base64-decoded bytes after whsec_, and the signed string is id.timestamp.body rather than the body alone. Stripe's identically prefixed secret behaves differently, and porting that code silently fails every check.
Open tracking relies on a pixel, and privacy-preserving mail clients fetch it without a human present. email.opened is directional at best; email.clicked is the harder signal.
FAQ
Create a test endpoint on this page and add its URL in the Resend dashboard under Webhooks. Send yourself one email and the whole lifecycle — sent, delivered, opened — appears in the Inbox as separate events, with no code deployed and no account required.
Resend uses Svix, so the HMAC key is the base64-decoded bytes after the whsec_ prefix, and the signed string is svix-id, svix-timestamp and the raw body joined by full stops. Verifiers that use the secret verbatim, or sign only the body, fail on every delivery.
Resend delivers at-least-once and without ordering guarantees. Order your own timeline by the created_at field inside the payload rather than by when each webhook arrived.
email.bounced means the receiving server permanently rejected the message. email.complained means it was delivered and the recipient marked it as spam. Both should stop future sends to that address, but only the complaint indicates the recipient took action.
Yes. Add a routing rule pointing at your local tunnel and matching events are forwarded with retries while still being stored here — useful because email lifecycle events trickle in over minutes, long after a dev server has been restarted.