All articles
Email delivery, bounce, and complaint event envelopes flowing through a signed tunnel into a Next.js route on localhost.
ResendNext.jsemail webhookslocalhost

Test Resend Webhooks Locally with Next.js

To test Resend webhooks locally in Next.js, create an App Router POST route that reads the raw body, verify its Svix headers with your Resend signing secret, and register an HTTPS tunnel URL in the Resend dashboard. Send an email through Resend, then handle real email.sent, email.delivered, email.bounced, or email.complained events on localhost.

What a Resend webhook tells your application

An API response saying an email was accepted is not proof that it reached the recipient. Delivery happens asynchronously. Resend webhooks let your application update message state, suppress bad addresses, report bounces, and react to complaints after the original send request has finished. The official Resend webhook documentation lists event types and dashboard setup.

A local webhook test should cover the entire state machine, not only whether a POST reaches your route. Correlate each event's email ID with the record created when sending. Treat states as transitions: accepted, sent, delivered, delayed, bounced, complained, opened, or clicked where applicable. A later duplicate must not overwrite a more useful state or trigger the same alert twice.

Create the Next.js App Router route

Install the verifier maintained for the signing format:

npm install svix

Then create a Node-runtime route. Resend signs the original body, so use request.text() exactly once before parsing.

// app/api/webhooks/resend/route.ts
import { Webhook } from 'svix';

export const runtime = 'nodejs';

export async function POST(request: Request) {
  const payload = await request.text();
  const headers = {
    'svix-id': request.headers.get('svix-id') ?? '',
    'svix-timestamp': request.headers.get('svix-timestamp') ?? '',
    'svix-signature': request.headers.get('svix-signature') ?? '',
  };

  let event: ResendEvent;
  try {
    const webhook = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);
    event = webhook.verify(payload, headers) as ResendEvent;
  } catch {
    return Response.json({ error: 'Invalid webhook signature' }, { status: 400 });
  }

  await enqueueResendEvent({
    deliveryId: headers['svix-id'],
    event,
  });
  return Response.json({ received: true });
}

The signing secret belongs to this webhook endpoint and normally begins with a provider-specific prefix. Copy it from the Resend webhook settings into an ignored local environment file such as .env.local. It is not the Resend API key used to send email.

Why the three Svix headers matter

  • svix-id uniquely identifies a delivery and is the best idempotency key.
  • svix-timestamp binds the signature to a time, allowing the verifier to reject stale requests outside its tolerance.
  • svix-signature can contain one or more versioned signatures used to authenticate the body.

Do not implement this protocol by splitting header strings unless you have a compelling reason. The SDK handles encoding, multiple signatures, and timestamp checks. Resend explicitly recommends using the signing secret and these headers for verification. The deeper signature verification guide explains why raw bytes and timing-safe checks matter.

Expose Next.js and register the endpoint

  1. Run npm run dev and confirm the application listens on port 3000.
  2. Start npx portpreview 3000 in another terminal.
  3. In Resend, create a webhook whose endpoint is https://YOUR-TUNNEL.portpreview.dev/api/webhooks/resend.
  4. Select only the email events your application processes.
  5. Copy the endpoint's signing secret into RESEND_WEBHOOK_SECRET and restart Next.js so it loads the variable.
  6. Send a message using a verified domain and inspect the events that reach the local route.

Keep the public URL stable for the session. If the tunnel origin changes, edit the Resend endpoint before testing again. An endpoint configured with an old URL cannot reach your new process, even if localhost itself is healthy.

Use a typed event dispatcher

Webhook payloads should enter one narrow dispatcher. Validate required fields and make unrecognized event types observable without treating them as server failures.

async function processEvent(event: ResendEvent) {
  switch (event.type) {
    case 'email.delivered':
      await markDelivered(event.data.email_id, event.created_at);
      break;
    case 'email.bounced':
      await markBounced(event.data.email_id, event.data.bounce?.message);
      await suppressIfPermanent(event.data);
      break;
    case 'email.complained':
      await suppressRecipients(event.data.to);
      await alertCompliance(event.data.email_id);
      break;
    default:
      await recordUnhandledEvent(event);
  }
}

Keep payload types aligned with the current Resend schema rather than assuming every event has identical data. For example, bounce details and recipient lists may be relevant only on certain events. Save the event type, provider email ID, event timestamp, and a minimal redacted payload for support investigations.

