NewLocalCan 3.0 with CLI, Multi-region and more ⟶

Test Resend Webhooks Locally

Every delivery dies in your handler with No matching signature found, the error resend.webhooks.verify() throws, and it almost never means the secret is wrong. Get a stable HTTPS URL Resend can reach, read the svix-signature and the unparsed bytes it actually sent, and replay that same delivery until verification passes.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Resend delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 2Grab your persistent URL, something like your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once.
  3. 3Open the Webhooks page in Resend (resend.com/webhooks) and click Add Webhook. Paste your LocalCan URL with your route, e.g. https://your-app-12.localcan.dev/api/webhooks/resend, and select the events you want to observe. Then take that endpoint’s signing secret (the whsec_… value the create-webhook API returns as signing_secret) and store it as RESEND_WEBHOOK_SECRET. It is scoped to this one endpoint.
  4. 4Fire a real event rather than a synthetic payload: mail bounced@resend.dev for email.bounced, complained@resend.dev for email.complained, delivered@resend.dev for email.delivered. All take a +label extension except the suppressed address. To re-send one you already have, go back to resend.com/webhooks, pick the endpoint, pick the message and click Replay.
  5. 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body. Expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.

Why localhost can't receive Resend webhooks

Resend fires webhooks from its own infrastructure to what its docs call a publicly accessible HTTPS URL. Every event is an outbound request from Resend to you, never a connection you open, and localhost:3000 resolves only on your machine, so there is nothing at the other end and the delivery is logged as a failed attempt against your endpoint.

What makes this worse than most providers: Resend performs no registration handshake. Nothing is challenged, nothing pings your URL, no test event fires on save. Paste a dead address and the dashboard accepts it. You find out hours later, from the failure email Resend sends your team. So the URL has to be one you can watch, which is why Resend’s own docs reach for ngrok or VS Code port forwarding here.

Just want to see what Resend sends first? Peek at the raw payload on webhook.cool, then point the endpoint at LocalCan to forward it into localhost.

What makes the Resend loop fast

One URL means one whsec_ forever

A Resend webhook is identified by its URL. Change the address and you are not editing an endpoint, you are deleting one and creating another, and the new one is issued a new signing_secret. That is a fresh whsec_ in .env and the event list picked again from scratch.

Your …localcan.dev URL is written into the project file and comes back identical after a restart, a reboot or a closed lid, so you pay that registration cost exactly once. Pin a custom domain and the same endpoint can outlive the laptop.

Read the bytes, not a summary line

Verification here is a byte comparison over {svix-id}.{svix-timestamp}.{raw body}, so the only useful evidence is the untouched request. LocalCan captures it: all three svix-* headers as delivered, and the body before any framework has parsed and re-serialised it.

That is what separates the three failures that all present as one 400: a JSON parser that ran before your verifier, a whsec_ from an endpoint you recreated, and a timestamp outside the five-minute window. It also catches the quieter one: a delivery that never reached your app at all because it landed on another port.

Stop waiting out the retry ladder

Resend retries a failed delivery immediately, then at 5 seconds, then 5 minutes, then 30 minutes. Fix your bounce handler ninety seconds too late and the next real attempt is half an hour away, so debugging turns into mailing bounced@resend.dev again and again to keep the loop tight.

Replay ends that. The captured request goes back at your handler byte for byte, as often as you like, with no new email sent and no attempt burned (the suppression write, the dedupe on svix-id, the error path) until the real retry lands on a handler that already works.

Inspect & replay Resend events

Common events

  • email.sentthe API request succeeded, not yet a delivery
  • email.deliveredaccepted by the recipient’s mail server
  • email.bouncedpermanently rejected; fire it with `bounced@resend.dev`
  • email.complainedmarked as spam after delivery
  • email.delivery_delayedtemporary failure, e.g. a full mailbox
  • email.receivedinbound mail arrived at an address you own
  • suppression.addedan address was added to your suppression list

These are the events an email integration has to get right: mirroring email.delivered and email.bounced onto a record, honouring email.complained immediately, keeping your own list in step with suppression.added. Send to the simulator addresses to produce them on demand, then watch each land in Inspect Traffic with its svix-id, svix-timestamp and svix-signature intact and its JSON body expandable down to data.

Delivery is at-least-once, so svix-id is both the value you dedupe on and the thing to check when the same event shows up twice. Fix the handler, hit Replay, and LocalCan re-sends that exact request (same id, same bytes) instead of you sending another email and waiting out the ladder. The Resend-specific caveat: the signed svix-timestamp travels with the replay, so replay while you iterate rather than reaching for something captured yesterday.

Timeout
Resend publishes no response timeout. It asks you to answer HTTP 200 OK on receipt, so do the work asynchronously and reply immediately.
Retries
Immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and a further 10 hours.
Auto-disable
No published threshold. On sustained failure Resend emails your team the endpoint URL, the time of the last failed attempt and the last HTTP status, then eventually disables the endpoint and sends a second notification. Re-enable it from resend.com/webhooks.

