How do I enable Resend webhooks for delivered/bounced/complained events and verify the webhook signature?
Communications APIs (CPaaS)

How do I enable Resend webhooks for delivered/bounced/complained events and verify the webhook signature?

9 min read

Resend webhooks make it easy to track the full lifecycle of your emails—especially key events like delivered, bounced, and complained (spam reports). To use them safely in production, you not only need to enable the right events, but also verify the webhook signature to ensure the payload really comes from Resend and hasn’t been tampered with.

This guide walks through:

  • Enabling Resend webhooks for delivered, bounced, and complained events
  • Understanding the event payloads
  • Verifying webhook signatures securely in your backend
  • Testing, troubleshooting, and best practices

What are Resend webhooks and why use them?

Resend webhooks are HTTP callbacks that Resend sends to your server when something happens to an email you sent, such as:

  • It’s successfully delivered to the recipient’s mail server
  • It bounces (hard/soft bounce)
  • The recipient marks it as spam (complained)

Using webhooks, you can:

  • Maintain accurate delivery status in your database
  • Stop emailing bounced addresses
  • Suppress users who complain/spam-report your messages
  • Drive product logic (e.g., activate accounts on delivery, trigger alerts on bounce)

To do this correctly, you’ll configure webhooks for the right events and then verify each webhook request using Resend’s signature headers.


Step 1: Prepare your webhook endpoint

Before enabling webhooks in Resend, you need an HTTPS endpoint in your backend that can:

  1. Accept POST requests with a JSON body
  2. Read and parse the event payload
  3. Verify the Resend webhook signature
  4. Respond with a 2xx status code (e.g., 200) when successful

Example design:

  • URL: https://your-domain.com/webhooks/resend
  • Method: POST
  • Content type: application/json

Basic JSON handler (pseudo-code):

POST /webhooks/resend
  - Read raw request body as string (for signature verification)
  - Read headers (including Resend signature header)
  - Verify signature using your Resend webhook secret
  - Parse JSON body into event object
  - Handle event type (delivered/bounced/complained)
  - Return 200 OK

The critical part is verifying the signature using the exact raw body of the request.


Step 2: Enable Resend webhooks for delivered, bounced, and complained events

In the Resend dashboard:

  1. Go to Webhooks (or the equivalent section under your project).
  2. Click Create Webhook (or Add webhook).
  3. Enter your endpoint URL, for example:
    https://your-domain.com/webhooks/resend
  4. Choose the events you want:
    • email.delivered
    • email.bounced
    • email.complained
  5. Save the webhook.

Resend will typically generate a webhook signing secret for this webhook URL. Copy it and store it securely (environment variable, secret manager, etc.)—you will use it to verify signatures.

Example environment variable:

RESEND_WEBHOOK_SECRET=whsec_1234567890abcdef

Step 3: Understand delivered, bounced, and complained event payloads

Resend sends a JSON payload in the webhook body. The exact shape may evolve, but it generally includes:

  • A high-level type (event type)
  • A created_at or timestamp
  • Data about the email and status

Example email.delivered event:

{
  "type": "email.delivered",
  "created_at": "2024-04-01T12:34:56.789Z",
  "data": {
    "email_id": "email_123",
    "to": "user@example.com",
    "from": "no-reply@your-domain.com",
    "subject": "Welcome!",
    "timestamp": 1711974896789,
    "message_id": "<abc123@resend.io>",
    "tags": {
      "user_id": "user_42"
    }
  }
}

Example email.bounced event:

{
  "type": "email.bounced",
  "created_at": "2024-04-01T12:35:56.789Z",
  "data": {
    "email_id": "email_123",
    "to": "bad-address@example.com",
    "from": "no-reply@your-domain.com",
    "subject": "Welcome!",
    "bounce_type": "hard",
    "bounce_reason": "Mailbox does not exist",
    "timestamp": 1711974956789
  }
}

Example email.complained event:

{
  "type": "email.complained",
  "created_at": "2024-04-01T12:40:00.000Z",
  "data": {
    "email_id": "email_123",
    "to": "user@example.com",
    "from": "no-reply@your-domain.com",
    "subject": "Weekly newsletter",
    "complaint_type": "abuse",
    "timestamp": 1711975200000
  }
}

