
How do I enable Resend webhooks for delivered/bounced/complained events and verify the webhook signature?
Most developers using Resend eventually need two things: reliable webhooks for message events (delivered, bounced, complained, etc.) and a secure way to verify that those webhooks really came from Resend. This guide walks through how to enable Resend webhooks for delivered/bounced/complained events and how to verify the webhook signature end-to-end.
Overview: How Resend webhooks work
Resend can send HTTP POST requests (webhooks) to your backend whenever certain email events occur, such as:
email.deliveredemail.bouncedemail.complainedemail.openedemail.clickedemail.sentemail.delivery_delayed
Each webhook request contains:
- A JSON payload describing the event
- HTTP headers, including a signature header
- A timestamp to prevent replay attacks
Your responsibilities are to:
- Configure the webhook endpoint in the Resend dashboard.
- Accept and parse the JSON payload.
- Verify the webhook signature using your signing secret.
- Return a
2xxstatus code to acknowledge receipt.
Step 1: Create a webhook endpoint in your application
Start by defining an HTTP endpoint that Resend can call when events occur.
Key requirements:
- HTTPS URL (recommended; some environments may require it).
- Accepts POST requests.
- Can read JSON in the request body.
- Responds with a
2xxstatus code (e.g.,200or204) on success.
Example webhook endpoint (Node.js / Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
// Use raw body for signature verification
app.use(
'/webhooks/resend',
express.raw({ type: 'application/json' })
);
app.post('/webhooks/resend', (req, res) => {
const rawBody = req.body; // Buffer
const signature = req.header('resend-signature') || '';
const timestamp = req.header('resend-timestamp') || '';
// TODO: verify signature using your Resend signing secret
// Parse body once verified
const event = JSON.parse(rawBody.toString('utf8'));
// Handle different event types
switch (event.type) {
case 'email.delivered':
// handle delivered
break;
case 'email.bounced':
// handle bounced
break;
case 'email.complained':
// handle complained
break;
default:
// ignore others or log
break;
}
res.status(200).send('ok');
});
app.listen(3000, () => {
console.log('Listening on port 3000');
});
You’ll complete the signature verification logic in a later step.
Step 2: Enable webhooks in the Resend dashboard
To start receiving delivered, bounced, and complained events, you have to register your webhook URL in the Resend dashboard.
-
Log in to your Resend account.
-
Go to Webhooks (or Settings → Webhooks, depending on the UI).
-
Click Create webhook or Add endpoint.
-
Enter your webhook URL, e.g.:
https://your-domain.com/webhooks/resend
-
Choose the events you want to subscribe to:
email.deliveredemail.bouncedemail.complained- (Optionally add other events like
email.openedoremail.clickedif needed.)
-
Save the webhook configuration.
Resend will now send POST requests to your endpoint whenever those events occur.
Step 3: Understand the webhook payload structure
A typical email-related webhook from Resend includes:
type: the event type (e.g.,email.delivered,email.bounced,email.complained)created_at: timestamp of the eventdata: an object describing the email and event details
Example payload for a delivered event (structure may vary slightly):
{
"type": "email.delivered",
"created_at": "2024-04-12T10:23:45.000Z",
"data": {
"object": "email",
"id": "b3f0896d-1e3c-4ba6-9b1d-c52dfc0a2c71",
"from": "no-reply@example.com",
"to": ["user@example.com"],
"subject": "Welcome to our app",
"tags": {
"userId": "12345"
},
"bounce": null,
"complaint": null
}
}
A bounced event might look like:
{
"type": "email.bounced",
"created_at": "2024-04-12T10:23:45.000Z",
"data": {
"object": "email",
"id": "b3f0896d-1e3c-4ba6-9b1d-c52dfc0a2c71",
"from": "no-reply@example.com",
"to": ["user@example.com"],
"subject": "Welcome to our app",
"bounce": {
"type": "Permanent",
"code": "550",
"reason": "Mailbox not found"
},
"complaint": null
}
}
And a complained event (spam complaint):
{
"type": "email.complained",
"created_at": "2024-04-12T10:23:45.000Z",
"data": {
"object": "email",
"id": "b3f0896d-1e3c-4ba6-9b1d-c52dfc0a2c71",
"from": "no-reply@example.com",
"to": ["user@example.com"],
"subject": "Weekly newsletter",
"complaint": {
"feedback_type": "abuse",
"user_agent": "ExampleFeedbackLoop/1.0"
},
"bounce": null
}
}
Use the type field as your main switch for routing logic.
Step 4: Get your webhook signing secret
To verify the webhook signature, you must have a signing secret. This is provided by Resend:
- In the Resend dashboard, open your Webhook configuration.
- Look for a Signing secret or Secret field.
- Copy the signing secret and store it securely (e.g., in environment variables).
Example environment variable:
RESEND_WEBHOOK_SECRET=whsec_1234567890abcdef
Never commit this secret to version control or expose it client-side.
Step 5: Verify the Resend webhook signature
Resend includes a signature header so you can confirm the request is authentic. While the exact header names and algorithm can evolve, a typical pattern looks like:
resend-signature: HMAC signatureresend-timestamp: Unix timestamp in seconds or ISO timestamp
General verification steps
- Read the raw request body as a string or buffer (before JSON parsing).
- Get the signature and timestamp from the headers.
- Construct a signed payload string. Commonly:
timestamp + "." + rawBody. - Compute an HMAC using your webhook secret and the payload string (e.g., HMAC-SHA256).
- Compare the computed signature to the one from the header using a constant-time comparison.
- Optionally reject requests if the timestamp is too old (e.g., older than 5 minutes).
Node.js example: verify Resend webhook signature
import crypto from 'crypto';
import express from 'express';
const app = express();
const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET!;
// Use raw body so we can verify signature correctly
app.use(
'/webhooks/resend',
express.raw({ type: 'application/json' })
);
function verifyResendSignature({
rawBody,
signatureHeader,
timestampHeader
}: {
rawBody: Buffer;
signatureHeader: string;
timestampHeader: string;
}): boolean {
if (!signatureHeader || !timestampHeader) return false;
const timestamp = Number(timestampHeader);
if (Number.isNaN(timestamp)) return false;
// Optional: reject old timestamps (e.g., 5 minutes)
const now = Math.floor(Date.now() / 1000);
const maxAge = 5 * 60; // 5 minutes
if (Math.abs(now - timestamp) > maxAge) {
return false;
}
const payload = `${timestamp}.${rawBody.toString('utf8')}`;
const expectedSignature = crypto
.createHmac('sha256', RESEND_WEBHOOK_SECRET)
.update(payload)
.digest('hex');
// Constant-time comparison
const provided = Buffer.from(signatureHeader, 'utf8');
const expected = Buffer.from(expectedSignature, 'utf8');
return (
provided.length === expected.length &&
crypto.timingSafeEqual(provided, expected)
);
}
app.post('/webhooks/resend', (req, res) => {
const rawBody = req.body as Buffer;
const signature = req.header('resend-signature') || '';
const timestamp = req.header('resend-timestamp') || '';
const isValid = verifyResendSignature({
rawBody,
signatureHeader: signature,
timestampHeader: timestamp
});
if (!isValid) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
switch (event.type) {
case 'email.delivered':
// Update your DB: mark message as delivered
break;
case 'email.bounced':
// Mark address as invalid, stop sending, log bounce details
break;
case 'email.complained':
// Immediately suppress recipient and avoid future sends
break;
default:
// Ignore or log other event types
break;
}
res.status(200).send('ok');
});
app.listen(3000, () => {
console.log('Resend webhook listener running on port 3000');
});
Adapt the signature algorithm or header names as per the current Resend documentation for webhooks.
Step 6: Handle delivered, bounced, and complained events properly
Once the webhook signature is verified, you can safely act on the event data.
Handling email.delivered
Common actions:
- Update message status in your database:
sent → delivered. - Trigger downstream actions, e.g., analytics events or success tracking.
- Optionally log the timestamp for delivery-duration analysis.
Example logic (pseudo-code):
if (event.type === 'email.delivered') {
const emailId = event.data.id;
await db.emails.update({
id: emailId,
status: 'delivered',
deliveredAt: event.created_at
});
}
Handling email.bounced
Bounces indicate delivery failures. For most apps, you should:
- Mark the email address as invalid or “hard bounced” when appropriate.
- Stop sending non-critical messages to that address.
- Log the reason and code for review.
if (event.type === 'email.bounced') {
const emailId = event.data.id;
const address = event.data.to?.[0];
const bounce = event.data.bounce;
await db.emails.update({
id: emailId,
status: 'bounced',
bounceCode: bounce?.code,
bounceReason: bounce?.reason
});
if (address) {
await db.recipients.update({
email: address,
deliverability: 'bounced'
});
}
}
Handling email.complained
Spam complaints are serious signals. Best practice:
- Immediately suppress the complaining email address.
- Avoid sending any further marketing or transactional emails.
- Log and monitor complaint rate.
if (event.type === 'email.complained') {
const emailId = event.data.id;
const address = event.data.to?.[0];
await db.emails.update({
id: emailId,
status: 'complained'
});
if (address) {
await db.recipients.update({
email: address,
deliverability: 'complained',
suppressed: true
});
}
}
Step 7: Test your Resend webhook integration
Before relying on webhooks in production, test thoroughly.
Use Resend’s test tools (if available)
- In the Webhooks section of the dashboard, look for a Send test event or Test webhook feature.
- Choose an event type (
email.delivered,email.bounced,email.complained). - Send a test payload to your configured URL and confirm:
- Your server receives the request.
- Signature verification passes.
- Your application logic updates state correctly.
Trigger real events in sandbox / staging
-
Send test emails via Resend to:
- A valid test email (to get
email.delivered). - Known bounce test addresses (if Resend or your provider offers them).
- An address connected to a test complaint loop, if available.
- A valid test email (to get
-
Monitor logs for:
- Webhook hits.
- Any 4xx or 5xx responses from your server.
- Signature verification failures (often due to incorrect secret or body parsing).
Common pitfalls and how to avoid them
1. JSON body parsing breaks signature verification
If you use express.json() or similar body parsers before verifying the signature, you may alter the raw body and cause verification to fail.
Fix: Use express.raw({ type: 'application/json' }) for the webhook route and only parse the JSON after signature verification.
2. Wrong webhook secret
If you have multiple webhook endpoints or environments (dev/staging/prod), ensure you’re using the correct secret for each URL.
- Use environment variables like
RESEND_WEBHOOK_SECRET_DEV,RESEND_WEBHOOK_SECRET_PROD. - Double-check you’re copying the secret from the correct webhook configuration in the Resend dashboard.
3. Ignoring timestamps
Without timestamp validation, attackers could replay old webhook payloads.
- Always check the
resend-timestamp. - Reject requests where the timestamp is too far from the current time (e.g., > 5 minutes).
4. Not returning a 2xx status code
If your endpoint returns non-2xx responses, Resend may retry according to its retry policy.
- On success, always respond with
200or204. - If processing may take longer, consider acknowledging quickly and handling logic asynchronously.
Summary
To enable Resend webhooks for delivered, bounced, and complained events and verify the webhook signature securely:
- Create a webhook endpoint in your backend that accepts POST requests and reads the raw body.
- Register the endpoint in the Resend dashboard and select
email.delivered,email.bounced, andemail.complainedevents. - Obtain your webhook signing secret and store it securely as an environment variable.
- Verify the webhook signature by:
- Using the raw request body.
- Combining it with the timestamp.
- Creating an HMAC (e.g., SHA-256) using your secret.
- Comparing it to the signature header in constant time.
- Implement event-specific logic:
- Update status for
email.delivered. - Handle invalid addresses for
email.bounced. - Suppress recipients for
email.complained.
- Update status for
- Test and monitor using Resend’s test tools and real events in staging or sandbox.
Following this setup ensures your Resend webhooks are both reliable and secure, giving you accurate tracking for delivered, bounced, and complained email events while protecting your system from spoofed requests.