Verify the Resend request

Header
svix-signature (with svix-id and svix-timestamp)
Algorithm
HMAC-SHA256, base64-encoded (space-delimited v1,<sig> list)
What's signed
Resend delivers through Svix, so the signed string is {svix-id}.{svix-timestamp}.{raw body}: the svix-id header, a dot, the svix-timestamp header, a dot, then the exact bytes of the body. The HMAC key is not the whsec_ string: strip the prefix and base64-decode the remainder. The digest is base64-encoded and matched against each space-separated v1,<sig> entry in svix-signature. A timestamp more than five minutes from your clock in either direction is rejected before the digest is compared at all.
Secret
The signing_secret (whsec_…) issued when the endpoint was created, returned by the create-webhook API and shown on the endpoint at resend.com/webhooks. Resend’s examples store it as RESEND_WEBHOOK_SECRET. It is scoped to that single endpoint, so recreating the endpoint invalidates it.
Node.js
// Next.js App Router. The SDK path: resend.webhooks.verify() wraps the
// standardwebhooks library, which strips the whsec_ prefix, base64-decodes the
// key, hashes "{id}.{timestamp}.{body}" and enforces the 5-minute tolerance.
import { NextResponse } from 'next/server'
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export async function POST(req) {
  // RAW body. Never req.json() before verifying: parsing and re-serialising
  // changes the bytes and the digest can no longer match.
  const payload = await req.text()

  let event
  try {
    // The SDK renames these to webhook-id / webhook-timestamp / webhook-signature
    // internally; you still read the svix-* headers Resend puts on the wire.
    event = resend.webhooks.verify({
      payload,
      headers: {
        id: req.headers.get('svix-id'),
        timestamp: req.headers.get('svix-timestamp'),
        signature: req.headers.get('svix-signature'),
      },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET, // whsec_...
    })
  } catch (err) {
    // "No matching signature found"  -> wrong secret, or a parsed body
    // "Message timestamp too old"    -> outside the 5-minute window
    return new NextResponse('Invalid webhook: ' + err.message, { status: 400 })
  }

  // At-least-once delivery: dedupe on this id before you write anything.
  const messageId = req.headers.get('svix-id')

  if (event.type === 'email.bounced') {
    // suppress the address and flag the record, keyed on messageId
  }

  return NextResponse.json({ received: true })
}

LocalCan does not verify the signature for you (resend.webhooks.verify() does, or the svix library if you prefer it), but it shows every input that verification depends on, so a rejection stops being a guess. The inspector holds the svix-id, svix-timestamp and svix-signature Resend sent, plus the body exactly as it arrived on the wire.

When No matching signature found comes back, you can check in seconds whether the body your verifier saw is the body Resend signed, whether your whsec_ still belongs to a live endpoint, and whether the timestamp is inside the window. Then replay the same request to confirm the fix, promptly, because of that window.

A replayed capture verifies for five minutes and then stops. The svix-timestamp is part of what was signed, so it travels with the replay, and the library rejects anything outside the window with Message timestamp too old before it looks at the digest. The correct secret will not save you. Replay is the right tool while you iterate on a handler and the wrong one for a delivery from yesterday. For an old event, open the endpoint at resend.com/webhooks, select the message and click Replay: Resend redelivers it freshly signed.

LocalCan and Resend CLI

resend webhooks listen is a real, actively released command: it starts a small HTTP server, registers a temporary webhook, prints a line per event, optionally forwards to your app, and deletes the webhook again on Ctrl+C.

What it does not do is get events to your machine. --url is required (its own help text calls it the tunnel URL and every example is an ngrok address), and that tunnel must point at the CLI’s port, 4318 by default, not at your app, which it reaches separately via --forward-to. So this is not a contest. LocalCan is the URL the command asks for: run localcan http 4318 and hand it the address, or skip the CLI and register your LocalCan URL at resend.com/webhooks for an endpoint that is still there tomorrow.

LocalCanResend CLI
Public HTTPS URLPersistent …localcan.dev, identical after every restart--url is required, brings no URL of its own
Registering the endpointYou add it once at resend.com/webhooksCreates a temporary webhook, deletes it on Ctrl+C
Subscribing to eventsWhatever the saved endpoint subscribes to--events email.sent email.bounced per run
Where the tunnel pointslocalcan http 3000, straight at your appMust target port 4318; app reached via --forward-to
Signing secret to verify withYour endpoint’s whsec_ stays in .envTemporary endpoint’s secret is not printed in the banner
Inspect headers + raw bodyGUI inspector, on by default, full request keptOne summary line per event (--json for NDJSON)
Replay a deliveryOne-click Replay of the exact captured requestNone locally; Dashboard Replay redelivers freshly signed
Works across providersOne URL for Resend, Stripe, GitHub…Resend only

Resend webhook FAQ

Stop guessing why the webhook 401'd. Get a stable Resend endpoint, read every delivery, and replay it until your handler is right.

Free trial. No credit card required.