Always rely on the Resend docs for the latest schema, but the type field is your first filter for routing logic.


Step 4: How Resend webhook signatures work

To protect your webhook, Resend signs each request. The server includes:

  • A signature header (e.g., Resend-Signature or similar)
  • A timestamp header (e.g., Resend-Timestamp)

The core idea:

  1. Resend concatenates / formats specific values (often timestamp + raw body).
  2. It runs an HMAC using your webhook secret.
  3. It sends the result in the signature header.

Your job:

  • Recompute the HMAC using the same algorithm and raw body with your stored secret.
  • Compare your computed signature to the one in the header using a constant-time comparison.
  • Optionally, enforce a timestamp tolerance (e.g., reject if older than 5 minutes) to prevent replay attacks.

Check the Resend docs for the exact:

  • Header names
  • Signature scheme (HMAC-SHA256 is common)
  • Payload format used for the signature

Step 5: Verifying Resend webhook signatures (code examples)

Below are conceptual examples. Adjust according to Resend’s exact spec for header names and signing algorithm.

Node.js (Express) example

Install dependencies:

npm install express crypto body-parser

Key rule: DO NOT parse JSON before verifying; use the raw body.

const express = require("express");
const crypto = require("crypto");
const bodyParser = require("body-parser");

const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET;

const app = express();

// Keep raw body for signature verification
app.use(
  "/webhooks/resend",
  bodyParser.raw({ type: "application/json" })
);

function verifyResendSignature(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  // Example payload to sign; confirm with Resend docs
  const payload = `${timestamp}.${rawBody.toString("utf8")}`;

  const hmac = crypto
    .createHmac("sha256", RESEND_WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(hmac, "utf8"),
    Buffer.from(signature, "utf8")
  );
}

app.post("/webhooks/resend", (req, res) => {
  const rawBody = req.body;
  const signature = req.header("Resend-Signature");
  const timestamp = req.header("Resend-Timestamp");

  // Optional: reject if timestamp is too old
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) {
    return res.status(400).send("Timestamp too old");
  }

  if (!verifyResendSignature(rawBody, signature, timestamp)) {
    return res.status(400).send("Invalid signature");
  }

  // Signature is valid; now parse JSON
  const event = JSON.parse(rawBody.toString("utf8"));

  switch (event.type) {
    case "email.delivered":
      // Update email status to 'delivered'
      break;
    case "email.bounced":
      // Mark address as bounced/suppress
      break;
    case "email.complained":
      // Add user to complaint/suppression list
      break;
    default:
      // Ignore unsupported event types
  }

  res.status(200).send("OK");
});

app.listen(3000, () => {
  console.log("Resend webhook listening on port 3000");
});

Python (FastAPI) example

pip install fastapi uvicorn python-multipart
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, Header, HTTPException

from pydantic import BaseModel

RESEND_WEBHOOK_SECRET = "your_webhook_secret"

app = FastAPI()


def verify_resend_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
  if not signature or not timestamp:
    return False

  # Example payload to sign; confirm with Resend docs
  payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")

  digest = hmac.new(
    RESEND_WEBHOOK_SECRET.encode("utf-8"),
    payload,
    hashlib.sha256,
  ).hexdigest()

  return hmac.compare_digest(digest, signature)


@app.post("/webhooks/resend")
async def resend_webhook(
  request: Request,
  resend_signature: str = Header(None, alias="Resend-Signature"),
  resend_timestamp: str = Header(None, alias="Resend-Timestamp"),
):
  raw_body = await request.body()

  # Optional timestamp validation (5 minutes)
  now = int(time.time())
  if abs(now - int(resend_timestamp)) > 300:
    raise HTTPException(status_code=400, detail="Timestamp too old")

  if not verify_resend_signature(raw_body, resend_signature, resend_timestamp):
    raise HTTPException(status_code=400, detail="Invalid signature")

  event = await request.json()
  event_type = event.get("type")

  if event_type == "email.delivered":
    # Handle delivered
    pass
  elif event_type == "email.bounced":
    # Handle bounced
    pass
  elif event_type == "email.complained":
    # Handle complained
    pass

  return {"status": "ok"}

Use similar patterns in other frameworks: access the raw request body, verify the signature, then parse JSON.


Step 6: Handling delivered, bounced, and complained events in your app

