
Resend
- 89 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
resend is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- resend
- AI & Agent Building
- AI-coding skill
Resend by the numbers
- 89 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,891 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill resendAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 89 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Resend
Overview
Resend is a modern email API built for developers, providing programmatic email sending with support for React Email components, domain management, and webhook-driven event tracking.
When to use: Transactional emails (welcome, password reset, receipts), batch email delivery, scheduled sending, webhook-based delivery tracking, domain verification, React Email integration.
When NOT to use: High-volume marketing automation (use dedicated ESP), SMS/push notifications, email hosting/inbox management.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Send email | resend.emails.send({ from, to, subject }) | Returns { data, error }, supports html/text/react content |
| Send with React | resend.emails.send({ react: <Email /> }) | Node.js SDK only, renders React Email components server-side |
| Batch send | resend.batch.send([...emails]) | Multiple emails in one request, no attachments/scheduling |
| Schedule email | emails.send({ scheduled_at }) | ISO 8601 or natural language, cancel before send window |
| Attachments | emails.send({ attachments: [...] }) | Max 40MB total after encoding, supports content or path |
| Idempotent send | emails.send(params, { idempotencyKey }) | Prevents duplicate sends on retry |
| Retrieve email | resend.emails.get(emailId) | Check delivery status and metadata |
| Add domain | resend.domains.create({ name }) | Returns DNS records for SPF, DKIM, MX |
| Verify domain | resend.domains.verify(domainId) | Triggers DNS record check |
| List domains | resend.domains.list() | Returns all domains with status |
| Create webhook | Dashboard or API | Subscribe to email lifecycle events |
| Verify webhook | resend.webhooks.verify({ payload, ... }) | Validates Svix signature headers |
| Tags | emails.send({ tags: [...] }) | Key-value pairs for categorization, ASCII only, max 256 chars |
| Custom headers | emails.send({ headers: {...} }) | Add custom email headers |
| Create contact | resend.contacts.create({ email, ... }) | Global contacts with custom properties |
| List contacts | resend.contacts.list() | Returns all contacts |
| Create broadcast | resend.broadcasts.create({ from, ... }) | Bulk email to a segment, supports template variables |
| Send broadcast | resend.broadcasts.send(id, { segmentId }) | Delivers broadcast to a segment of contacts |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Using root domain for sending | Use a subdomain like send.yourdomain.com to isolate reputation |
Not checking error in response | Always destructure { data, error } and handle errors |
| Sending attachments in batch requests | Attachments and scheduling are not supported in batch sends |
| Hardcoding API key in source code | Use environment variable RESEND_API_KEY |
| Skipping webhook signature verification | Always verify using Svix headers before processing events |
Using react prop outside Node.js SDK | The react prop only works with the Node.js SDK |
| Not setting up DMARC after SPF/DKIM verify | Add DMARC record after SPF and DKIM pass to improve deliverability |
| Exceeding 50 recipients in a single send | Use batch send or loop for more than 50 recipients per request |
Using && for tag name/value characters | Tag names and values must be ASCII letters, numbers, _, or - |
| Ignoring bounce/complaint webhooks | Monitor email.bounced and email.complained to protect reputation |
Delegation
- Email template design: Use
Exploreagent to discover React Email component patterns - Domain DNS configuration: Use
Taskagent for step-by-step DNS setup verification - Webhook endpoint setup: Use
Taskagent for route handler implementation
References
- Sending emails, batch sending, attachments, scheduling, and idempotency
- Contacts, Segments, Broadcasts, template variables, and bulk sending
- Domain verification, DNS records, and sender identity management
- Webhook events, signature verification, and event handling
Contacts and Broadcasts
Resend provides Contacts, Segments, and Broadcasts for managing recipients and sending bulk emails. Contacts are global entities linked to an email address. Segments group contacts for targeted sending. Broadcasts deliver emails to entire segments.
Contacts
Create a Contact
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.contacts.create({
email: 'steve@example.com',
firstName: 'Steve',
lastName: 'Wozniak',
unsubscribed: false,
});List Contacts
const { data, error } = await resend.contacts.list();Get a Contact
const { data, error } = await resend.contacts.get('contact-id');Update a Contact
const { data, error } = await resend.contacts.update({
id: 'contact-id',
firstName: 'Updated Name',
unsubscribed: false,
});Update by Email
const { data, error } = await resend.contacts.update({
email: 'steve@example.com',
unsubscribed: true,
});Delete a Contact
const { data, error } = await resend.contacts.remove('contact-id');Contact with Custom Properties
Contacts support custom key-value properties for personalization in broadcasts.
const { data, error } = await resend.contacts.create({
email: 'user@example.com',
firstName: 'Jane',
lastName: 'Doe',
properties: {
company_name: 'Acme Corp',
plan: 'enterprise',
},
});Broadcasts
Broadcasts send emails to an entire segment of contacts.
Create a Broadcast
const { data, error } = await resend.broadcasts.create({
from: 'Acme <newsletter@send.acme.com>',
subject: 'Monthly Newsletter',
html: 'Hi {{{FIRST_NAME|there}}}, check out our latest updates.',
});Send a Broadcast
const { data, error } = await resend.broadcasts.send('broadcast-id', {
segmentId: 'segment-id',
scheduledAt: '2025-01-15T09:00:00.000Z',
});Update a Broadcast
const { data, error } = await resend.broadcasts.update('broadcast-id', {
subject: 'Updated Subject Line',
html: 'Updated content with {{{FIRST_NAME|friend}}}.',
});List Broadcasts
const { data, error } = await resend.broadcasts.list();Get a Broadcast
const { data, error } = await resend.broadcasts.get('broadcast-id');Delete a Broadcast
const { data, error } = await resend.broadcasts.remove('broadcast-id');Template Variables
Broadcasts support personalization with triple-brace template variables. The value after | is the fallback when the contact property is missing.
| Variable | Description |
|---|---|
| `{{{FIRST_NAME\ | there}}}` |
| `{{{LAST_NAME\ | friend}}}` |
{{{EMAIL}}} | Contact email address |
{{{RESEND_UNSUBSCRIBE_URL}}} | One-click unsubscribe link (required) |
Example Broadcast HTML
<h1>Hi {{{FIRST_NAME|there}}},</h1>
<p>Here is your weekly digest from Acme.</p>
<p>
<a href="{{{RESEND_UNSUBSCRIBE_URL}}}">Unsubscribe</a>
</p>Always include {{{RESEND_UNSUBSCRIBE_URL}}} in broadcast emails for compliance with anti-spam regulations.
REST API Examples
Create Contact (cURL)
curl -X POST 'https://api.resend.com/contacts' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"firstName": "Jane",
"lastName": "Doe",
"unsubscribed": false
}'Create and Send Broadcast (cURL)
curl -X POST 'https://api.resend.com/broadcasts' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"segment_id": "segment-id-here",
"from": "Acme <newsletter@send.acme.com>",
"subject": "Weekly Update",
"html": "Hi {{{FIRST_NAME|there}}}, here are your updates."
}'Migration from Audiences to Segments
Resend renamed Audiences to Segments. The Contacts model is now global rather than scoped per audience. Existing audience IDs continue to work as segment IDs during the migration period.
Domain Management
Why Use a Subdomain
Resend recommends using a subdomain (e.g., send.yourdomain.com) instead of the root domain for email sending. This isolates your transactional email reputation from your main domain and clearly communicates the purpose of each subdomain.
Create a Domain
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.domains.create({
name: 'send.acme.com',
});
if (error) {
console.error('Failed to create domain:', error);
return;
}
console.log('Domain created:', data.id);
console.log('DNS records to configure:', data.records);Create Domain with Options
const { data, error } = await resend.domains.create({
name: 'send.acme.com',
region: 'eu-west-1',
open_tracking: true,
click_tracking: true,
tls: 'enforced',
});Available Regions
| Region | Value |
|---|---|
| US East (Virginia) | us-east-1 |
| EU West (Ireland) | eu-west-1 |
| South America | sa-east-1 |
| Asia Pacific | ap-northeast-1 |
DNS Records
After creating a domain, Resend returns DNS records that must be configured with your DNS provider. Three record types are required for full verification.
SPF Record (TXT)
Authorizes Resend to send emails on behalf of your domain.
Type: TXT
Name: send
Value: "v=spf1 include:amazonses.com ~all"
TTL: AutoDKIM Record (TXT)
Public key used to verify email authenticity.
Type: TXT
Name: resend._domainkey
Value: p=MIGfMA0GCSqGSIb3DQEBA... (provided by Resend)
TTL: AutoMX Record
Routes bounce and feedback notifications.
Type: MX
Name: send
Value: feedback-smtp.us-east-1.amazonses.com
Priority: 10
TTL: AutoDMARC Record (Optional, Recommended)
Add after SPF and DKIM verify to improve deliverability and prevent spoofing.
Type: TXT
Name: _dmarc
Value: "v=DMARC1; p=none; rua=mailto:dmarc@acme.com"
TTL: AutoDMARC policy values:
p=none— Monitor only (start here)p=quarantine— Send suspicious emails to spamp=reject— Reject emails that fail authentication
Verify a Domain
Trigger a DNS verification check after configuring records.
const { data, error } = await resend.domains.verify('domain_id_123');
if (error) {
console.error('Verification failed:', error);
return;
}
console.log('Verification initiated');List Domains
const { data, error } = await resend.domains.list();
if (data) {
for (const domain of data.data) {
console.log(`${domain.name}: ${domain.status}`);
}
}Get Domain Details
const { data, error } = await resend.domains.get('domain_id_123');
if (data) {
console.log('Domain:', data.name);
console.log('Status:', data.status);
console.log('Records:', data.records);
}Update Domain Settings
const { data, error } = await resend.domains.update({
id: 'domain_id_123',
open_tracking: true,
click_tracking: false,
tls: 'enforced',
});Delete a Domain
const { data, error } = await resend.domains.remove('domain_id_123');Domain Status Values
| Status | Description |
|---|---|
not_started | Domain created, DNS records not yet configured |
pending | DNS records detected, verification in progress |
verified | All DNS records verified, ready to send |
failed | Verification failed, check DNS configuration |
temporary_failure | Transient issue, will retry automatically |
REST API (cURL)
Create Domain
curl -X POST 'https://api.resend.com/domains' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "send.acme.com"
}'Verify Domain
curl -X POST 'https://api.resend.com/domains/domain_id_123/verify' \
-H 'Authorization: Bearer re_xxxxxxxxx'List Domains
curl -X GET 'https://api.resend.com/domains' \
-H 'Authorization: Bearer re_xxxxxxxxx'Common Verification Issues
Records Not Propagating
DNS changes can take up to 72 hours to propagate. Use a DNS lookup tool to confirm records are publicly visible before triggering verification.
Incorrect Record Location
SPF and MX records must be configured on the subdomain (e.g., send.acme.com), not the root domain. The Name field in the DNS record should be send, not @ or blank.
Conflicting SPF Records
Only one SPF record is allowed per domain. If an existing SPF record exists, merge the Resend include:
"v=spf1 include:_spf.google.com include:amazonses.com ~all"Cloudflare DNS Proxy
If using Cloudflare, set the DNS records to "DNS only" (gray cloud), not "Proxied" (orange cloud). Proxied records can interfere with email verification.
Sender Identity Best Practices
- Use a consistent
fromaddress format:"Display Name <email@send.acme.com>" - Match the sending domain to a verified domain
- Use different subdomains for different email types (e.g.,
send.acme.comfor transactional,news.acme.comfor marketing) - Set
reply_toto an address you actively monitor
Sending Emails
Basic Email Send
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: 'Acme <notifications@send.acme.com>',
to: ['user@example.com'],
subject: 'Welcome to Acme',
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
});
if (error) {
console.error('Failed to send email:', error);
return;
}
console.log('Email sent:', data.id);Send with Plain Text
const { data, error } = await resend.emails.send({
from: 'Acme <notifications@send.acme.com>',
to: ['user@example.com'],
subject: 'Your receipt',
text: 'Thank you for your purchase. Order #12345.',
});Send with React Email (Node.js SDK Only)
import { Resend } from 'resend';
import { WelcomeEmail } from './emails/welcome';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: 'Acme <welcome@send.acme.com>',
to: ['user@example.com'],
subject: 'Welcome to Acme',
react: <WelcomeEmail username="Jane" />,
});React Email Component Example
import {
Html,
Head,
Body,
Container,
Text,
Button,
} from '@react-email/components';
interface WelcomeEmailProps {
username: string;
}
export function WelcomeEmail({ username }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Text>Hello {username},</Text>
<Text>Welcome to Acme! Get started by verifying your account.</Text>
<Button
href="https://acme.com/verify"
style={{ background: '#000', color: '#fff', padding: '12px 20px' }}
>
Verify Account
</Button>
</Container>
</Body>
</Html>
);
}Multiple Recipients, CC, and BCC
const { data, error } = await resend.emails.send({
from: 'Acme <notifications@send.acme.com>',
to: ['alice@example.com', 'bob@example.com'],
cc: ['manager@example.com'],
bcc: ['audit@acme.com'],
reply_to: 'support@acme.com',
subject: 'Team Update',
html: '<p>Here is the weekly update.</p>',
});Attachments
Attachments can be provided as base64 content or a URL path. Max 40MB total per email after encoding.
const { data, error } = await resend.emails.send({
from: 'Acme <billing@send.acme.com>',
to: ['user@example.com'],
subject: 'Your Invoice',
html: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: 'invoice.pdf',
content: invoicePdfBuffer,
},
],
});Attachment via URL
const { data, error } = await resend.emails.send({
from: 'Acme <reports@send.acme.com>',
to: ['user@example.com'],
subject: 'Monthly Report',
html: '<p>Your monthly report is attached.</p>',
attachments: [
{
filename: 'report.pdf',
path: 'https://acme.com/reports/2024-01.pdf',
},
],
});Inline Image Attachment
const { data, error } = await resend.emails.send({
from: 'Acme <news@send.acme.com>',
to: ['user@example.com'],
subject: 'Newsletter',
html: '<p>Check this out:</p><img src="cid:logo" />',
attachments: [
{
filename: 'logo.png',
content: logoPngBuffer,
content_type: 'image/png',
content_id: 'logo',
},
],
});Scheduled Sending
Schedule emails for future delivery using ISO 8601 format or natural language.
const { data, error } = await resend.emails.send({
from: 'Acme <reminders@send.acme.com>',
to: ['user@example.com'],
subject: 'Reminder: Your trial expires tomorrow',
html: '<p>Your free trial expires in 24 hours.</p>',
scheduled_at: '2024-12-25T09:00:00.000Z',
});Natural Language Scheduling
const { data, error } = await resend.emails.send({
from: 'Acme <reminders@send.acme.com>',
to: ['user@example.com'],
subject: 'Follow-up',
html: '<p>Just checking in.</p>',
scheduled_at: 'in 1 hour',
});Idempotent Sends
Prevent duplicate emails when retrying failed requests by providing an idempotency key.
const { data, error } = await resend.emails.send(
{
from: 'Acme <orders@send.acme.com>',
to: ['user@example.com'],
subject: 'Order Confirmation #12345',
html: '<p>Your order has been confirmed.</p>',
},
{
idempotencyKey: 'order-confirm/12345',
},
);Batch Send with Idempotency
const { data, error } = await resend.batch.send(
[
{
from: 'Acme <notifications@send.acme.com>',
to: ['alice@example.com'],
subject: 'Weekly Digest',
html: '<p>Your weekly digest.</p>',
},
{
from: 'Acme <notifications@send.acme.com>',
to: ['bob@example.com'],
subject: 'Weekly Digest',
html: '<p>Your weekly digest.</p>',
},
],
{
idempotencyKey: 'weekly-digest/2024-w03',
},
);Batch Sending
Send multiple emails in a single API request. Attachments and scheduling are not supported in batch requests.
const { data, error } = await resend.batch.send([
{
from: 'Acme <onboarding@send.acme.com>',
to: ['user1@example.com'],
subject: 'Welcome!',
html: '<h1>Welcome to Acme!</h1>',
},
{
from: 'Acme <onboarding@send.acme.com>',
to: ['user2@example.com'],
subject: 'Welcome!',
html: '<h1>Welcome to Acme!</h1>',
},
]);
if (error) {
console.error('Batch send failed:', error);
return;
}
console.log('Sent emails:', data);Tags for Categorization
Tags are key-value pairs for categorizing and filtering emails. Names and values must use ASCII letters, numbers, underscores, or dashes (max 256 chars each).
const { data, error } = await resend.emails.send({
from: 'Acme <notifications@send.acme.com>',
to: ['user@example.com'],
subject: 'Password Reset',
html: '<p>Click the link to reset your password.</p>',
tags: [
{ name: 'category', value: 'password_reset' },
{ name: 'user_id', value: '12345' },
],
});Custom Headers
const { data, error } = await resend.emails.send({
from: 'Acme <notifications@send.acme.com>',
to: ['user@example.com'],
subject: 'Update',
html: '<p>Important update.</p>',
headers: {
'X-Entity-Ref-ID': 'unique-ref-123',
'List-Unsubscribe': '<https://acme.com/unsubscribe>',
},
});Retrieve Email Status
const { data, error } = await resend.emails.get('email_id_12345');
if (data) {
console.log('Status:', data.last_event);
}Error Handling Pattern
async function sendEmail(to: string, subject: string, html: string) {
const { data, error } = await resend.emails.send({
from: 'Acme <notifications@send.acme.com>',
to: [to],
subject,
html,
});
if (error) {
if (error.name === 'validation_error') {
throw new Error(`Invalid email params: ${error.message}`);
}
if (error.name === 'rate_limit_exceeded') {
throw new Error('Rate limited, retry later');
}
throw new Error(`Email send failed: ${error.message}`);
}
return data.id;
}REST API (cURL)
curl -X POST 'https://api.resend.com/emails' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"from": "Acme <notifications@send.acme.com>",
"to": ["user@example.com"],
"subject": "Hello",
"html": "<p>Hello world</p>"
}'With Idempotency Key (cURL)
curl -X POST 'https://api.resend.com/emails' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-confirm/12345' \
-d '{
"from": "Acme <notifications@send.acme.com>",
"to": ["user@example.com"],
"subject": "Order Confirmation",
"html": "<p>Your order is confirmed.</p>"
}'Webhooks
Overview
Resend uses webhooks to notify your application about email lifecycle events in real time. Webhooks are powered by Svix and include cryptographic signatures for verification. Subscribe to events via the Resend dashboard or API.
Event Types
| Event | Description |
|---|---|
email.sent | API request succeeded, delivery attempt in progress |
email.delivered | Email successfully delivered to recipient's mail server |
email.delivery_delayed | Temporary delivery issue (full inbox, transient error) |
email.bounced | Recipient's mail server permanently rejected the email |
email.complained | Recipient marked the email as spam |
email.opened | Recipient opened the email (requires open tracking) |
email.clicked | Recipient clicked a link in the email (requires click tracking) |
domain.created | New domain added to the account |
domain.updated | Domain settings changed |
domain.deleted | Domain removed from the account |
contact.created | New contact added to an audience |
contact.updated | Contact information changed |
contact.deleted | Contact removed from an audience |
Webhook Payload Structure
All webhook payloads follow the same structure:
{
"type": "email.delivered",
"created_at": "2024-02-22T23:41:12.126Z",
"data": {
"email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
"from": "Acme <onboarding@send.acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2024-02-22T23:41:11.894719+00:00"
}
}Bounce Payload (Additional Fields)
{
"type": "email.bounced",
"created_at": "2024-11-22T23:41:12.126Z",
"data": {
"email_id": "56761188-7520-42d8-8898-ff6fc54ce618",
"from": "Acme <onboarding@send.acme.com>",
"to": ["user@example.com"],
"subject": "Welcome",
"bounce": {
"message": "The recipient's email address does not exist.",
"type": "Permanent",
"subType": "Suppressed"
}
}
}Signature Verification Headers
Every webhook request includes three Svix headers for verification:
| Header | Description |
|---|---|
svix-id | Unique message identifier |
svix-timestamp | Unix timestamp of the webhook dispatch |
svix-signature | HMAC signature for payload validation |
Verify with Resend SDK
The Resend SDK provides a built-in method for webhook verification.
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const payload = await resend.webhooks.verify({
payload: JSON.stringify(req.body),
headers: {
id: req.headers['svix-id'] as string,
timestamp: req.headers['svix-timestamp'] as string,
signature: req.headers['svix-signature'] as string,
},
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
});Verify with Svix Library
For manual verification without the Resend SDK.
import { Webhook } from 'svix';
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);
const payload = wh.verify(rawBody, {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
});Next.js Route Handler Example
import { Resend } from 'resend';
import { NextRequest, NextResponse } from 'next/server';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const body = await req.text();
let payload;
try {
payload = await resend.webhooks.verify({
payload: body,
headers: {
id: req.headers.get('svix-id')!,
timestamp: req.headers.get('svix-timestamp')!,
signature: req.headers.get('svix-signature')!,
},
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
});
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
switch (payload.type) {
case 'email.delivered':
await handleDelivered(payload.data);
break;
case 'email.bounced':
await handleBounced(payload.data);
break;
case 'email.complained':
await handleComplained(payload.data);
break;
case 'email.opened':
await handleOpened(payload.data);
break;
case 'email.clicked':
await handleClicked(payload.data);
break;
}
return NextResponse.json({ received: true });
}Express Route Handler Example
import express from 'express';
import { Webhook } from 'svix';
const app = express();
app.use(express.raw({ type: 'application/json' }));
app.post('/api/webhooks/resend', (req, res) => {
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);
let payload;
try {
payload = wh.verify(req.body.toString(), {
'svix-id': req.headers['svix-id'] as string,
'svix-timestamp': req.headers['svix-timestamp'] as string,
'svix-signature': req.headers['svix-signature'] as string,
});
} catch {
return res.status(401).json({ error: 'Invalid signature' });
}
const { type, data } = payload as {
type: string;
data: Record<string, unknown>;
};
switch (type) {
case 'email.delivered':
console.log('Delivered:', data.email_id);
break;
case 'email.bounced':
console.log('Bounced:', data.email_id, data.bounce);
break;
case 'email.complained':
console.log('Complaint:', data.email_id);
break;
}
res.json({ received: true });
});TypeScript Event Types
interface ResendWebhookEvent {
type: string;
created_at: string;
data: EmailEventData;
}
interface EmailEventData {
email_id: string;
from: string;
to: string[];
subject: string;
created_at: string;
broadcast_id?: string;
template_id?: string;
tags?: Record<string, string>;
bounce?: {
message: string;
type: 'Permanent' | 'Transient';
subType: string;
};
}Monitoring Best Practices
Handle Bounces
Remove or suppress addresses that permanently bounce to protect sender reputation.
async function handleBounced(data: EmailEventData) {
if (data.bounce?.type === 'Permanent') {
await db.user.update({
where: { email: data.to[0] },
data: { emailStatus: 'bounced', emailSuppressed: true },
});
}
}Handle Complaints
Immediately unsubscribe users who mark emails as spam.
async function handleComplained(data: EmailEventData) {
await db.user.update({
where: { email: data.to[0] },
data: { emailOptOut: true },
});
}Track Engagement
Use open and click events to measure email effectiveness.
async function handleOpened(data: EmailEventData) {
await db.emailEvent.create({
data: {
emailId: data.email_id,
type: 'opened',
recipientEmail: data.to[0],
timestamp: new Date(),
},
});
}Webhook Security Checklist
- Always verify the Svix signature before processing events
- Use the raw request body for verification (not parsed JSON)
- Store the webhook secret in an environment variable (
RESEND_WEBHOOK_SECRET) - Return a
2xxstatus code promptly to avoid retries - Process events asynchronously after acknowledging receipt
- Implement idempotent event handlers (webhooks may be retried)
- Log unrecognized event types instead of failing