Resend webhooks: verify signatures and never miss a bounce or delivery event
Checked against Resend's docs: 2 November 2026. Resend's event list and retry schedule change from time to time, so check Resend's own docs before you rely on a detail.
Resend sends webhooks through Svix. Each POST carries three headers, svix-id, svix-timestamp and svix-signature. To trust one, verify those headers against the raw request body using your endpoint's whsec_ signing secret. Then return a 2xx quickly and dedupe on svix-id, because Resend's retries reuse it. Below: which events to handle, verification code (Next.js, Express, Python, by hand), a checklist for missing webhooks, and how to never lose a bounce. We make SolidHooks, and we've tried to be fair about the alternatives, including Resend's own.
How Resend webhooks work
A Resend webhook is an HTTPS POST to an endpoint you own, sent when something happens to an email, domain, contact or inbox. You can create and manage webhooks from the dashboard, the API, an SDK, the Resend CLI or Resend's MCP server (Resend: webhooks introduction).
Setting one up takes two steps:
- 1.Create an endpoint in your app that accepts POST requests.
- 2.Add a webhook in Resend with that URL and the event types you want.
Each webhook has its own signing secret, shown on the webhook's details page. The create, retrieve and list webhook API calls also return it in the response body (Resend: verify webhook requests). Keep it in an environment variable such as RESEND_WEBHOOK_SECRET, and never log it.
Which Resend webhook events to handle
Most apps act on the email events. Meanings below are from Resend's event types page.
| Event | What Resend says it means | What to do |
|---|---|---|
email.delivered | Delivered to the recipient's mail server | Mark the message delivered |
email.delivery_delayed | Temporary issue, such as a full inbox | Don't alert yet |
email.bounced | The recipient's mail server permanently rejected it | Suppress the address in your own app |
email.complained | Delivered, then marked as spam | Unsubscribe them |
email.failed | Failed to send, e.g. invalid recipients, API key, domain or quota problems | Fix your sending setup |
email.sent, opened, clicked, scheduled, suppressed | Sending, engagement and suppression updates | Record as needed |
email.received | Resend received an inbound email | Hand it to your inbound handler or agent |
Resend also sends domain, contact, suppression, topic and inbox events (such as inbox.email.received). Select only the events you handle.
Verifying a Resend webhook signature
Resend signs each webhook with a key specific to the endpoint. Verifying it proves the request came from Resend and wasn't changed on the way. The timestamp also limits replay attacks, where someone resends a captured valid request (Resend: verify webhook requests).
The scheme is Svix's, and it's the same as Standard Webhooks. Only the header prefix differs: Svix uses svix-, Standard Webhooks uses webhook- (Svix: manual verification). Here is the method:
- 1.Take the part of the secret after
whsec_and base64-decode it. The raw bytes are the key, not the string. - 2.Build the signed content:
{svix-id}.{svix-timestamp}.{raw body}. - 3.Compute HMAC-SHA256 with that key and base64-encode the result.
- 4.
svix-signatureis a space-separated list such asv1,<sig> v1,<sig>. Remove thev1,prefix and accept the request if any entry matches, using a constant-time comparison. - 5.Reject timestamps too far from now. Five minutes either way is the usual window.
The step that trips most people up is the raw body. Resend warns that the signature is "sensitive to even the slightest change". If a framework parses the JSON and you verify a re-stringified copy, verification will fail.
About these snippets. They're our own examples, written for this page. We ran each one on 2 November 2026 (Node 24,resend6.37.0,svix2.9.1,standardwebhooks1.1.1, Python 3 with Flask) against requests signed with test secrets. We tried a valid request, a tampered body, the wrong secret, a 10-minute-old timestamp and a two-signature rotation list. Each snippet gave the expected result. They are not production code, so test them in your own stack.
The Resend SDK (Next.js App Router)
Resend's SDK has resend.webhooks.verify(). In an App Router route handler, read the body with req.text() and the headers with req.headers.get():
// app/api/webhooks/resend/route.tsimport { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY); export async function POST(req: Request) { // 1. Read the raw body. Never req.json() before verifying. const payload = await req.text(); let event; try { // 2. Throws if the signature or timestamp is invalid event = resend.webhooks.verify({ payload, headers: { id: req.headers.get('svix-id') ?? '', timestamp: req.headers.get('svix-timestamp') ?? '', signature: req.headers.get('svix-signature') ?? '', }, webhookSecret: process.env.RESEND_WEBHOOK_SECRET!, }); } catch { return new Response('Invalid signature', { status: 400 }); } // 3. Hand off and answer quickly; dedupe later on svix-id await enqueue(req.headers.get('svix-id')!, event); return new Response(null, { status: 204 });}
enqueue stands for your own queue or database insert. Keep the slow work out of the request.
The svix library (Express)
You can also use the svix package that Resend's docs point to. In Express, apply express.raw() to this route only, so the body stays as bytes:
import express from 'express';import { Webhook } from 'svix'; const app = express();const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET!); app.post( '/webhooks/resend', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); try { // Throws if invalid. Recent svix versions return nothing, // so parse the JSON yourself afterwards. wh.verify(raw, { 'svix-id': req.header('svix-id') ?? '', 'svix-timestamp': req.header('svix-timestamp') ?? '', 'svix-signature': req.header('svix-signature') ?? '', }); } catch { return res.status(400).send('Invalid signature'); } const event = JSON.parse(raw); switch (event.type) { case 'email.bounced': case 'email.complained': // add the recipient to your suppression list break; } res.sendStatus(204); },);
One catch we found while testing: in svix 2.9.1, verify() is typed to return undefined. Code that does const event = wh.verify(...) and then reads event.type crashes. Parse the body yourself once it has verified.
By hand (Node.js)
You don't need a library, and writing it once makes the method clear:
import { createHmac, timingSafeEqual } from 'node:crypto'; const TOLERANCE_SECONDS = 5 * 60; export function verifyResendWebhook( rawBody: string, headers: { id: string; timestamp: string; signature: string }, secret: string, // "whsec_..."): boolean { const ts = Number(headers.timestamp); if (!Number.isInteger(ts)) return false; if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false; // The key is the base64 part after "whsec_", decoded to bytes const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key) .update(`${headers.id}.${headers.timestamp}.${rawBody}`) .digest(); // Space-separated list such as "v1,abc= v1,def="; any v1 may match return headers.signature.split(' ').some((entry) => { const [version, sig] = entry.split(','); if (version !== 'v1' || !sig) return false; const given = Buffer.from(sig, 'base64'); return given.length === expected.length && timingSafeEqual(given, expected); });}
By hand (Python, Flask)
import base64, hashlib, hmac, os, timefrom flask import Flask, request, abort app = Flask(__name__)SECRET = os.environ["RESEND_WEBHOOK_SECRET"] # "whsec_..."TOLERANCE_SECONDS = 300 def verify(raw_body: bytes, msg_id: str, timestamp: str, signature: str) -> bool: try: ts = int(timestamp) except ValueError: return False if abs(time.time() - ts) > TOLERANCE_SECONDS: return False key = base64.b64decode(SECRET.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for entry in signature.split(" "): version, _, sig = entry.partition(",") if version == "v1" and hmac.compare_digest(sig, expected): return True return False @app.post("/webhooks/resend")def resend_webhook(): raw = request.get_data() # bytes, before any JSON parsing h = request.headers if not verify(raw, h.get("svix-id", ""), h.get("svix-timestamp", ""), h.get("svix-signature", "")): abort(400) event = request.get_json() # queue the event, keyed on h["svix-id"] return "", 204
Things not to do: use express.json() on this route; JSON.stringify a parsed body and verify that; compare signatures with ==; check only the first signature in the list; use the whsec_... string itself as the HMAC key; or log the secret.
Resend webhook not received or failing? A checklist
Work through these in order:
- 1.Is the endpoint public and HTTPS? Resend can't reach
localhost. During development, use the Resend CLI's webhook listener instead (Resend: introduction). - 2.Is the event type selected? A webhook only receives the events you chose when you created it.
- 3.Has Resend disabled the endpoint? If an endpoint keeps failing, Resend disables it automatically and emails your team. Check the Webhooks page and re-enable it once the endpoint is fixed (Resend: retries and replays).
- 4.Are you returning a 2xx, and fast? Do the slow work after you respond. Any non-2xx counts as a failure and starts the retry schedule.
- 5.Is something in front of the route rejecting it? Auth middleware, a firewall, a WAF or a bot-protection rule can block the POST before your code runs.
- 6.Was the body parsed before you verified it? This is the most common cause of a failed signature check.
- 7.Are you using the right secret? Each webhook has its own secret, so check it isn't from another endpoint or environment, and that it hasn't been rotated. (Resend: Webhook Ingester troubleshooting).
- 8.Is the timestamp outside your tolerance? Check your server clock for skew.
- 9.Then replay what you missed from the dashboard (see below).
A useful debugging step: log the length of the raw body and compare it with Content-Length. If they differ, something changed the body before you hashed it.
Never miss an event: retries, replay and storage
Resend's retries. According to Resend's docs (checked 2 November 2026), Resend tries each message immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and a further 10 hours. Each gap starts after the previous attempt fails, so the gaps add up to about 27 and a half hours (27 h 35 min 5 s) between the first and last attempts, plus however long each failed attempt takes. That is your window to recover, provided Resend hasn't disabled the endpoint first.
Replay. From the dashboard you can replay a failed or succeeded message. Open the webhook, find the message and click Replay.
Idempotency. Retries keep the same svix-id (Resend: event types), so the same event can reach you more than once. Store svix-id with a unique constraint and skip anything you've already processed.
Store first, process second. The safest pattern is to write the verified event to durable storage before you act on it, and then process it from there. Resend recommends this too.
Doing this with SolidHooks
SolidHooks is a managed inbound webhook receiver. It has a built-in resend provider that uses its Standard Webhooks verifier (SolidHooks: sources, verification). You name the provider and give your whsec_ secret, and SolidHooks handles the rest of the scheme:
- Svix header names accepted. Both
svix-*andwebhook-*headers are accepted. - Safe during secret rotation. Any
v1entry in the list may match.v1a(ed25519) entries are skipped, not verified. - Timestamp tolerance. The default is 300 seconds either way. You can set
toleranceSecondsfrom 1 to 86,400. - Dedupe on by default. The
resendpreset defaultsidempotencyKeysto["headers.svix-id"]. If your Resend account white-labels its headers, set["headers.webhook-id"]instead (SolidHooks: idempotency). - Rejected requests are kept for diagnosis. Requests that fail verification get a 401 and are stored as rejected events with a reason (e.g.
signature_mismatch), never delivered or billed. Recording is rate-limited per source, so during a flood of bad requests the rejected-event log is a representative sample, not a record of every 401.POST /v1/sources/{id}/test-signatureexplains a failure (verification).
SolidHooks doesn't create the webhook in Resend for you. You paste its ingest URL into Resend yourself.
Steps
1. Create a resend source with your signing secret (request shape from SolidHooks: verification):
curl -s "$API/v1/sources" \ -H "authorization: Bearer $SDHK_KEY" \ -H 'content-type: application/json' \ -d '{ "name": "Resend email events", "provider": "resend", "verifierConfig": { "secret": "<your-resend-whsec-secret>" } }'
The response includes the source's ingestUrl, which looks like https://sdhk.events/<maskId> (SolidHooks: quickstart).
2. Paste the ingestUrl into Resend as the webhook endpoint, and select your events.
3. Create a destination and a connection from the source to your app or agent endpoint, as in the quickstart. Connections can filter by event type. With no filter, every event is forwarded.
4. Verify SolidHooks' own signature at your endpoint. New destinations sign deliveries with Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature). The secret comes from GET /v1/destinations/{id}/signing-secret (SolidHooks: verifying deliveries):
import express from 'express';import { Webhook } from 'standardwebhooks'; const app = express();// Secret from GET /v1/destinations/{id}/signing-secretconst wh = new Webhook(process.env.SOLIDHOOKS_SIGNING_SECRET!.replace(/^whsec_/, '')); app.post('/hooks/email-events', express.raw({ type: '*/*' }), (req, res) => { try { wh.verify(req.body, { 'webhook-id': req.header('webhook-id') ?? '', 'webhook-timestamp': req.header('webhook-timestamp') ?? '', 'webhook-signature': req.header('webhook-signature') ?? '', }); } catch { return res.sendStatus(400); // SolidHooks will retry } const event = JSON.parse(req.body.toString('utf8')); // dedupe on webhook-id, then process the event res.sendStatus(204);});
What happens when your app is down
SolidHooks accepts and stores the event from Resend whether or not your app is up. Resend gets its 2xx and has no reason to disable your endpoint. SolidHooks then retries delivery to your app. The first retry comes after 5 seconds, and the gaps widen from there. Hobby gets 3 attempts within 1 hour; Launch, Scale and Enterprise get 8 attempts over 36 hours (SolidHooks: retries). Automatic retries, and a manual retry of a single delivery, are free and don't count against your quota.
If an event needs to go through again after that, for example to a connection you've just added, you can replay it from the stored event log. A replay creates new deliveries, and each one is billed (SolidHooks: replay). Replays arrive with a new webhook-id, so plan your dedupe key with that in mind.
Triggering an email agent from Resend
If you're building an agent that reads and answers email, the Resend events to listen for are email.received and the inbox events, such as inbox.email.received. Resend's docs make two points that matter here (Resend: event types):
- Inbox event objects are loaded when the webhook is sent, not when the change happened. Two quick changes can both show the second state.
emailanddraftobjects in inbox events leave outhtmlandtext. Your agent has to fetch the body from Resend's API.
The flow is: Resend event → verified, stored, deduplicated event → your agent's endpoint, with retries if the agent is busy or down. SolidHooks is built to be set up by agents as well as people. It has a REST API, an llms.txt and a hosted MCP server, so an agent can create the source and connection itself.
One thing not to confuse: SolidHooks also has an email provider that gives a source its own address and turns each message it receives into an event (SolidHooks: email ingestion). That is SolidHooks receiving email directly. It is a different thing from receiving Resend's email.received webhook through the resend provider.
Other ways to do it
- Your own handler. A snippet above plus a table keyed on
svix-id; you own storage, retries and monitoring. - Resend Webhook Ingester. Resend's open-source Next.js app that verifies and stores events in your own database, such as Postgres, MySQL or BigQuery (Resend, checked 2 November 2026). Good for a self-hosted archive.
- Vercel Connect triggers. According to Vercel (29 October 2026), Connect registers the Resend webhook for you, verifies requests and forwards events to your Vercel project.
- Trigger.dev webhooks (beta). According to Trigger.dev, Resend works via
webhooks.svix(), pitched for "email management agents that respond to Resend events".
FAQ
Receive, verify and keep every Resend event
The Hobby plan is free for 10,000 deliveries a month. Launch is $29 a month for 50,000, and Scale is $375 a month for 1,000,000 (pricing, plans docs, checked 2 November 2026). Start free, or let your agent set up the pipeline.