Resend webhooks are how your backend finds out what happened to an email after you handed it off: delivered, bounced, complained, opened, clicked. We run Resend webhooks in production at GetFluxly, where they feed email analytics and suppression, so this guide is written from the endpoint side rather than the dashboard side. It covers the full event list, what a payload actually looks like, how to verify the Svix signature without the two mistakes that catch almost everyone, why your handler has to be idempotent, how retries behave, and the things about running this in production that no tutorial mentions.
The Resend webhook event types.
Resend organizes webhooks into four families. Email events describe the life of a single message and are what most integrations subscribe to. Contact events (contact.created, contact.updated, contact.deleted) and domain events (domain.created, domain.updated, domain.deleted) cover account level changes and are useful mostly if you manage audiences or domains programmatically. Suppression events (suppression.added, suppression.removed) fire when an address goes onto or comes off your suppression list, which is worth subscribing to if you mirror suppressions in your own database. You choose which events a given webhook receives when you create it, so start narrow and add more as you need them. The email events are the ones worth knowing cold.
| Event | When it fires |
|---|---|
| email.sent | Resend accepted the message and is attempting delivery to the recipient's mail server. |
| email.delivered | The receiving mail server accepted the message. The most reliable positive signal. |
| email.delivery_delayed | A temporary problem is delaying delivery. Resend keeps trying; no action needed yet. |
| email.bounced | The receiving server rejected the message. Carries a bounce object describing why. |
| email.complained | The recipient marked the message as spam. Treat a rising rate as an emergency. |
| email.opened | A tracking pixel loaded. Requires open tracking to be enabled on the domain. |
| email.clicked | A tracked link was followed. Requires click tracking; carries a click object. |
| email.failed | Resend could not send the message at all, distinct from a recipient-side bounce. |
| email.scheduled | A scheduled send was accepted for future delivery. |
| email.suppressed | The send was skipped because the address is on your suppression list. |
| email.received | An inbound message arrived, if you use Resend for receiving mail. |
A note on opens and clicks: email.opened and email.clicked only fire if you enable open tracking and click tracking on the sending domain, and both come with the caveats that make engagement data soft. If your goal is to measure what those signals actually predict rather than just count them, the companion piece on Resend email analytics covers why open rates overcount and how to tie the events to real behavior.
What a webhook payload looks like.
Every event is a JSON object with the same top level shape: a type field naming the event, a created_at timestamp for when the event happened, and a data object with the details. Here is a real email.sent payload, which is the baseline every other email event builds on.
{
"type": "email.sent",
"created_at": "2023-02-22T23:41:12.126Z",
"data": {
"created_at": "2023-02-22T23:41:11.894719+00:00",
"email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
"from": "Acme <onboarding@resend.dev>",
"to": ["delivered@resend.dev"],
"subject": "Sending this example"
}
}Note that data.to is an array, not a string, which trips up handlers that assume a single recipient. The data object carries the email_id you use to correlate the event back to the message you sent, along with from, to, and subject. Specific events extend the data object: email.clicked adds a click object with the link that was followed, a timestamp, the IP address, and the user agent; email.bounced adds a bounce object describing the failure, including whether it was a hard or soft bounce. Read defensively. Providers add fields over time, and a subtype your code has never seen should degrade to a safe default rather than throw.
Verifying the signature with Svix.
Your webhook endpoint is public, so anyone who finds the URL can POST to it. Verification is the only thing standing between a real Resend event and a forged one, and skipping it means an attacker can invent bounce or complaint events and corrupt your send log. Resend signs every webhook through Svix, which means three headers travel with each request: svix-id (a unique message id), svix-timestamp (when it was signed), and svix-signature (the signature itself). The Svix library turns verification into one call.
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" }), // keep the raw bytes
async (req, res) => {
let event;
try {
// req.body is a Buffer here. Verify the buffer, never
// JSON.stringify(req.body): re-serializing changes the bytes
// and the signature will not match.
event = wh.verify(req.body, {
"svix-id": req.get("svix-id"),
"svix-timestamp": req.get("svix-timestamp"),
"svix-signature": req.get("svix-signature"),
});
} catch (err) {
return res.status(401).send("invalid signature");
}
// Acknowledge fast, then process out of band. The svix-id header
// is stable across retries, so use it as the idempotency key.
res.status(200).send("ok");
await enqueue(req.get("svix-id"), event);
},
);Two mistakes cause almost every verification failure, and both pass in a quick local test. The first is verifying the wrong bytes. The signature is computed over the exact raw body Resend sent, so the instant a body parser turns the request into a JavaScript object, the original byte sequence is gone and re-serializing it with JSON.stringify will not reproduce it. That is why the example uses express.raw and hands the buffer straight to verify. In Next.js route handlers, call req.text() and verify the string before you parse it.
The second mistake is ignoring the timestamp. Without a freshness check, anyone who captures one valid payload can replay it forever. The Svix libraries reject timestamps outside a five minute window for you. If you verify by hand, as we do in our Python backend, you have to add that check yourself: parse svix-timestamp, compare it to now, and reject anything outside roughly five minutes in either direction before you even bother computing the HMAC. When we implemented the scheme directly, the details that mattered were stripping the whsec_ prefix and base64 decoding the secret before using it as the HMAC key, signing the string that joins the id, the timestamp, and the raw body with periods, and comparing the v1 token in constant time.
Idempotency: webhooks retry, so dedupe by event id.
A webhook is an at-least-once delivery, not an exactly-once one. Resend will re-send an event if it does not get a timely success, and it may occasionally deliver the same event twice even when your endpoint succeeded, because the acknowledgement got lost on the way back. If your handler increments a counter, sends an internal alert, or writes a row on every delivery, a single retried event quietly doubles it. The handler has to be idempotent.
The clean fix is to dedupe on the svix-id header, which is stable across every retry of a given event. Record the id the first time you process an event, and when an event arrives with an id you have already stored, return a 200 and stop. A small table of processed ids with a time based cleanup is plenty; you do not need to keep them forever, only long enough to cover the retry window. Beyond deduping, design each side effect so that running it twice lands in the same place: prefer upserts over inserts, and set a state rather than toggling it.
Return 2xx fast, process async.
Resend judges your endpoint by the status code and how quickly it comes back. Anything that is not a 2xx counts as a failure and triggers a retry, and a slow handler risks a timeout that also reads as a failure. The rule is to do the minimum synchronously (verify the signature, confirm you have not already seen the event, and acknowledge) and push the real work onto a queue or a background task. The example handler does exactly this: it responds 200 the moment verification passes, then enqueues the event for processing. If your database is slow or a downstream service is down, that should never turn into a wall of webhook retries hammering a struggling system.
When you do return a failure, know what happens next. Because Resend runs on Svix, the retry schedule is fixed: after a failed attempt, delivery is retried after 5 seconds, then 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and once more after another 10 hours. If every attempt fails for five days straight, the endpoint is disabled and you get an operational notification, after which you re-enable it from the dashboard. You can also replay individual events manually from the Resend dashboard, which is the tool you reach for after fixing a bug that failed a batch of handlers.
Testing Resend webhooks locally.
The obstacle to local development is that Resend needs a public URL and your machine does not have one. A tunnel bridges the gap: run ngrok or cloudflared to expose your local endpoint, register a webhook pointing at the tunnel URL, and send yourself a real email so live events flow to your laptop. This is better than crafting fake payloads by hand, because the events are real and, crucially, they are really signed, so you are exercising your verification path rather than mocking around it.
Lean on the dashboard while you iterate. Resend records every delivery attempt along with the status your endpoint returned, so a 401 tells you verification is failing and a timeout tells you the handler is too slow. The replay button lets you re-fire a captured event against your endpoint as many times as you need, which is the fastest loop for debugging a handler. Keep the signing secret in an environment variable even locally, because testing verification against fake signatures tests nothing.
What we learned running this in production.
The first lesson is that webhook delivered state is eventually consistent, not immediate. An email.delivered event can arrive seconds after the send or, when a receiving server is slow, much later, and a delivery delayed event can precede a delivery that still succeeds. Build your data model so a message can sit in an in between state for a while without anything breaking. If a piece of your product blocks on a delivery confirmation that has not landed yet, you will get support tickets that are really just timing.
The second lesson, which is the one that bites hardest, is to never trust event ordering. Webhooks are not guaranteed to arrive in the order the underlying events occurred, and with retries in the mix a delivered event can genuinely show up after the open event for the same message. Do not write logic that assumes sent comes before delivered comes before opened. Key everything on the message id, treat each event as an independent fact that updates state, and use the timestamp inside the payload rather than the order of arrival when you need to reason about sequence. We also return a 2xx for event types we do not act on, so Resend sees a success and does not retry an event we simply chose to ignore, and we reserve the loud 401 strictly for a signature that fails to verify.
If none of this sounds like work you want to own, that is a reasonable conclusion, and it is why we built the consumer once so our customers do not have to. When you connect Resend to GetFluxly, we run the verified endpoint, the deduplication, the retries, and the suppression for you, and the events land on the customer profile automatically.
Resend webhooks, answered.
What are all the Resend webhook event types?
Resend groups its webhook events into four families. The email events cover the full life of a message: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.scheduled, email.suppressed, and email.received for inbound mail. There are also contact events (contact.created, contact.updated, contact.deleted), domain events (domain.created, domain.updated, domain.deleted), and suppression events (suppression.added, suppression.removed) for account level changes. You subscribe to only the events you care about when you create the webhook, so most integrations start with the delivery, bounce, and complaint events and add opens and clicks later.
How do I verify a Resend webhook signature?
Resend delivers webhooks through Svix, so verification follows the Svix scheme. Each request carries three headers: svix-id, svix-timestamp, and svix-signature. The signature is an HMAC-SHA256 computed over the string formed by joining the id, the timestamp, and the raw request body with periods, keyed on your signing secret with its whsec_ prefix stripped and the remainder base64 decoded. The svix-signature header holds one or more space separated tokens like v1,<base64>, and you accept the request if any token matches your computed value using a constant time comparison. The Svix libraries do all of this for you; if you roll your own, those are the exact steps.
What is the most common mistake verifying Resend webhooks?
Verifying against the parsed and re-serialized body instead of the raw bytes. Frameworks love to parse JSON for you, and the moment you call req.json() or let a body parser run, the exact byte sequence Resend signed is gone: key order, whitespace, and number formatting can all shift, and the HMAC no longer matches. You must capture the raw body before anything parses it and verify that. The second most common mistake is skipping the timestamp check, which leaves you open to replay of a captured payload. The Svix libraries enforce a five minute tolerance automatically; a hand rolled verifier has to add it.
Do I need to make Resend webhook handlers idempotent?
Yes. Webhooks retry, and a retry is a re-delivery of an event you may have already processed, so a naive handler will double count. The fix is to dedupe on the event id. The svix-id header is stable across every retry of the same message, which makes it a clean idempotency key: record it when you first process an event, and on any later delivery with an id you have already seen, acknowledge with a 200 and do nothing else. Design every side effect in the handler so that processing the same event twice produces the same result as processing it once.
What is the Resend webhook retry schedule?
Because Resend uses Svix, it follows the Svix retry schedule. If your endpoint does not return a 2xx, delivery is retried after 5 seconds, then 5 minutes, then 30 minutes, then 2 hours, then 5 hours, then 10 hours, and once more after another 10 hours. If every attempt fails for five days, the endpoint is disabled and you receive an operational notification, after which you re-enable it from the dashboard. You can also manually replay individual events from the Resend dashboard, which is useful after you fix a bug that caused a batch of handlers to fail.
How do I test Resend webhooks locally?
Your handler needs a public URL, which your laptop does not have, so the usual approach is a tunnel: run a tool like ngrok or cloudflared to expose your local endpoint, then point a Resend webhook at the tunnel URL and send yourself a test email to generate real events. The Resend dashboard shows every delivery attempt with the response your endpoint returned, and lets you replay events, which is the fastest way to iterate on a handler. Keep your signing secret in an environment variable even in development, since the whole point of the exercise is to test verification against real signatures.
Webhooks are simple in shape and unforgiving in the details. Verify the raw body, check the timestamp, dedupe by event id, answer fast and work async, and treat every event as an out of order, at-least-once fact about a message you can look up by id. Get those five things right and Resend webhooks become a quiet, reliable feed. For the wider context of making transactional mail itself worth sending, see the guide to transactional email best practices.