Resend signs every webhook through Svix, so verification means checking three headers against a whsec_ secret from your dashboard. In Node, that is one call to Webhook.verify() from the svix package, and the single mistake that breaks almost every implementation is passing it a parsed body instead of the raw one. In any other language it is an HMAC-SHA256 over the string `${id}.${timestamp}.${rawBody}`. This guide covers both, plus the replay window, the retry schedule that follows a rejected request, and how to stay idempotent.
Why an unverified endpoint is a real problem.
Your webhook URL is public. Anyone who finds it can POST to it, and without verification your handler will believe them. That is not a theoretical risk when the events in question drive suppression lists and analytics: a forged email.bounced event lets someone suppress an address you needed to reach, and a stream of forged email.complained events can poison the numbers you use to judge deliverability. Verification is the only thing separating a real Resend event from an invented one.
This post is the verification deep dive. If you want the wider tour of what events exist and what their payloads carry, the guide to Resend webhooks covers the full event list and the payload shape.
The three headers and the whsec_ secret.
Every delivery carries three headers. Together with your endpoint secret they are everything verification needs.
svix-id: msg_2gT6q8kQ1nX4rZ9wLpVbCdEf svix-timestamp: 1774785600 svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
svix-id is the unique id of the message. It is stable across every retry of the same event, which makes it your idempotency key. svix-timestamp is a Unix timestamp in seconds, recording when the message was signed. svix-signature is the signature itself, formatted as v1, followed by a base64 value. It can hold more than one token separated by spaces, which happens during a secret rotation, so treat it as a list and accept the request if any token matches.
The secret comes from the webhook endpoint in the Resend dashboard and looks like whsec_ followed by base64. Store it in an environment variable, one per endpoint, and never in the code.
Verifying in Node with the svix package.
Install it with npm i svix. Construct one Webhook instance with your secret and call verify() per request. It throws when anything is wrong and returns the parsed payload when everything is right, so the whole check is a try block.
import express from "express";
import { Webhook } from "svix";
const app = express();
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET); // whsec_...
app.post(
"/webhooks/resend",
express.raw({ type: "application/json" }), // keep the raw bytes
async (req, res) => {
let event;
try {
// verify() throws on a bad signature, a missing header, or a
// timestamp outside the five minute window. It returns the
// parsed payload when everything checks out.
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");
}
res.status(200).send("ok");
await enqueue(req.get("svix-id"), event); // dedupe key
},
);Note that the response goes out before the work happens. Resend judges your endpoint on the status code and the time it takes, so verify, acknowledge, and then process out of band.
The raw body is the whole game.
If verification fails while your secret is definitely correct, this is why. The signature is computed over the exact bytes Resend sent. The instant a JSON body parser runs, those bytes are replaced by a JavaScript object, and no amount of re-serializing gets them back: JSON.stringify() can reorder keys, change whitespace, and normalize number formatting, and any one of those changes produces a different HMAC.
In Express, that means mounting express.raw() on the webhook route, before any global express.json() middleware can touch it, and handing the resulting buffer to verify(). In a Next.js route handler, it means calling req.text() and never req.json().
// app/api/webhooks/resend/route.js
import { Webhook } from "svix";
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET);
export async function POST(req) {
const raw = await req.text(); // NOT req.json()
const headers = {
"svix-id": req.headers.get("svix-id"),
"svix-timestamp": req.headers.get("svix-timestamp"),
"svix-signature": req.headers.get("svix-signature"),
};
let event;
try {
event = wh.verify(raw, headers);
} catch {
return new Response("invalid signature", { status: 401 });
}
// event is the parsed payload; raw is what was signed.
return Response.json({ ok: true });
}The tell for this bug is that everything works when you fire a handcrafted request through curl and fails on every real delivery, or the reverse. If you need the parsed object as well as the raw body, parse the raw string yourself after verification passes, or use the value verify() returns.
Verifying by hand, for stacks without a Svix library.
Svix publishes libraries for several languages, but if yours is not one of them, or you would rather not add a dependency to a twenty line check, the scheme is short. Base64 decode the part of the secret after the underscore to get the key. Join the message id, the timestamp, and the raw body with periods. HMAC-SHA256 that with the key, base64 encode the digest, and compare it in constant time against each token in the signature header.
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify(secret: str, headers: dict, raw_body: str) -> bool:
msg_id = headers["svix-id"]
timestamp = headers["svix-timestamp"]
signature_header = headers["svix-signature"]
# Check freshness before spending any time on crypto.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
# The secret looks like whsec_<base64>. Strip the prefix,
# base64 decode the rest, and use those bytes as the HMAC key.
key = base64.b64decode(secret.split("_", 1)[1])
signed_content = f"{msg_id}.{timestamp}.{raw_body}".encode()
expected = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()
# The header may carry several space separated tokens.
for token in signature_header.split(" "):
version, _, signature = token.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseTwo details are easy to get wrong here. The key is the decoded bytes, not the whsec_ string and not the base64 text after the prefix. And the comparison has to be constant time, which is what hmac.compare_digest is for; a plain equality check on a signature leaks timing information.
Timestamp tolerance and replay.
A signature is valid forever unless something bounds it in time. That means anyone who captures one legitimate payload, from a log file, a proxy, or an error report, can replay it at your endpoint whenever they like and it will verify. The bound is the timestamp: reject any request whose svix-timestamp is more than five minutes away from now, in either direction. Both directions matter, since a far future timestamp is just as suspicious as a stale one.
The Svix libraries enforce that five minute tolerance for you, which is one of the better arguments for using them. A hand rolled verifier has to add the check explicitly, and it should run before the HMAC so an attacker cannot use your endpoint as a hashing service.
What happens after you reject a request.
Returning a 401 is the right response to a bad signature, but be aware of what it sets in motion. Any response that is not a 2xx counts as a failure, and that includes 3xx redirects, which surprises people who put their endpoint behind a URL that quietly redirects to a canonical host. Taking longer than the 15 second response timeout is also a failure. Failures trigger the fixed Svix retry schedule.
| Attempt | After | What it buys you |
|---|---|---|
| 1 | Immediate | The first retry fires right away. |
| 2 | 5 seconds | Covers a momentary blip or a cold start. |
| 3 | 5 minutes | Covers a short deploy or restart. |
| 4 | 30 minutes | Covers a longer incident. |
| 5 | 2 hours | You have time to notice and fix. |
| 6 | 5 hours | Overnight coverage for a small team. |
| 7 | 10 hours | Second to last chance. |
| 8 | 10 hours | Final attempt for this message. |
If an endpoint keeps failing for around five days, it is disabled automatically and you re-enable it from the dashboard. That is the scenario worth guarding against: a verification bug that ships on a Friday can quietly cost you the endpoint by Wednesday.
There is one escape hatch. If you have decided a particular message is one you will never accept and you want the retries to stop, respond with the header webhook-delivery: abort-message. Resend gives up on that message and leaves the endpoint enabled. Use it for events you deliberately refuse, not as a way to paper over a handler that is throwing.
Deduplicate on svix-id, and do not trust order.
Verification proves the event is real. It does not prove the event is new. Delivery is at-least-once, so the same event can arrive twice even when your endpoint succeeded, because the acknowledgement got lost coming back. A handler that increments a counter or writes a row on every delivery will double count.
Dedupe on svix-id. Record it the first time you process an event, and return a 200 without doing anything else when an id you have already seen arrives again. A small table with a time based cleanup is enough; you only need to cover the retry window, which the schedule above caps at a little over a day.
Ordering is the other thing verification does not give you. Events are not guaranteed to arrive in the order they happened, so sort by the created_at field in the payload when sequence matters, and design each side effect so it sets state rather than toggling it. If you are building analytics on top of these events, the piece on email event tracking covers how to model them so that out of order arrival stops mattering.
If none of this is work you want to own, that is a reasonable conclusion. Connecting Resend to GetFluxly gives you the verified endpoint, the deduplication, and the suppression handling already built, with the events landing on the customer profile.
Resend webhook verification, common questions answered.
What is the whsec_ secret in Resend?
It is the signing secret for a single webhook endpoint, and Resend shows it in the dashboard next to that endpoint. The format is whsec_ followed by a base64 string. The prefix is a label, not part of the key: to compute a signature by hand you strip whsec_ and base64 decode what is left, and those raw bytes are your HMAC key. The Svix libraries take the full string including the prefix and handle the stripping for you, so pass it through untouched from your environment variable.
Why does Resend webhook verification fail with a valid secret?
Because you are almost certainly verifying the wrong bytes. The signature is computed over the exact raw body Resend sent, so the moment a body parser turns the request into an object, the original byte sequence is gone. Re-serializing with JSON.stringify will not reproduce it, since key order, whitespace, and number formatting can all shift. Capture the raw body before anything parses it, using express.raw in Express or await req.text() in a Next.js route handler, and verify that string or buffer directly.
How many times does Resend retry a failed webhook?
Eight attempts in total, on a fixed Svix schedule: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours again. Anything that is not a 2xx counts as a failure, including a 3xx redirect, and so does taking longer than the 15 second response timeout. If an endpoint keeps failing for around five days, it is automatically disabled and you re-enable it from the dashboard. To stop retries for one specific message that you know you will never accept, respond with the header webhook-delivery: abort-message.
Can I verify Resend webhooks without the svix library?
Yes, and the scheme is short enough to implement in any language. Base64 decode the part of the secret after whsec_ to get your key. Build the signed content as the message id, the timestamp, and the raw body joined with periods, in that order. Compute HMAC-SHA256 over that with your key and base64 encode the result. Then compare it, in constant time, against each space separated token in svix-signature after dropping the v1, prefix, and reject any request whose timestamp is more than five minutes from now.
Do Resend webhooks arrive in order?
No. Delivery is at-least-once and ordering is not guaranteed, so an email.delivered event can genuinely arrive after the email.opened event for the same message, especially once retries are in the mix. Never write logic that assumes sent precedes delivered precedes opened. Key everything on the email id, treat each event as an independent fact that updates state, and use the created_at value inside the payload rather than arrival order whenever you need to reason about sequence.