Once the signature is verified, you can safely update your system.

Delivered events (email.delivered)

Possible actions:

  • Mark message as DELIVERED in your database
  • Track delivery metrics per campaign
  • Trigger follow-up workflows that require a delivered email (for some flows)

Example logic:

if (event.type === "email.delivered") {
  const { email_id, to } = event.data;
  await db.emails.update(email_id, {
    status: "DELIVERED",
    deliveredAt: new Date(event.created_at),
  });
}

Bounced events (email.bounced)

Bounces indicate delivery failure. You should:

  • Update email status to BOUNCED
  • Flag the recipient as invalid (especially on hard bounces)
  • Avoid sending future emails to that address
if (event.type === "email.bounced") {
  const { email_id, to, bounce_type, bounce_reason } = event.data;

  await db.emails.update(email_id, {
    status: "BOUNCED",
    bounceType: bounce_type,
    bounceReason: bounce_reason,
    bouncedAt: new Date(event.created_at),
  });

  if (bounce_type === "hard") {
    await db.recipients.upsert(to, { isDeliverable: false });
  }
}

Complained events (email.complained)

Complaints (spam reports) are serious. Best practices:

  • Immediately stop marketing emails to the complaining address
  • Consider suppressing all non-critical email types for that user
  • Respect regulations and deliverability standards
if (event.type === "email.complained") {
  const { email_id, to } = event.data;

  await db.emails.update(email_id, {
    status: "COMPLAINED",
    complainedAt: new Date(event.created_at),
  });

  await db.subscriptions.unsubscribeEmail(to, {
    reason: "complaint",
  });
}

Step 7: Testing your Resend webhooks

To confirm your configuration:

  1. Send test emails via Resend to a controlled address.
  2. Use dashboard tools if available:
    • Some providers let you send “test webhooks” from the UI.
  3. Use local tunneling during development:
    • ngrok, localtunnel, or cloudflared to expose localhost.
    • Point your Resend webhook URL to the tunnel URL.
  4. Log everything temporarily:
    • Raw headers, raw body, and verification result.
    • Remove or sanitize logs in production.

Example: log to console in development only:

if (process.env.NODE_ENV !== "production") {
  console.log("Headers:", req.headers);
  console.log("Raw body:", rawBody.toString("utf8"));
}

Common pitfalls and how to avoid them

1. Parsing JSON before verifying

If your framework’s JSON middleware runs before you capture the raw body, the body may be altered (whitespace, encoding) and signature verification will fail.

Fix: Use a raw body parser or framework-specific mechanism to capture the unmodified body for verification.

2. Using the wrong secret or mixing up secrets

Resend may support multiple webhooks with different secrets.

Fix:

  • Store the correct secret for each webhook.
  • If you have multiple endpoints, match secrets by URL or ID.

3. Ignoring timestamps

If you don’t validate timestamps, a leaked signed payload can be replayed.

Fix:

  • Reject requests where Resend-Timestamp is older than a tolerance (e.g., 300 seconds).

4. Using non-constant-time comparison

String equality (===) can leak timing information.

Fix:

  • Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python), or equivalent.

Security best practices for Resend webhooks

To securely enable Resend webhooks for delivered, bounced, and complained events:

  • Always enforce HTTPS on your webhook URL
  • Verify signatures for every request
  • Validate timestamps to prevent replay
  • Whitelist Resend IPs (if IP range is provided) as an extra layer
  • Limit methods to POST only
  • Return 2xx quickly and process heavy work asynchronously (e.g., queue/worker)

Summary: How to enable Resend webhooks and verify signatures

To fully implement webhooks for delivered, bounced, and complained email events:

  1. Create a secure POST endpoint in your backend.
  2. In the Resend dashboard, add a webhook pointing to that endpoint.
  3. Enable the events:
    • email.delivered
    • email.bounced
    • email.complained
  4. Store the webhook secret as an environment variable.
  5. Read the raw request body and headers in your handler.
  6. Recompute and verify the signature using the secret and timestamp.
  7. Parse the JSON and handle each event type, updating your database and suppression lists.
  8. Test with real and/or test webhooks and monitor logs for failures.

Following this process ensures your webhook integration is both functional and secure, giving you reliable, real-time insight into delivered, bounced, and complained events from Resend.