# Guide to Resend Webhooks: Features and Best Practices

Resend webhooks notify your application about the lifecycle of the emails you send: delivered, bounced, complained, opened, clicked, and inbound email received. If you're building on Resend, webhooks are how you react to email events without polling.

This guide covers how Resend webhooks work, the events you'll handle, how to verify the Svix signature with the official SDK, and the best practices for production.

## What are Resend webhooks?

Resend webhooks are JSON POSTs delivered through Svix, so they follow the Svix signature format: three headers, `svix-id`, `svix-timestamp`, and `svix-signature`. The signing secret starts with `whsec_`. The signature is an HMAC-SHA256 (base64) over `<svix-id>.<svix-timestamp>.<raw_body>`, and the `svix-signature` header can carry multiple space-separated values (each prefixed `v1,`); any one matching is valid. Deliveries older than 5 minutes are rejected. The official `resend` SDK verifies all of this.

## Resend webhook features

| Feature | Details |
| --- | --- |
| Configuration | Resend dashboard > Webhooks |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Signature scheme | Svix: base64 HMAC-SHA256 over `id.timestamp.body`, `whsec_` secret |
| Replay window | 5 minutes; multiple `v1,` signatures, any match is valid |
| SDK | `resend` `webhooks.verify` (Node); manual Svix verification for Python |
| Delivery | At-least-once (duplicates possible); dedupe on `data.email_id` |

## Common events

Resend covers the outbound email lifecycle plus inbound:

| Event | Fires when |
| --- | --- |
| `email.sent` | The message is accepted for sending |
| `email.delivered` | The message is delivered |
| `email.delivery_delayed` | Delivery is delayed |
| `email.bounced` | The message bounces |
| `email.complained` | The recipient marks it as spam |
| `email.opened` / `email.clicked` | The recipient opens or clicks |
| `email.received` | An inbound email arrives (metadata only; fetch the body via the Receiving API) |

## Setting up Resend webhooks

In the Resend dashboard, go to Webhooks, add your endpoint, select events, and copy the signing secret (`whsec_...`) into `RESEND_WEBHOOK_SECRET`. For inbound `email.received`, configure a receiving address or an MX record. Note that the inbound payload is metadata only, you fetch the body and attachments via the Receiving API.

## Securing Resend webhooks

Use the official SDK's `webhooks.verify`, passing the raw body and the three Svix headers (the SDK expects short key names `id` / `timestamp` / `signature`). It throws on an invalid signature and returns the parsed event otherwise. Capture the raw body before parsing.

```javascript
const { Resend } = require("resend");

const resend = new Resend(process.env.RESEND_API_KEY);
const SECRET = process.env.RESEND_WEBHOOK_SECRET;

app.post("/webhooks/resend", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = resend.webhooks.verify({
      payload: req.body.toString("utf8"), // raw body
      headers: {
        id: req.headers["svix-id"],
        timestamp: req.headers["svix-timestamp"],
        signature: req.headers["svix-signature"],
      },
      webhookSecret: SECRET, // your whsec_... secret
    });
    res.sendStatus(200); // acknowledge fast
    processQueue.add(event); // branch on event.type, dedupe on data.email_id, async
  } catch {
    res.sendStatus(401); // invalid signature or stale timestamp
  }
});

```

The same verification in Python, done manually (the Svix construction):

```python
import base64
import hashlib
import hmac
import os

SECRET = os.environ["RESEND_WEBHOOK_SECRET"]

def verify(raw_body: bytes, svix_id: str, svix_timestamp: str, svix_signature: str) -> bool:
    key = base64.b64decode(SECRET.split("_", 1)[1])  # strip whsec_ then base64-decode
    signed = f"{svix_id}.{svix_timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for part in svix_signature.split():  # space-separated, each "v1,<sig>"
        if part.startswith("v1,") and hmac.compare_digest(part[3:], expected):
            return True
    return False

```

## Resend webhook limitations and pain points

### The SDK wants short header keys

The Problem: `webhooks.verify` takes `{ id, timestamp, signature }`, but the actual HTTP headers are `svix-id` / `svix-timestamp` / `svix-signature`. Passing the full header names fails.

Why It Happens: The SDK maps the Svix headers to short keys.

Workarounds:

* Map `svix-*` headers to the short keys when calling `verify`.

How Hookdeck Can Help: Hookdeck verifies the Svix signature at the edge, so your app doesn't juggle header naming.

### The secret is base64 after the prefix

The Problem: When verifying manually, the `whsec_` secret must have its prefix stripped and the remainder base64-decoded before use as the HMAC key. Using the whole string, or skipping the decode, never matches.

Why It Happens: Svix secrets are `whsec_` + base64 key material.

Workarounds:

* Strip `whsec_`, base64-decode, then HMAC; or use the SDK.

How Hookdeck Can Help: Hookdeck handles the secret format at the edge.

### Inbound payload is metadata only

The Problem: `email.received` carries sender, recipient, and subject, but not the body or attachment content, so acting on the message needs a second call.

Why It Happens: Resend keeps the inbound webhook lean.

Workarounds:

* Fetch the full message and attachments via the Receiving API.

How Hookdeck Can Help: Hookdeck durably queues events so your worker can enrich them via the API at its own pace.

### At-least-once delivery

The Problem: Duplicates are possible, and the signature proves authenticity, not uniqueness.

Why It Happens: Resend delivers at-least-once with retries.

Workarounds:

* Dedupe on `data.email_id` and make handlers idempotent.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify with the official SDK over the raw body

Use `resend.webhooks.verify` in Node with the mapped Svix headers; verify the Svix construction manually in Python.

### Respect the 5-minute window and multiple signatures

Reject stale deliveries, and accept if any `v1,` signature matches.

### Dedupe on data.email_id

Delivery is at-least-once, so make handlers idempotent.

### Acknowledge fast, process asynchronously

Return 200 quickly and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Resend webhooks use the Svix format: `svix-id` / `svix-timestamp` / `svix-signature` headers, a `whsec_` secret, base64 HMAC-SHA256 over `id.timestamp.body`, a 5-minute window, and possibly multiple signatures. Verify with the official `resend` SDK (map the headers to short keys), or the Svix construction manually in Python, dedupe on `data.email_id`, and fetch inbound bodies via the Receiving API.

[Hookdeck](https://hookdeck.com) verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Resend webhooks reliably in minutes.