Test Resend webhooks without deploying
Capture email delivered, bounced and complained events from Resend, which signs through Svix like Clerk does.
Get a URL that accepts the webhook#
A capture URL takes any method, any body, and records exactly what arrived: method, headers, query string, body and source IP. You do not need an account to make one, and an anonymous URL is deleted after 24 hours.
curl -X POST https://mocklane.com/api/v1/anon/endpoints \
-H "Content-Type: application/json" -d '{}'
# The response carries a capture URL of the form:
# https://mocklane.com/h/<endpoint-id>Paste that URL into Resend at Resend dashboard, Webhooks, Add Webhook, then trigger something. Events worth starting with:
email.sentemail.deliveredemail.bouncedemail.complained
Verify the svix-signature header#
Resend signs each request so you can tell a real delivery from anyone who has guessed your URL. The digest is HMAC-SHA256 over the message id, the timestamp and the raw body, joined with full stops, base64-encoded, keyed with your signing secret (starts with whsec_).
- Header
svix-signature- Timestamp header
svix-timestamp- Value format
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=- Algorithm
- HMAC-SHA256, base64
- Signed content
- the message id, the timestamp and the raw body, joined with full stops
const crypto = require("crypto");
// Svix signs "{id}.{timestamp}.{rawBody}".
const signed = `${req.headers["svix-id"]}.${req.headers["svix-timestamp"]}.${rawBody}`;
// The key is the DECODED secret, not the whsec_ string itself.
const key = Buffer.from(process.env.RESEND_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(signed).digest("base64");
// svix-signature can carry several space-separated "v1,<sig>" values during
// a secret rotation. Any one of them matching is a pass.
const ok = req.headers["svix-signature"]
.split(" ")
.some((part) => {
const sig = part.split(",")[1];
return sig && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
});crypto.timingSafeEqual. A plain === returns as soon as two bytes differ, and that timing difference is enough to recover a valid signature one byte at a time.What catches people out#
- The signing secret is prefixed with whsec_. The key is the base64 decode of everything after that prefix, not the literal string, which is the step most hand-written Svix verifiers miss.
- The scheme is identical to Clerk's, because both deliver through Svix. A verifier written for one works for the other.
- email.bounced is the event worth wiring first. A bounce you never process becomes a suppressed address and a quietly failing signup flow.
Replay it until your handler is right#
A captured request can be re-sent to your own endpoint, with the body or headers edited first. That turns a bug that needed a real Resend event into something you can reproduce on demand, which matters most for events that are awkward to trigger twice.
Assert on it in CI#
A scenario chains the steps into one run that passes or fails, so a change that breaks your Resend handling fails the build rather than reaching production.
steps:
- type: send_webhook
body: { event: "email.sent" }
- type: wait_for_webhook
timeout_seconds: 30
assert:
- status_code equals 200Triggered from CI with an API token. See the scenarios documentation for the full step list.
Signature details checked against Resend’s documentation on 22 October 2026. Schemes change: if this page and their docs disagree, believe theirs and tell us.