You sent the email. React Email rendered it. Your ESP confirmed delivery. But did the user actually get it?
Without webhooks, you're flying blind: password resets that never arrive, verification emails in spam, bounce spikes you don't catch until customers complain.
Why webhooks matter for production email
ESPs like Resend, SendGrid, Postmark, and AWS SES all send webhooks when email events happen:
- Delivered: Email accepted by recipient's mail server
- Bounced: Hard bounce (bad address) or soft bounce (inbox full)
- Complained: User marked your email as spam
- Opened: Tracking pixel loaded (not 100% reliable, but useful)
- Clicked: User clicked a tracked link
Tracking these events lets you:
- Suppress bounced emails before your reputation tanks
- Retry soft bounces automatically
- Alert support when critical emails fail
- Measure engagement to spot deliverability issues early
- Track user behavior for activation funnels
Setting up a webhook endpoint in Next.js
Here's a production-ready webhook handler for Resend (patterns apply to other ESPs):
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
// Store events in your database
import { db } from "@/lib/db";
// Resend webhook signature verification
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const hmac = crypto.createHmac("sha256", secret);
const digest = hmac.update(payload).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
}
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const signature = req.headers.get("svix-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing signature" },
{ status: 401 }
);
}
// Verify signature (prevents replay attacks)
const isValid = verifyWebhookSignature(
body,
signature,
process.env.RESEND_WEBHOOK_SECRET!
);
if (!isValid) {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
const event = JSON.parse(body);
// Handle different event types
switch (event.type) {
case "email.delivered":
await handleDelivered(event.data);
break;
case "email.bounced":
await handleBounced(event.data);
break;
case "email.complained":
await handleComplained(event.data);
break;
case "email.opened":
await handleOpened(event.data);
break;
case "email.clicked":
await handleClicked(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
);
}
}
async function handleDelivered(data: any) {
await db.emailEvent.create({
data: {
emailId: data.email_id,
type: "delivered",
timestamp: new Date(data.created_at),
metadata: data,
},
});
}
async function handleBounced(data: any) {
await db.emailEvent.create({
data: {
emailId: data.email_id,
type: "bounced",
bounceType: data.bounce?.type, // "hard" or "soft"
reason: data.bounce?.message,
timestamp: new Date(data.created_at),
metadata: data,
},
});
// Suppress hard bounces
if (data.bounce?.type === "hard") {
await db.user.update({
where: { email: data.to },
data: { emailSuppressed: true, suppressionReason: "hard_bounce" },
});
}
}
async function handleComplained(data: any) {
await db.emailEvent.create({
data: {
emailId: data.email_id,
type: "complained",
timestamp: new Date(data.created_at),
metadata: data,
},
});
// Auto-suppress spam complainers
await db.user.update({
where: { email: data.to },
data: { emailSuppressed: true, suppressionReason: "spam_complaint" },
});
}
async function handleOpened(data: any) {
await db.emailEvent.create({
data: {
emailId: data.email_id,
type: "opened",
timestamp: new Date(data.created_at),
metadata: data,
},
});
}
async function handleClicked(data: any) {
await db.emailEvent.create({
data: {
emailId: data.email_id,
type: "clicked",
url: data.click?.link,
timestamp: new Date(data.created_at),
metadata: data,
},
});
}Tracking email status in your app
Store a reference to each sent email so you can correlate webhook events:
import { render } from "@react-email/render";
import { Resend } from "resend";
import { db } from "@/lib/db";
import PasswordResetEmail from "@/emails/password-reset";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendPasswordReset(
userId: string,
email: string,
resetUrl: string
) {
const html = render(PasswordResetEmail({ resetUrl, email }));
const { data, error } = await resend.emails.send({
from: "security@yourapp.com",
to: email,
subject: "Reset your password",
html,
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
// Store sent email record
await db.sentEmail.create({
data: {
emailId: data.id, // Resend's ID
userId,
recipientEmail: email,
templateName: "password-reset",
sentAt: new Date(),
status: "sent",
},
});
return data.id;
}Then query email status in your app:
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const emailId = searchParams.get("emailId");
if (!emailId) {
return NextResponse.json({ error: "Missing emailId" }, { status: 400 });
}
const email = await db.sentEmail.findUnique({
where: { emailId },
include: {
events: {
orderBy: { timestamp: "desc" },
},
},
});
if (!email) {
return NextResponse.json({ error: "Email not found" }, { status: 404 });
}
return NextResponse.json({
status: email.status,
sentAt: email.sentAt,
events: email.events,
});
}Automated alerts for critical failures
Password resets and verification emails can't afford to fail silently. Set up alerts when critical templates bounce:
import { db } from "@/lib/db";
import { sendSlackAlert } from "@/lib/slack";
const CRITICAL_TEMPLATES = [
"password-reset",
"email-verification",
"magic-link",
];
export async function checkCriticalBounces() {
const recentBounces = await db.emailEvent.findMany({
where: {
type: "bounced",
timestamp: {
gte: new Date(Date.now() - 15 * 60 * 1000), // Last 15 minutes
},
},
include: {
sentEmail: true,
},
});
const criticalBounces = recentBounces.filter((event) =>
CRITICAL_TEMPLATES.includes(event.sentEmail.templateName)
);
if (criticalBounces.length > 0) {
await sendSlackAlert({
channel: "#email-alerts",
message: `🚨 ${criticalBounces.length} critical email(s) bounced in the last 15 minutes`,
bounces: criticalBounces.map((b) => ({
template: b.sentEmail.templateName,
recipient: b.sentEmail.recipientEmail,
reason: b.reason,
})),
});
}
}
// Run this with a cron job every 15 minutes
// Or trigger it directly in your webhook handler- Critical templates (password resets, verification)
- Bounce rate spikes (10+ bounces in 15 minutes)
- Spam complaints (these kill your sender reputation)
Engagement tracking for deliverability
Track open and click rates to catch deliverability issues early:
import { db } from "@/lib/db";
export async function getTemplateEngagement(
templateName: string,
days: number = 7
) {
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const sent = await db.sentEmail.count({
where: {
templateName,
sentAt: { gte: startDate },
},
});
const delivered = await db.emailEvent.count({
where: {
type: "delivered",
sentEmail: { templateName },
timestamp: { gte: startDate },
},
});
const opened = await db.emailEvent.count({
where: {
type: "opened",
sentEmail: { templateName },
timestamp: { gte: startDate },
},
});
const clicked = await db.emailEvent.count({
where: {
type: "clicked",
sentEmail: { templateName },
timestamp: { gte: startDate },
},
});
const bounced = await db.emailEvent.count({
where: {
type: "bounced",
sentEmail: { templateName },
timestamp: { gte: startDate },
},
});
return {
sent,
delivered,
deliveryRate: (delivered / sent) * 100,
openRate: (opened / delivered) * 100,
clickRate: (clicked / delivered) * 100,
bounceRate: (bounced / sent) * 100,
};
}
// Alert if rates drop suddenly
export async function detectEngagementAnomalies() {
const templates = await db.sentEmail.findMany({
distinct: ["templateName"],
select: { templateName: true },
});
for (const { templateName } of templates) {
const last7Days = await getTemplateEngagement(templateName, 7);
const last30Days = await getTemplateEngagement(templateName, 30);
// Alert if delivery rate drops >10% or bounce rate doubles
if (
last7Days.deliveryRate < last30Days.deliveryRate - 10 ||
last7Days.bounceRate > last30Days.bounceRate * 2
) {
await sendAlert({
template: templateName,
issue: "engagement_drop",
current: last7Days,
baseline: last30Days,
});
}
}
}Retry logic for soft bounces
Soft bounces (inbox full, temporary server issues) often succeed on retry:
import { db } from "@/lib/db";
import { sendEmail } from "./send";
export async function retryFailedEmails() {
// Find soft bounces from the last 24 hours with < 3 retry attempts
const failedEmails = await db.sentEmail.findMany({
where: {
events: {
some: {
type: "bounced",
bounceType: "soft",
},
},
retryCount: { lt: 3 },
sentAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
},
},
include: {
user: true,
},
});
for (const email of failedEmails) {
try {
// Wait longer between each retry (exponential backoff)
const delayMinutes = Math.pow(2, email.retryCount) * 60; // 60, 120, 240 minutes
const nextRetry = new Date(email.sentAt.getTime() + delayMinutes * 60 * 1000);
if (new Date() < nextRetry) {
continue; // Not time yet
}
// Resend the email
await sendEmail({
templateName: email.templateName,
to: email.recipientEmail,
userId: email.userId,
// ... other template-specific props
});
await db.sentEmail.update({
where: { id: email.id },
data: { retryCount: { increment: 1 } },
});
} catch (error) {
console.error(`Retry failed for email ${email.id}:`, error);
}
}
}
// Run this with a cron job every hourBuilding an email monitoring dashboard
Show email health at a glance:
import { db } from "@/lib/db";
import { getTemplateEngagement } from "@/lib/email/analytics";
export default async function EmailHealthPage() {
const templates = [
"welcome",
"password-reset",
"email-verification",
"invoice",
"trial-ending",
];
const stats = await Promise.all(
templates.map(async (template) => ({
name: template,
...(await getTemplateEngagement(template, 7)),
}))
);
return (
<div>
<h1>Email Health Dashboard</h1>
<table>
<thead>
<tr>
<th>Template</th>
<th>Sent (7d)</th>
<th>Delivery Rate</th>
<th>Open Rate</th>
<th>Bounce Rate</th>
</tr>
</thead>
<tbody>
{stats.map((stat) => (
<tr key={stat.name}>
<td>{stat.name}</td>
<td>{stat.sent}</td>
<td
style={{
color: stat.deliveryRate < 95 ? "red" : "green",
}}
>
{stat.deliveryRate.toFixed(1)}%
</td>
<td>{stat.openRate.toFixed(1)}%</td>
<td
style={{
color: stat.bounceRate > 5 ? "red" : "inherit",
}}
>
{stat.bounceRate.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Red flags to watch:
- Delivery rate < 95%: Check your SPF/DKIM/DMARC setup
- Bounce rate > 5%: List hygiene issue or bad email validation
- Open rate drop > 20%: Possible spam folder placement
- Spam complaints > 0.1%: Immediate sender reputation risk
Testing webhooks locally
Use Resend's webhook testing tool or ngrok to test webhooks on localhost:
# Install ngrok
brew install ngrok
# Expose your local dev server
ngrok http 3000
# Copy the ngrok URL and add /api/webhooks/email to your ESP's webhook settings
# Example: https://abc123.ngrok.io/api/webhooks/email
# Trigger test events from your ESP's dashboardCommon webhook mistakes to avoid
1) Not verifying signatures
Anyone can POST to your webhook endpoint. Always verify the signature to prevent fake events.
2) Blocking the webhook response
ESPs expect a 200 response within ~30 seconds. Do heavy processing (database writes, alerts) in a background job if it takes longer.
3) Not handling duplicates
Webhooks can arrive multiple times for the same event. Use emailId as an idempotency key to prevent duplicate processing.
4) Ignoring bounce types
Hard bounces (bad address) and soft bounces (inbox full) need different handling. Retry soft bounces, suppress hard bounces.
5) Alert fatigue
Don't alert on every bounce. Focus on critical templates and rate spikes to keep alerts actionable.
What to implement first
If you're just starting with webhooks, implement in this order:
- Bounce handling: Track bounces, suppress hard bounces
- Delivery confirmation: Store delivery status for critical emails
- Alerts: Notify when password resets or verification emails fail
- Retry logic: Auto-retry soft bounces with exponential backoff
- Engagement tracking: Monitor open/click rates for deliverability issues
Webhooks turn email from a black box into an observable system. Set them up early, before deliverability issues become customer complaints.