Skip to main content
友田 陽大
Resend & transactional email
Resend
Webhook
冪等性
信頼性
Next.js
TypeScript
メール配信
到達率

Resend Webhooks in Production: Signature Verification, Idempotent Receipt, and Bounce/Complaint Handling

A production webhook guide faithful to the Resend docs (as of August 2026) and the resend@6.4.1 implementation: all 19 event types, correct svix signature verification, idempotent receipt keyed on svix-id, the retry policy, hard bounce and complaint operations, and storage design — in TypeScript that actually runs.

Published
Reading time
25 min read
Author
友田 陽大
Share

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.

EventOfficial definition (condensed)How to use it
email.sentThe API request succeeded and Resend will attempt deliveryStart of the send log. Do not render this as "success"
email.deliveredDelivery to the recipient's mail server succeededAudit trail and support evidence
email.delivery_delayedCould not be delivered because of a temporary issue (full mailbox, transient server problem)Observe rather than alert. Investigate the address if it repeats
email.bouncedThe recipient's mail server rejected the email permanentlyStop sending on a hard bounce. Persist the bounce object
email.complainedDelivered, but the recipient marked it as spamStop permanently. There is no reason field
email.openedThe recipient opened the emailDirectional only — the docs state open rates are not always accurate
email.clickedThe recipient clicked a linkUse click.link for funnel analysis. click.ipAddress is personal data
email.failedThe send failed due to an error (invalid recipients, API key, domain verification, quota)Alert-worthy. Put failed.reason in the notification
email.scheduledThe email was scheduled to be sentVisibility for scheduled sends
email.suppressedResend suppressed the sendDetects drift between your list and Resend's
email.receivedResend received an inbound emailEntry point for inbound. The body comes from a separate API
contact.created / updated / deletedContact lifecycleMirror into your own database
domain.created / updated / deletedDomain status changeMonitor DNS verification state
suppression.added / removedAn address entered or left the suppression listThe 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:

  1. verify() is synchronous. Do not await it.
  2. It throws on failure. Unlike the rest of the Resend SDK it does not return { data, error }, so try/catch is mandatory.
  3. payload must 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.

SourceWhat 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 FAQSix 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.

typeCommon nameMeaningExample sub-types
PermanentHard bounceThe receiving server rejected it and it will never be deliveredGeneral, NoEmail
TransientSoft bounceRejected, but may be delivered in the futureGeneral, MailboxFull, MessageTooLarge, ContentRejected, AttachmentRejected
UndeterminedUndeterminedIt bounced, but the message lacked enough information to determine whyUndetermined

Two internal contradictions are worth stating outright.

  1. The event page gives the type examples as "Permanent, Temporary", while the bounce reference page enumerates Transient. Resend's published machine-readable OpenAPI spec settles it: WebhookEventBounce.type is an enum of exactly Undetermined, Transient, and Permanent. Temporary never arrives — the event page's example is simply stale. The implementation advice is unchanged: treat everything that is not Permanent as "not permanent".
  2. subType is not a closed enum. Suppressed and MessageRejected appear 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.

FieldTypeMeaning
idstringUnique identifier for the suppression
emailstringThe suppressed email address
originbounce / complaint / manualHow the address was suppressed (a closed three-value enum)
source_idstring or nullID of the email that triggered it; null for manual
created_atstringISO 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.ipAddress is 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-id posted 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() (never request.json() first)
  • /api is excluded from the middleware matcher so the body is never rewritten
  • resend.webhooks.verify() is wrapped in try/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, never subType
  • email.complained permanently 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, and email_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. subType is 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.

Frequently asked questions

Is signature verification really required for Resend webhooks?
Yes. A webhook endpoint is an unauthenticated public POST surface, so without signature verification anyone who knows the URL can inject fake 'this address bounced' or 'this recipient complained' events. The result is that legitimate customers land on your suppression list and stop receiving invoices and authentication emails. Resend passes the signature in three headers — svix-id, svix-timestamp, and svix-signature — and resend.webhooks.verify() in SDK v6.4.1 verifies them for you.
Why does the same event arrive twice?
Because Resend documents webhook delivery as at-least-once. If your server processed the event but the acknowledgement was lost to a network timeout, the same event is redelivered. The docs also prescribe the fix: store the svix-id header, which is unique per delivery, and skip any ID you have already seen. Delivery order is not guaranteed either, so sort by the payload's created_at when order matters.
What happens if I fail to return 200?
Resend retries. The detailed page (Retries and Replays) lists eight attempts — immediately, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and a further 10 hours — for a retry window of roughly 27 hours 35 minutes 5 seconds. However, the introduction FAQ lists six steps and the storage guide says 'up to 24 hours', so three official pages disagree. If failures continue, Resend emails your team and eventually disables the endpoint automatically.
How do I tell a hard bounce from a soft bounce?
Read data.bounce.type. Permanent is a hard bounce (permanent rejection), Transient is a soft bounce (rejected now, may be delivered later), and Undetermined means the bounce message did not carry enough information. Treat subType (MailboxFull, MessageTooLarge, and so on) as supporting detail only: Suppressed and MessageRejected appear in the webhook samples but are absent from the bounce reference list, so it is not a closed enum.
What should I do when email.complained arrives?
Stop sending to that address immediately and permanently. complained means the email was delivered successfully but the recipient marked it as spam, and the payload carries no extra field explaining why — there is no complaint type, no reason code, nothing. With no way to investigate, stopping is the only available action. Resend's account limits state that a spam rate above 0.08% may result in a temporary pause in sending.
How do I test webhooks locally?
The fastest path is the Resend CLI's resend webhooks listen, which starts a server, registers a temporary webhook, streams events, and cleans up on exit. Adding --forward-to relays payloads to your local server while passing the original Svix headers through, so you can exercise signature verification end to end. Complement that with a unit test that reproduces the signature yourself, and CI will catch regressions in the verification path.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Transactional email infrastructure on Resend — design, implementation, and production operations

"We sent it" is not "it arrived." I close that gap in code and DNS rather than in operational vigilance: sender-domain authentication (SPF/DKIM/DMARC), a typed send path validated with Zod, idempotency keys and backoff for resilience, bounce and complaint handling over webhooks, RFC 8058 one-click unsubscribe, and structured logs that never carry PII. This site's own contact form, gated resources, email course and post-purchase mail all run on Resend in production — including the outage where misplaced authentication records took sending down entirely.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading