
How do I enable Resend webhooks for delivered/bounced/complained events and verify the webhook signature?
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:
- Accept
POSTrequests with a JSON body - Read and parse the event payload
- Verify the Resend webhook signature
- Respond with a
2xxstatus 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:
- Go to Webhooks (or the equivalent section under your project).
- Click Create Webhook (or Add webhook).
- Enter your endpoint URL, for example:
https://your-domain.com/webhooks/resend - Choose the events you want:
email.deliveredemail.bouncedemail.complained
- 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_ator 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-Signatureor similar) - A timestamp header (e.g.,
Resend-Timestamp)
The core idea:
- Resend concatenates / formats specific values (often timestamp + raw body).
- It runs an HMAC using your webhook secret.
- 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
DELIVEREDin 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:
- Send test emails via Resend to a controlled address.
- Use dashboard tools if available:
- Some providers let you send “test webhooks” from the UI.
- Use local tunneling during development:
ngrok,localtunnel, orcloudflaredto exposelocalhost.- Point your Resend webhook URL to the tunnel URL.
- 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-Timestampis 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) orhmac.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
POSTonly - 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:
- Create a secure
POSTendpoint in your backend. - In the Resend dashboard, add a webhook pointing to that endpoint.
- Enable the events:
email.deliveredemail.bouncedemail.complained
- Store the webhook secret as an environment variable.
- Read the raw request body and headers in your handler.
- Recompute and verify the signature using the secret and timestamp.
- Parse the JSON and handle each event type, updating your database and suppression lists.
- 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.