← All guides
ResendJuly 12, 20265 min read

Test Resend Email Webhooks on Localhost with Relayers

Receive, filter, and transform Resend email webhooks on your local machine. This guide wires a resend webhook localhost tunnel so you can debug bounces and complaints fast.

Resend fires webhooks the moment an email is sent, delivered, bounced, complained about, opened, or clicked. That's exactly the data you need to keep a suppression list clean or trigger a follow-up flow. The problem shows up the second you try to build the handler: Resend can't reach localhost:3000, so you end up deploying half-baked code to staging just to see a single email.bounced payload land.

There's a faster loop. Point Resend at a public Relayers endpoint, tunnel the events straight to your machine, and iterate against real payloads with real Svix signatures — no redeploys, no ngrok URL that dies every restart.

What you'll build

Resend sends every selected event to your Relayers endpoint. Relayers filters and (optionally) reshapes each one, then forwards it down an authenticated tunnel to whatever is running on your laptop — with the original body and headers preserved so Svix verification still passes.

Prerequisites

1Create an endpoint in Relayers

Open the Relayers desktop app and go to Endpoints → Create. Give it a name like resend-dev and copy the public ingest URL. It looks like this:

https://api.relayers.app/v1/webhooks/wep_a1b2c3d4e5

That wep_ URL is publicly reachable, so Resend can post to it from the internet. Everything downstream — filtering, transforming, tunneling — hangs off this one endpoint. You can hit Send test from the Endpoints view to confirm it's live before you touch Resend.

2Add the webhook in Resend

In the Resend Dashboard, go to Webhooks → Add Webhook. Paste your Relayers ingest URL into Endpoint URL, then select the events you care about:

For a suppression-list workflow, email.bounced and email.complained are the two that matter. Save the webhook.

Resend delivers webhooks through Svix, so each request arrives with three signing headers:

svix-id:        msg_2abc...
svix-timestamp: 1720000000
svix-signature: v1,g0h1i2j3...

Copy the Signing Secret from the Resend webhook page (it starts with whsec_). You'll use it locally to verify signatures in step 5.

3Forward events to localhost

Log in once, then start the tunnel:

relayers login
relayers listen --forward localhost:3000

Now trigger a real event — send yourself a test email from Resend, or use a known-bad address like bounce@simulator.amazonses.com to force an email.bounced. The event travels Resend → Relayers → tunnel → your handler in one hop. Watch it arrive live in the Events view, and click any event to open the payload inspector.

A raw email.bounced body looks roughly like this:

{
  "type": "email.bounced",
  "data": {
    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    "to": ["user@example.com"],
    "from": "you@yourdomain.com",
    "subject": "Your receipt"
  }
}

4Route and filter with JQ

You probably don't want every email.opened ping hitting your suppression logic. In the Rules view, add a rule with a JQ filter that only matches bounces:

{ "type": "email.bounced" }

Set the rule's destination to your tunnel (localhost:3000). Rules match top-to-bottom and first match wins, so you can stack a second rule for complaints and reorder them by dragging:

{ "type": "email.complained" }

Anything that doesn't match a rule is dropped, so your local handler only ever sees the events it's built to process. If you want a catch-all during debugging, add a rule with no filter as the last entry.

5Transform the payload

Resend's payload is nested. If your handler just wants a flat record to drop into a suppression table, use the Transformations view. Relayers supports jq, JSONata, JavaScript, and Go templates, with a test playground so you can preview the output before saving. A jq transform:

{
  event: .type,
  emailId: .data.email_id,
  to: .data.to
}

Paste a sample bounce into the playground, confirm the output, and attach the transformation to your rule. Your handler now receives:

{
  "event": "email.bounced",
  "emailId": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
  "to": ["user@example.com"]
}

A note on signatures

Relayers forwards the original request body and headers untouched. That means the svix-id, svix-timestamp, and svix-signature headers reach your localhost handler exactly as Resend sent them, and Svix verification works locally with no changes:

import { Webhook } from "svix";

const wh = new Webhook(process.env.RESEND_SIGNING_SECRET);
const payload = wh.verify(rawBody, {
  "svix-id": req.headers["svix-id"],
  "svix-timestamp": req.headers["svix-timestamp"],
  "svix-signature": req.headers["svix-signature"],
});

One caveat: Svix signs the raw body. If you apply a Relayers transformation, the transformed JSON no longer matches the original signature — so verify against the untransformed payload, or keep transformations for downstream steps that don't re-verify. For local signature testing, route the raw event straight through.

Going to production

When your handler is solid, swap the tunnel destination for a public URL. In the Rules view, change the destination from localhost:3000 to your deployed endpoint (https://api.yourapp.com/webhooks/resend). The filters and transformations you already tuned carry over unchanged — no rewrite. Use Export to save the whole endpoint as a JSON bundle and Import it into a teammate's workspace or a second environment.

Troubleshooting

Wrap-up

Resend plus Relayers turns email-event handling into a tight local loop: real payloads, real Svix signatures, JQ filtering, and payload transforms — all hitting localhost while you code. When you're ready, one destination change ships the exact same pipeline to production.

Create a free endpoint at relayers.app and download the app to start testing your Resend webhooks on localhost today.