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");
}
try {
// Resolve only after an atomic durable save, deduplicated by svix-id.
await enqueue(req.get("svix-id"), event);
} catch {
// Resend must retry if this event has not been saved safely.
return res.status(503).send("queue unavailable");
}
return res.status(200).send("ok");
},
);
Supply enqueue from your application. It must atomically
save the event id and payload to durable storage before resolving, or
confirm that the same id is already safely stored. Enforce uniqueness
in storage so concurrent retries cannot insert two copies. Return 503
if that write fails; returning 200 first would tell Resend to stop
retrying an event your app might lose. A worker handles the slower work
after the save, with its own retries. Set a short timeout on the queue
write so an unavailable queue does not hold the request open.
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 });
}
try {
// Use your durable queue adapter; do not detach this promise.
await enqueue(headers["svix-id"], event);
} catch {
return new Response("queue unavailable", { status: 503 });
}
// A worker owns the saved event after this request returns.
return Response.json({ ok: true });
}
The Next.js example uses the same enqueue contract as
the Express example. Import your queue adapter into the route and await
its durable save. A detached promise or an in memory list is not a
durable queue, and verification alone does not store the event.
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 False
Two 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. A response timeout can also trigger another attempt. The table follows Resend's detailed retry guide, with each delay measured from the previous failed attempt.
| Attempt | After | What it buys you |
|---|---|---|
| 1 | Immediate | The initial delivery, before any retry. |
| 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. |
Resend sends failure notifications and can disable an endpoint that continues to fail. Its retry and replay documentation explains how to recover. Check the endpoint state and fix the cause before enabling it again.
After recovery, replay a single event and check the saved result before replaying a batch. The Resend webhook workflow guide covers delivery inspection, the new replay API, and failure tests for your queue adapter.
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 stored safely arrives again. Use a unique key in durable
storage, not a separate check followed by an insert. Keep that record
for your manual replay and recovery period too; a successful event can
be replayed after its automatic retry window ends. Worker side effects
must also tolerate the same job being attempted more than once.
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?
Resend's detailed retry guide lists eight attempts including the initial delivery. Later attempts follow the delays in the table above, each measured from the previous failure. Return 200 only after saving the event safely. Check the dashboard for the next attempt and any endpoint failure notification. Manual replays can deliver the same event again even after a successful response.
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.