Make handling idempotent before testing retries

Webhook systems provide at-least-once delivery behavior in practice. A timeout can occur after your database commit but before the provider receives your 200 response. The provider then retries a request you already applied. Use svix-id as a unique delivery key and insert it in the same transaction as the state change.

await db.transaction(async (tx) => {
  const inserted = await tx.webhookDelivery.insertOnce({
    provider: 'resend',
    deliveryId,
  });
  if (!inserted) return;
  await applyEmailEvent(tx, event);
});

Do not deduplicate solely by email ID because one email legitimately receives multiple event types. Depending on your data model, keep both a delivery-level unique key and state-transition rules. Read webhook retry and idempotency patterns before connecting events to billing, suppression, or customer notifications.

Return quickly without losing the event

Signature verification is appropriate in the request path; slow business work is not. Persist or enqueue the verified event, then return 2xx. If you return before any durable write, a process crash can lose the event. If you wait for several remote APIs, your endpoint can time out and invite retries. A database inbox table is often the simplest local and production design.

Generate useful test events

Sent and delivered

Send to an address you control from a verified domain. Record the email ID returned by the send API and confirm incoming events update the same row. Delivery timing varies by recipient server, so do not assume events arrive immediately or in a simplistic sequence.

Bounces

Use Resend's documented test addresses or testing features rather than inventing traffic to unrelated domains. Verify that permanent failures suppress future mail while temporary conditions follow your retry policy. Do not automatically suppress on every delayed event.

Complaints

Complaint handling is both deliverability and compliance logic. Ensure a repeated webhook does not create repeated alerts, and ensure the affected recipient is excluded from later campaigns according to your policy.

Troubleshoot Resend webhook failures

Signature verification always fails

Confirm the endpoint's signing secret—not the API key—is loaded. Use await request.text(), do not parse and re-stringify JSON, and pass all three Svix headers with their exact values. Restart the development server after changing .env.local.

The route returns 404 or 405

App Router route files must be named route.ts beneath the intended URL segments and export POST. Check whether middleware rewrites the tunnel request to a locale or login page. Test the public URL with curl and inspect the actual response.

Resend shows retries despite successful processing

Check that every successful branch returns 2xx promptly. Errors thrown after a database update can produce a 500 and a duplicate retry. Make processing transactional and idempotent, then inspect response latency.

Events arrive but cannot be linked to an email

Persist the provider email ID from the original Resend send response. Do not rely on subject lines or recipient addresses as identifiers. Those fields are neither unique nor stable enough for correlation.

Replayed captures fail timestamp verification

That is expected when replaying an old signed request through the normal verifier: its timestamp may be outside the permitted tolerance. Prefer provider redelivery where available. For isolated business-logic tests, verify once, save a sanitized event fixture, and test the dispatcher separately. The replay guide explains this boundary.

Security and privacy for email event testing

  • Never expose RESEND_API_KEY or the endpoint signing secret in source, browser bundles, screenshots, or request logs.
  • Verify before parsing or persisting the event.
  • Redact recipients, subjects, headers, and message metadata from shared tunnel captures.
  • Apply retention limits to raw webhook payloads; store only what support and compliance require.
  • Use a separate local endpoint secret from production and rotate it when the test endpoint is deleted.

The final design should work identically after deployment: public HTTPS endpoint, raw-body verification, durable idempotency, fast acknowledgement, and asynchronous state handling. For App Router-specific raw-body details, see the Next.js webhook localhost guide.

Frequently asked questions

How do I test Resend webhooks locally in Next.js?
Create an App Router POST route, verify the raw body with the Svix headers and endpoint signing secret, expose port 3000 through HTTPS, and register that public URL in Resend.
Should a Resend webhook use request.json() in Next.js?
Not before verification. Read await request.text() so the signed bytes remain unchanged, verify with Svix, and use the verified event returned by the SDK.
Is the Resend webhook signing secret the same as the API key?
No. The API key authorizes sending requests. Each webhook endpoint has a signing secret used to verify incoming events; store both separately.
How do I prevent duplicate Resend webhook processing?
Store svix-id under a unique constraint and apply the event in the same transaction. Do not deduplicate only by email ID because one email has multiple valid events.