The moment you read the 200 { id: "..." } from resend.emails.send() as "the email was sent", your email stack starts lying to you. The official definition of the email.sent event admits as much: it fires "whenever the API request was successful. Resend will attempt to deliver the message to the recipient's mail server." A 200 is a receipt of acceptance, not proof of delivery.
What happened after that — whether the receiving server accepted it (delivered), rejected it permanently (bounced), or accepted it only for the recipient to hit "report spam" (complained) — only ever arrives through a webhook. A system without a webhook endpoint learns that its email is not arriving from a customer support ticket.
I run this portfolio itself on Resend in production: the contact form, the lead magnet, a seven-part email course, and the post-purchase delivery email after a Stripe payment. I have also shipped a real outage here — Resend's domain authentication records misplaced on the apex, which left /api/contact returning 502 until I fixed the DNS and added a sender fallback, retries, and an idempotency key. However well you harden the sending side, you never learn what happened on the receiving side unless you accept webhooks. This article closes that gap.
Everything below is grounded in the Resend documentation (as of August 2026) and in the type definitions and implementation of resend@6.4.1 as installed in this repository. Where the official pages contradict each other, I present the contradiction rather than smoothing it over. For the wider map, see the Resend transactional email production guide; for send-side reliability, see the idempotency, retry, and error-handling guide.
The event map: where each event sits in the sending lifecycle
Resend now emits 19 event types. The 2024-era belief that there are six to eight is out of date — email.failed, email.scheduled, email.received, email.suppressed, and the suppression.* family have all been added since. Start by placing them on the lifecycle.
resend.emails.send() → 200 { id } ← everything up to here is "the API accepted it"
|
v
email.scheduled (only when scheduledAt was supplied)
|
v
email.sent ────────────────────────────┐
| |
| Resend → recipient mail server |
v v
email.delivered email.failed
| (the send itself failed: quota exceeded,
| bad API key, unverified domain, ...)
+--> email.delivery_delayed --(retry)--> delivered / bounced
|
+--> email.bounced (recipient server rejected permanently = hard bounce)
|
+--> email.complained (delivered, then reported as spam)
|
+--> email.opened / email.clicked (engagement tracking)
email.suppressed a send to a suppressed address was stopped
suppression.added / removed the suppression list itself changed
Here is the same set with what each event is actually good for.
| Event | Official definition (condensed) | How to use it |
|---|---|---|
email.sent | The API request succeeded and Resend will attempt delivery | Start of the send log. Do not render this as "success" |
email.delivered | Delivery to the recipient's mail server succeeded | Audit trail and support evidence |
email.delivery_delayed | Could not be delivered because of a temporary issue (full mailbox, transient server problem) | Observe rather than alert. Investigate the address if it repeats |
email.bounced | The recipient's mail server rejected the email permanently | Stop sending on a hard bounce. Persist the bounce object |
email.complained | Delivered, but the recipient marked it as spam | Stop permanently. There is no reason field |
email.opened | The recipient opened the email | Directional only — the docs state open rates are not always accurate |
email.clicked | The recipient clicked a link | Use click.link for funnel analysis. click.ipAddress is personal data |
email.failed | The send failed due to an error (invalid recipients, API key, domain verification, quota) | Alert-worthy. Put failed.reason in the notification |
email.scheduled | The email was scheduled to be sent | Visibility for scheduled sends |
email.suppressed | Resend suppressed the send | Detects drift between your list and Resend's |
email.received | Resend received an inbound email | Entry point for inbound. The body comes from a separate API |
contact.created / updated / deleted | Contact lifecycle | Mirror into your own database |
domain.created / updated / deleted | Domain status change | Monitor DNS verification state |
suppression.added / removed | An address entered or left the suppression list | The single entry point for your own send-allowed table |
The shared payload envelope
All 19 events share the same envelope. The top level has exactly three keys: type (the event type), created_at (ISO 8601), and data (event-specific payload).
Two traps live here. First, created_at exists both at the top level and inside data, and they mean different things — the top-level value is when the webhook event was created, data.created_at is when the email or contact was created. The official samples differ by 232 milliseconds. Second, there is no id field in the JSON body at all. The per-delivery unique ID is the svix-id header, and that is where your idempotency key comes from.
email.received (inbound) is a further exception, and the docs are explicit: "Webhooks do not include the email body, headers, or attachments, only their metadata." If you need the body, call the received-emails API separately. Given request body size limits in serverless environments, that is a sensible design.
Signature verification: skip it and anyone can forge a bounce
A webhook endpoint is an unauthenticated, publicly reachable POST surface. Not verifying the signature means anyone who knows the URL can post this:
{
"type": "email.bounced",
"created_at": "2026-08-06T00:00:00.000Z",
"data": {
"to": ["important-customer@example.com"],
"bounce": { "type": "Permanent", "subType": "General", "message": "..." }
}
}
The more carefully you built the system, the worse this is. Your correct operational rule — "never send to a hard-bounced address again" — becomes an attack surface that permanently blocks delivery to legitimate customers. Invoices and authentication emails stop arriving.
How the signature works
Resend passes the signature in three svix-style headers.
svix-id unique per delivery (e.g. msg_p5jXN8AQM9LWM0D4loKWxJek)
svix-timestamp unix seconds, as a string
svix-signature v1,<base64> — may contain several space-separated values during rotation
The key is a signing secret prefixed with whsec_. You can read it on the webhook detail page, and it is returned as signing_secret by the create and retrieve endpoints. (The list endpoint's sample omits it, contradicting the sentence in the verification docs that says list returns it — one of several inconsistencies in these pages.)
The actual computation can be read from the svix library implementation. Treat the following as library behaviour, not a spec documented by Resend:
- The algorithm is HMAC-SHA256.
- The key is the secret with
whsec_stripped, then base64-decoded. - The signed string is
{svix-id}.{svix-timestamp}.{raw body}. - The output is standard base64, prefixed with
v1,. - Comparison is constant time.
- The timestamp tolerance is 300 seconds (5 minutes), rejecting both too-old and too-far-in-the-future values.
That 300-second figure appears nowhere in Resend's own documentation — svix's page only says to check that it is within your tolerance. It comes from a source constant in the library, so treat it as something that could change.
The timestamp check confines replay attacks — resending a captured, legitimate request — to a five-minute window. But a replay inside those five minutes is cryptographically valid, so the real replay defence is the svix-id deduplication in the next section. Signature verification alone is not enough.
Calling it correctly in SDK v6.4.1
resend@6.4.1 depends on svix@1.76.1, so you do not need to install svix yourself. The implementation is a thin wrapper:
// the actual body in node_modules/resend/dist/index.js (v6.4.1)
verify(payload) {
const webhook = new Webhook(payload.webhookSecret);
return webhook.verify(payload.payload, {
"svix-id": payload.headers.id,
"svix-timestamp": payload.headers.timestamp,
"svix-signature": payload.headers.signature,
});
}
Three calling rules follow directly from that:
verify()is synchronous. Do notawaitit.- It throws on failure. Unlike the rest of the Resend SDK it does not return
{ data, error }, sotry/catchis mandatory. payloadmust be the raw request body string. Passing an already-parsed object will never verify.
One version caveat: the internals move. Bundling svix is a fact about 6.4.1; the newer 6.18.1 swapped it for standardwebhooks. The signature scheme is the same (Standard Webhooks), and the headers Resend actually puts on the wire are still svix-id / svix-timestamp / svix-signature, so the call above works on either version. What does not work is an implementation that reads webhook-id off the request.
The docs warn about the third one in strong terms: "Make sure that you're using the raw request body when verifying webhooks. The cryptographic signature is sensitive to even the slightest change. Some frameworks parse the request as JSON and then stringify it, and this will also break the signature verification."
And yet another official page — the inbound-email FAQ — ships a sample containing payload: JSON.stringify(req.body), precisely the anti-pattern being warned against. When in doubt, await request.text() is always the right answer.
Implementation in a Next.js Route Handler
The official Next.js sample neither compiles nor works as written: it reads req.headers['svix-id'], but on an App Router NextRequest the headers property is a Headers instance, so that expression is undefined. The implementation below fixes that (for the sending side of a Route Handler, see the Next.js App Router guide).
// app/api/resend-webhook/route.ts
import { Resend } from "resend";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
export const dynamic = "force-dynamic";
/** A slow response invites retries. Fail fast rather than hang the invocation. */
export const maxDuration = 15;
let client: Resend | null = null;
/** new Resend() at module scope breaks next build, so create it lazily. */
const getResend = (): Resend => (client ??= new Resend(process.env.RESEND_API_KEY));
/**
* In v6.4.1 the return type of verify() is `unknown` in the type definitions
* (newer upstream releases return a discriminated union). "The signature is
* valid" and "the shape is what I expect" are different questions, so narrow at
* the boundary regardless of which version is installed.
*/
const webhookEnvelope = z.object({
type: z.string().min(1),
created_at: z.string().min(1),
data: z.record(z.string(), z.unknown()),
});
type WebhookEnvelope = z.infer<typeof webhookEnvelope>;
export async function POST(request: NextRequest) {
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (!secret) {
console.error(JSON.stringify({ route: "resend-webhook", phase: "not_configured" }));
return NextResponse.json({ error: "not configured" }, { status: 503 });
}
// The signature is computed over the exact bytes that were sent. Calling
// request.json() first re-serializes them and breaks it. Raw body comes first.
const raw = await request.text();
const id = request.headers.get("svix-id");
const timestamp = request.headers.get("svix-timestamp");
const signature = request.headers.get("svix-signature");
if (!id || !timestamp || !signature) {
return NextResponse.json({ error: "missing signature headers" }, { status: 400 });
}
let event: unknown;
try {
// verify() is synchronous and throw-based — not { data, error }.
event = getResend().webhooks.verify({
payload: raw,
headers: { id, timestamp, signature },
webhookSecret: secret,
});
} catch {
// Bad signature and stale timestamp both land here. Do not leak the reason.
console.warn(JSON.stringify({ route: "resend-webhook", phase: "bad_signature" }));
return NextResponse.json({ error: "invalid signature" }, { status: 400 });
}
const parsed = webhookEnvelope.safeParse(event);
if (!parsed.success) {
// Valid signature but an unexpected shape — probably a new event type.
// Returning 400 only starts a retry loop, so record it and accept with 200.
console.warn(JSON.stringify({ route: "resend-webhook", phase: "unknown_shape" }));
return NextResponse.json({ received: true }, { status: 200 });
}
return handleEvent({ svixId: id, event: parsed.data });
}
This repository's app/api/stripe-webhook/route.ts runs the same skeleton in production (it has no database, so it achieves idempotency by feeding the event ID into Resend's idempotencyKey instead of a dedup table). The order — read the raw body first, verify, parse, deduplicate, return 200 quickly — is identical for payments and for email. The payment side is covered in the Stripe payments production guide.
One more prerequisite: if anything rewrites the request body in transit, the signature breaks. Excluding /api from the Next.js middleware (proxy.ts) matcher is effectively mandatory.
A secondary defence: source IPs
If you want an IP allowlist on your server, Resend publishes these addresses.
44.228.126.217
50.112.21.217
52.24.126.164
54.148.139.208
2600:1f24:64:8000::/52
This is not a substitute for signature verification. IP ranges change, so use it only as an additional layer and re-check the current values on the official page.
Idempotent receipt: build for at-least-once and no ordering
The docs are explicit about the delivery guarantee: "Resend webhooks provide at-least-once delivery. Every event will be delivered to your endpoint at least once, but may be delivered more than once in rare cases (such as network timeouts where your server processed the event but the acknowledgement was lost)."
They also prescribe the fix: "To handle duplicates, use the svix-id header included with every webhook request. This is a unique identifier for each event delivery. Store processed svix-id values and skip any duplicates."
And they are equally explicit about ordering: "Events are sent as they occur, but delivery order is not guaranteed... For example, an email.opened event could arrive before the email.delivered event for the same email. If ordering matters for your application, use the created_at timestamp in the event payload to sort events after receipt."
Those two sentences are the entire implementation requirement.
/**
* The unique constraint on svix_id is the whole idempotency mechanism. The key
* is that the application takes no lock: only the writer that managed to INSERT
* proceeds, so simultaneous deliveries cannot double-process.
*/
async function handleEvent(args: { svixId: string; event: WebhookEnvelope }) {
const claimed = await db.query(
`insert into resend_webhook_events (svix_id, event_type, event_created_at)
values ($1, $2, $3)
on conflict (svix_id) do nothing
returning svix_id`,
[args.svixId, args.event.type, args.event.created_at],
);
// A redelivery. Do no work, return 200 so the retries stop.
if (claimed.rowCount === 0) {
return NextResponse.json({ received: true, duplicate: true }, { status: 200 });
}
// ... per-type handling (keep the side effects idempotent too)
return NextResponse.json({ received: true }, { status: 200 });
}
I used the same design on a payments platform — deduplication keyed on the event ID — and it produced zero double charges in production. Email is no different: write the side effects so that running "this hard-bounced, add it to the suppression list" twice changes nothing (insert ... on conflict do nothing, absolute assignment on update).
The practical consequence of no ordering guarantee is: do not build a state machine. Code that assumes delivered arrives before opened will break. Append each event as an independent fact and interpret it by sorting on created_at when you actually need an order.
Retry policy: three official pages say three different things
The success signal is unambiguous: "On receiving an event, respond with an HTTP 200 OK to signal to Resend that the event was successfully delivered."
The retry schedule is where three official pages fail to agree.
| Source | What it says |
|---|---|
| Retries and Replays (the detailed page) | Eight attempts: immediately, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and a further 10 hours |
| Webhooks Introduction FAQ | Six attempts: 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours |
| How to Store Webhooks Data FAQ | "Resend automatically retries failed webhook deliveries for up to 24 hours" |
This article takes the most detailed page, Retries and Replays, as the baseline. Summing its eight steps gives a retry window of roughly 27 hours 35 minutes 5 seconds. That page also offers a concrete example: "an attempt that fails three times before eventually succeeding will be delivered roughly 35 minutes and 5 seconds following the first attempt."
Either way the design conclusion is the same: assume redeliveries will keep arriving for about a day and keep the endpoint idempotent. Do not write anything that depends on the exact number of attempts.
There is ambiguity in the success status code too. The introduction says Resend retries if it "does not receive a 200 response"; the storage guide says "if your endpoint returns a 5xx error, we'll retry". No official page states whether 201 or 204 counts as success. Just return 200. The endpoint response timeout is not documented anywhere either.
What happens when failures persist
This behaviour did not exist in 2024-era knowledge. From the docs: "When a webhook endpoint starts failing to receive events, Resend sends an email notification to your team. The email includes the endpoint URL, the time of the last failed attempt, and the last HTTP response status code. If the endpoint continues to fail, Resend will eventually disable it automatically and send a second notification to let you know. Once your endpoint is back up, you can re-enable it from the Webhooks page in the dashboard."
The threshold that triggers auto-disable — how many failures, over what window — is not published. After recovery you re-enable manually and close the gap with manual replays from the dashboard, which work for both failed and succeeded messages. The documented use cases are backfilling after an outage on your endpoint, reprocessing events with updated handler code, and sending an event to a different endpoint for testing.
Return 200 quickly
The single biggest practical lever against retries and auto-disable is to keep heavy work out of the request. Calling external APIs, rendering templates, and notifying Slack inside the receiving handler means that any one of them being slow — or failing — stops the 200 from being returned and triggers redelivery and timeouts. Limit the handler to "verify, claim, append, return 200", and push side effects outside it.
On Vercel you can continue background work after the response with waitUntil (see the Vercel Functions execution model). If you have a queue, just enqueue a job. And if you rate-limit the endpoint itself, design it so Resend's own redeliveries are not the thing being rejected (see rate limiting on serverless).
Bounces and complaints: the real reason to implement webhooks
Reading bounce types correctly
data.bounce on email.bounced carries type (bounce type), subType (bounce sub-type), and message (the detailed message from the receiving server). diagnosticCode, an array of SMTP diagnostic responses, is a required field in the OpenAPI spec, yet it appears in neither the official JSON sample nor the SDK v6.4.1 type — so on the receiving side, treating it as optional is the safe choice.
According to the bounce reference page, type has three values.
type | Common name | Meaning | Example sub-types |
|---|---|---|---|
Permanent | Hard bounce | The receiving server rejected it and it will never be delivered | General, NoEmail |
Transient | Soft bounce | Rejected, but may be delivered in the future | General, MailboxFull, MessageTooLarge, ContentRejected, AttachmentRejected |
Undetermined | Undetermined | It bounced, but the message lacked enough information to determine why | Undetermined |
Two internal contradictions are worth stating outright.
- The event page gives the
typeexamples as "Permanent,Temporary", while the bounce reference page enumeratesTransient. Resend's published machine-readable OpenAPI spec settles it:WebhookEventBounce.typeis an enum of exactlyUndetermined,Transient, andPermanent.Temporarynever arrives — the event page's example is simply stale. The implementation advice is unchanged: treat everything that is notPermanentas "not permanent". subTypeis not a closed enum.SuppressedandMessageRejectedappear in the webhook samples but are missing from the reference list.
That leaves exactly one safe shape for the decision logic.
interface BounceData {
readonly bounce?: { readonly type?: string; readonly subType?: string; readonly message?: string };
}
/**
* Decide permanence on type === "Permanent" alone. subType is not a closed enum
* (Suppressed and MessageRejected appear in the official webhook samples but are
* absent from the bounce reference list), so never branch on it. Unknown values
* fall to the "not permanent" side so a legitimate address is never cut off.
*/
function isHardBounce(data: BounceData): boolean {
return data.bounce?.type === "Permanent";
}
The docs add an operationally important note: "Sometimes, inboxes use autoresponders to signal a bounce. A transient status could mean it's related to the autoresponder, and it's not a permanent issue." That is exactly why you should not cut an address on a single soft bounce.
Handling complaints
email.complained carries no extra fields at all. Its shape is identical to email.sent. There is no complaint category, no reason code, no feedback-loop type — not in the docs and not in the SDK types. This is one of the most commonly mis-remembered facts about Resend.
Since you cannot know why, exactly one action is available: never send to that address again.
There is a second limitation the docs state outright: "Not all Inbox Service Providers return a complained event, most notably, Gmail/Google Workspace." Complaint events are a signal to act on when they arrive, not a measure of how many complaints you are generating. Never read "zero complaints" as "we are healthy".
Account-level limits reinforce this. Resend documents that you must keep the bounce rate under 4% and the spam rate under 0.08%, and that exceeding either may result in a temporary pause in sending. Complaints bite as a rate, so you cannot afford to waste one. Subscription management is covered in the batch, scheduled, and subscription guide.
Suppressions
Resend maintains its own account-level suppression list, and addresses are added to it automatically after a hard bounce or a spam complaint (manual entries are also possible). Changes arrive as suppression.added and suppression.removed.
| Field | Type | Meaning |
|---|---|---|
id | string | Unique identifier for the suppression |
email | string | The suppressed email address |
origin | bounce / complaint / manual | How the address was suppressed (a closed three-value enum) |
source_id | string or null | ID of the email that triggered it; null for manual |
created_at | string | ISO 8601 timestamp when the suppression was created |
Attempting to send to a suppressed address emits email.suppressed. The sample message contains an operationally significant sentence: "This does not count toward your bounce rate metric." In other words, letting suppression do its job also protects your deliverability metrics.
One honest caveat. The Resend class in SDK v6.4.1 has no suppressions resource — verified against the type definitions, which expose apiKeys, segments, audiences (deprecated), batch, broadcasts, contacts, contactProperties, domains, emails, webhooks, templates, and topics. The REST API does have Suppressions endpoints, so reaching them through the SDK means using the generic methods.
The SDK exposes resend.get, resend.post, resend.put, resend.patch, and resend.delete as public generic methods, and endpoints the SDK does not model can be called through them. That said, this article does not assert the endpoint path, parameters, or response shape — check the official Suppressions page for the current values.
What I recommend in practice is to make your own send-allowed table the single gate. Keep Resend's suppression list as the last safety net, but run the pre-send check against your own database. That insulates you from API changes and lets you explain, from your own data, why a given address is not being contacted.
/** The gate every send must pass. The reason is stored for accountability. */
async function canSend(email: string): Promise<boolean> {
const row = await db.query(
`select 1 from email_suppressions where email = $1 limit 1`,
[email.trim().toLowerCase()],
);
return row.rowCount === 0;
}
For raising deliverability at the root — SPF, DKIM, and DMARC — see the domain authentication and deliverability guide. Half of all bounces are preventable with correct domain configuration.
Storage design: what to keep, and what not to
The docs state the case for storage plainly: "These events contain valuable data, however, by default webhooks are ephemeral." And: "Resend retains email data for 30 days across all plans (with flexible retention for Enterprise). If you need access to historical email data beyond that window, storing events in your own database ensures you never lose important information."
They also list compliance drivers: GDPR (demonstrating what was sent to whom and when), SOC 2 (audit requirements may include email delivery verification), and financial regulations (transaction-related emails may need retention for years).
The four minimum fields (per the docs)
- Event ID — the unique
svix-id, for deduplication - Event type — what happened (delivered, bounced, opened, and so on)
- Timestamp — when the event occurred
- Email ID — links the event back to the original send
Optional analytics extras listed by the docs are recipient addresses, subject lines, tags, bounce details, and click URLs.
What to be careful with
The warning from the docs: "Webhook data may contain personal information (email addresses, IP addresses from opens/clicks)."
- Email bodies never arrive in a webhook (inbound included — metadata only). Do not go fetch them from another API just to store them. Not holding data you do not need is the safest position.
click.ipAddressis personal data. Drop it before persisting unless click analysis genuinely requires it.- If you store the whole payload in
jsonb, design the retention period and access controls on the assumption that recipient addresses and subject lines are in there.
Table example
-- Event log: making svix_id the primary key is the entire idempotency mechanism
create table resend_webhook_events (
svix_id text primary key,
event_type text not null,
event_created_at timestamptz not null, -- payload created_at (to restore order)
received_at timestamptz not null default now(),
email_id uuid, -- link back to the original send
payload jsonb -- if stored, assume it contains PII
);
create index resend_webhook_events_email_idx on resend_webhook_events (email_id);
create index resend_webhook_events_type_time_idx
on resend_webhook_events (event_type, event_created_at desc);
-- Send eligibility: the application consults only this table before sending
create table email_suppressions (
email text primary key, -- store normalized to lower case
reason text not null
check (reason in ('bounce', 'complaint', 'manual')),
source_id uuid, -- ID of the email that triggered it
created_at timestamptz not null default now()
);
The docs also size the problem for you: "if you send 10,000 emails/month with average engagement, expect 30,000-50,000 events/month. Each event is typically 1-2 KB, so about 50-100 MB/month of raw data." At that scale a relational database is plenty; you do not need a dedicated analytics stack.
Retention guidance comes in three tiers: 30–90 days is often sufficient for operational debugging and recent analytics, compliance requirements depend on your industry (often 1–7 years), and for historical analysis you should consider aggregating old data rather than keeping raw events.
-- Drop raw events older than 90 days (roll them into aggregates first)
delete from resend_webhook_events
where event_created_at < now() - interval '90 days';
Verifying locally
1. Stream real events with the Resend CLI
The official CLI command resend webhooks listen will "listen for webhook events locally during development. Starts a server, registers a temporary webhook, streams events, and cleans up on exit."
# --forward-to relays to your local server while "passing original Svix headers",
# which means you can exercise signature verification end to end.
resend webhooks listen \
--url https://hostname.tailnet-name.ts.net \
--events email.bounced \
--forward-to http://localhost:3000/api/resend-webhook
--url (your public URL) is required, --events defaults to all, and --port defaults to 4318. For producing a public URL, the docs also suggest ngrok and VS Code Port Forwarding.
2. Reproduce the signature in a unit test
The CLI is great at your desk but cannot run in CI. Since the signature scheme is known, your tests can produce valid signatures themselves.
import { createHmac } from "node:crypto";
/**
* Test-only signature generation. The scheme was read from the svix library
* implementation — it is NOT a contract documented by Resend. The signed string
* is `${id}.${timestamp}.${raw body}`; the key is the secret with whsec_
* stripped and then base64-decoded.
*/
export function signResendWebhook(args: {
secret: string; // "whsec_..."
id: string; // svix-id
timestamp: string; // unix seconds, as a string
body: string; // raw body (reuse the same JSON.stringify output in tests)
}): string {
const key = Buffer.from(args.secret.replace(/^whsec_/, ""), "base64");
const digest = createHmac("sha256", key)
.update(`${args.id}.${args.timestamp}.${args.body}`, "utf8")
.digest("base64");
return `v1,${digest}`;
}
With that helper you can write at least these four tests:
- Valid signature returns 200
- One byte changed in the signature returns 400 and produces no side effects
- A timestamp ten minutes old returns 400 (the tolerance is five minutes)
- The same
svix-idposted twice produces side effects exactly once
When you write the third one, generate the timestamp from the current time, because the tolerance is five minutes. A test with a hard-coded timestamp starts failing five minutes after you write it and never passes again.
3. Replay from the dashboard
After fixing a handler, use the official replay feature: Webhooks page, then the endpoint, then the message, then the Replay button — four steps, and both failed and succeeded messages can be replayed.
One version caveat: email.scheduled, email.suppressed, suppression.added, and suppression.removed are not included in the WebhookEvent type in SDK v6.4.1. Subscribing to them through the SDK's webhooks.create() may produce a type error, in which case register them from the dashboard, the CLI, or the REST API. The documentation lists all 19.
Pre-launch checklist
- The raw body is read with
await request.text()(neverrequest.json()first) -
/apiis excluded from the middleware matcher so the body is never rewritten -
resend.webhooks.verify()is wrapped intry/catch(synchronous, throw-based) - The return value of
verify()is narrowed (Zod or equivalent) before use - Deduplication is keyed on
svix-id, and the side effects are idempotent too - Unknown event types are accepted with 200 and recorded, not rejected with 400
- Heavy work happens outside the handler so 200 is returned quickly
- Hard-bounce detection uses
type === "Permanent"only, neversubType -
email.complainedpermanently stops sending to that address - The pre-send check reads your own suppression table, synced from
suppression.* - Stored records include
svix-id, event type, timestamp, andemail_id - You decided whether to keep
click.ipAddress, with retention and access rules - Signature-verification unit tests run in CI (valid, tampered, expired, duplicate)
- You know which team receives the webhook-failure emails (so auto-disable is noticed)
Summary
Webhooks are not a nice-to-have notification feature. They are the only source of truth for the gap between the send API's 200 and actual delivery. Five things must not be got wrong:
- Verify the signature against the raw body. Skip it and your correct suppression logic becomes an attack surface.
- Deduplicate on
svix-id. At-least-once with no ordering guarantee is the documented contract. - Return 200 quickly. Slow responses invite retries, and sustained failures get the endpoint disabled automatically.
- Detect hard bounces with
type === "Permanent"alone.subTypeis not a closed enum. - Complaints come with no reason. Stopping is the only available action.
And, as this article kept showing, Resend's own documentation contradicts itself in several places — the retry schedule, the bounce type names, the raw-body handling in the verification samples, and the gaps between the docs and the SDK types. The answer is not to distrust the docs but to write implementations that do not depend on the ambiguous parts. Treating everything other than Permanent as non-permanent, avoiding any dependency on the number of retry attempts, and accepting unknown shapes with 200 are all consequences of that single policy.
Start by pointing one endpoint at just two event types, email.bounced and email.complained, on your existing sending stack. That alone will show you who you are currently failing to reach.
This article is based on the Resend documentation (Webhooks, Event Types, Verify Webhooks Requests, Retries and Replays, Email Bounces, How to Store Webhooks Data, and CLI — as of August 2026) and on the type definitions and implementation of the installed
resend@6.4.1, restructured with production judgement added. Some figures, such as the five-minute signature tolerance, were read from the svix library implementation and are not contracts documented by Resend. Specifications change, so verify the current values on the official pages before adopting any of this in production.