# 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: 2026-08-06
- Author: 友田 陽大
- Tags: Resend, Webhook, 冪等性, 信頼性, Next.js, TypeScript, メール配信, 到達率
- URL: https://tomodahinata.com/en/blog/resend-webhooks-signature-verification-bounce-complaint-guide
- Category: Resend & transactional email
- Pillar guide: https://tomodahinata.com/en/blog/resend-transactional-email-production-guide

## Key points

- The 200 returned by the send API means Resend accepted the message, not that it arrived. Delivery truth — delivered, bounced, complained — only ever reaches you through webhooks, so an email stack without a webhook endpoint is fundamentally operating blind
- Skip signature verification and anyone who knows your endpoint URL can forge a bounce for any address, poisoning your suppression list. Resend SDK v6.4.1 ships webhooks.verify() with svix bundled, but payload MUST be the raw body string (await request.text()) and verification failure throws instead of returning { data, error }
- Webhook delivery is at-least-once with no ordering guarantee. The docs explicitly prescribe deduplication on the svix-id header, and sorting by the payload's created_at when order matters — the same design a payment webhook needs
- Bounce types are Permanent, Transient, and Undetermined, but subType is not a closed enum (Suppressed and MessageRejected appear in the webhook samples yet are missing from the reference list). The only safe test is type === 'Permanent'; everything else belongs in the default branch
- Three official pages give three different retry schedules (six steps, eight steps, and 'up to 24 hours'). This article uses the detailed page's eight-step schedule as the baseline and states the conflict rather than smoothing it over

---

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](https://resend.com/docs/webhooks/introduction) (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](/en/blog/resend-transactional-email-production-guide); for send-side reliability, see the [idempotency, retry, and error-handling guide](/en/blog/resend-idempotency-retry-error-handling-reliability-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.

```text
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:

```json
{
  "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.

```text
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:

```ts
// 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](/en/blog/resend-nextjs-app-router-route-handler-react-email-guide)).

```ts
// 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](/en/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions).

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.

```text
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.

```ts
/**
 * 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](/en/blog/vercel-functions-fluid-compute-streaming-cron-guide)). 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](/en/blog/nextjs-serverless-rate-limiting-vercel-guide)).

---

## 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.

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.

```ts
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](/en/blog/resend-batch-scheduled-broadcasts-audiences-topics-unsubscribe-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.

```ts
/** 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](/en/blog/resend-domain-authentication-spf-dkim-dmarc-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

```sql
-- 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.

```sql
-- 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."

```bash
# --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.

```ts
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](https://resend.com/docs/webhooks/introduction) (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.
