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
- A Resend account with a verified sending domain.
- A local service listening on
localhost:3000(Express, Next.js route handler, FastAPI — anything). - The Relayers desktop app or CLI installed. Grab it from relayers.app.
- The
relayersCLI on yourPATH.
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:
email.sentemail.deliveredemail.bouncedemail.complainedemail.openedemail.clicked
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
- Svix verify fails — Make sure you're verifying against the raw body, not a transformed one, and that the signing secret matches the exact webhook in Resend (each webhook has its own secret).
- No events arriving — Confirm the event type is actually selected in the Resend webhook config. If you only checked
email.bounced, a delivered email won't fire anything. - Events land in Relayers but not localhost — Check that
relayers listenis still running and that a Rule's destination points at the rightlocalhost:PORT. The Analytics and Events views will show the event was received even when the tunnel is down. - Need to re-test a specific event — Open it in the Events view and hit retry to replay it through your rules and tunnel, instead of re-sending mail from Resend.
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.