Webhook Studio
/Resend webhooks
Webhooks / Resend

Resend webhooks

See exactly when an email bounced, opened, or was marked as spam.

Get a test URL

Get a live Resend webhook URL
No signup, no install. The URL works the moment you click, and every request Resend sends is stored with its full headers and raw body.
Free, anonymous, and expires after 3 days.
Building this with an AI agent? Webhook Studio is also an MCP server, so your agent can provision this endpoint, wait for the Resend event, replay it into localhost, and generate types from the real payload — keyless, no human in the browser. One-line install:
claude mcp add --transport http webhook-studio https://webhook-studio.com/mcp \
  --header "Authorization: Bearer $(curl -s -X POST https://webhook-studio.com/api/start | jq -r .api_token)"

Point Resend at this URL

  1. Create a test endpoint below and copy its URL.
  2. In the Resend dashboard, open Webhooks → Add Webhook.
  3. Paste the Webhook Studio URL and select the events you want (email.delivered, email.bounced are the usual first two).
  4. Copy the signing secret — it starts with whsec_.
  5. Send yourself a test email through Resend and watch the lifecycle events land in the Inbox.
Nothing to install. Webhook Studio sits between Resend and your app. Every delivery is stored and inspectable, and routing rules forward the ones you care about to your real backend — with retries, and without losing the request history when your backend is down.

What a Resend delivery looks like

A representative body, using Resend’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  "type": "email.delivered",
  "created_at": "2026-07-21T09:14:02.114Z",
  "data": {
    "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
    "created_at": "2026-07-21T09:13:58.226Z",
    "from": "notifications@example.com",
    "to": [
      "alex@example.com"
    ],
    "subject": "Your receipt from Example",
    "tags": [
      {
        "name": "category",
        "value": "receipt"
      }
    ]
  }
}

Delivery headers

HeaderWhat it carries
svix-signatureSpace-delimited "v1,<base64>" signatures. Multiple entries appear during a secret rotation; any match is valid.
svix-idUnique delivery id, stable across retries — the idempotency key, and part of the signed string.
svix-timestampUnix seconds, also signed. Compare against your clock to bound replay attacks.
User-AgentSvix-Webhooks/<version> — Resend delivers through Svix, like Clerk.

Event types

The events worth handling first. See Resend’s own documentation for the complete list.

EventFires when
email.sentThe API call succeeded and delivery will be attempted. Not proof of delivery.
email.deliveredThe recipient's mail server accepted the message. This is the real success signal.
email.bouncedThe recipient's server permanently rejected it. Stop sending to this address.
email.delivery_delayedA temporary failure; Resend will keep trying.
email.complainedDelivered, then marked as spam. Treat as an unsubscribe — it damages sender reputation.
email.openedThe recipient opened the message. Noisy: prefetching mail clients inflate this.
email.clickedThe recipient clicked a link.
email.failedSending failed outright due to an error.
suppression.addedAn address was added to the suppression list after a hard bounce, complaint, or manually.
contact.createdA contact was added to an audience.

Verify the svix-signature header

Resend signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.

Node.js (Express)
import express from 'express';
import crypto from 'crypto';

const app = express();

app.post('/webhooks/resend', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.get('svix-id');
  const ts = req.get('svix-timestamp');
  const header = req.get('svix-signature') ?? '';

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);

  // whsec_<base64> — decode the part after the prefix to get the key bytes.
  const key = Buffer.from(process.env.RESEND_WEBHOOK_SECRET.split('_')[1], 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${ts}.${req.body}`)
    .digest('base64');

  const ok = header.split(' ').some((part) => {
    const sig = part.startsWith('v1,') ? part.slice(3) : null;
    if (!sig) return false;
    const a = Buffer.from(expected, 'base64');
    const b = Buffer.from(sig, 'base64');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!ok) return res.sendStatus(401);

  res.sendStatus(200);
  const event = JSON.parse(req.body);
  // if (event.type === 'email.bounced') suppress(event.data.to)
});

Retries and delivery guarantees

Resend delivers through Svix. A non-2xx response is retried at 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, and if every attempt to an endpoint fails across a 5-day window the endpoint is disabled. Delivery is explicitly at-least-once and not ordered — email.delivered can arrive before the email.sent it follows — so deduplicate on svix-id and sort by the payload's own created_at when sequence matters.

Common pitfalls

Treating email.sent as delivery

email.sent only means the API accepted the request. The message can still bounce. Any "delivered" state in your UI should be driven by email.delivered, and a bounce can arrive seconds or minutes later.

Reconstructing a timeline from arrival order

Resend does not guarantee ordering, so email.opened can land before email.delivered. Sort on the created_at in the payload rather than on receipt time, or your lifecycle view will show impossible sequences.

Using the whsec_ secret as a raw string

As with Clerk, the HMAC key is the base64-decoded bytes after whsec_, and the signed string is id.timestamp.body rather than the body alone. Stripe's identically prefixed secret behaves differently, and porting that code silently fails every check.

Counting opens as engagement

Open tracking relies on a pixel, and privacy-preserving mail clients fetch it without a human present. email.opened is directional at best; email.clicked is the harder signal.

FAQ

How do I test Resend webhooks without deploying?

Create a test endpoint on this page and add its URL in the Resend dashboard under Webhooks. Send yourself one email and the whole lifecycle — sent, delivered, opened — appears in the Inbox as separate events, with no code deployed and no account required.

Why does my Resend signature verification fail?

Resend uses Svix, so the HMAC key is the base64-decoded bytes after the whsec_ prefix, and the signed string is svix-id, svix-timestamp and the raw body joined by full stops. Verifiers that use the secret verbatim, or sign only the body, fail on every delivery.

Why did email.opened arrive before email.delivered?

Resend delivers at-least-once and without ordering guarantees. Order your own timeline by the created_at field inside the payload rather than by when each webhook arrived.

How do I tell a bounce from a complaint?

email.bounced means the receiving server permanently rejected the message. email.complained means it was delivered and the recipient marked it as spam. Both should stop future sends to that address, but only the complaint indicates the recipient took action.

Can I forward Resend webhooks to localhost?

Yes. Add a routing rule pointing at your local tunnel and matching events are forwarded with retries while still being stored here — useful because email lifecycle events trickle in over minutes, long after a dev server has been restarted.