React Email16 min read

Webhook Monitoring for React Email: Track Delivery, Bounces, and Engagement in Production

Stop flying blind on email delivery. Set up webhooks to track deliveries, bounces, spam complaints, opens, and clicks. Includes retry logic, automated alerts, and engagement analytics for production React Email apps.

R

React Emails Pro

March 3, 2026

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.

Webhooks turn email sending from "fire and forget" into"fire and know"—tracking deliveries, opens, clicks, bounces, and spam complaints in real time.

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):

app/api/webhooks/email/route.ts
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,
    },
  });
}
Always verify webhook signatures. Without it, anyone can POST fake events to your endpoint and corrupt your data.

Tracking email status in your app

Store a reference to each sent email so you can correlate webhook events:

lib/email/send.ts
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:

app/api/user/email-status/route.ts
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:

lib/email/alerts.ts
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
Don't alert on every bounce—you'll drown in noise. Focus on:
  • 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:

lib/email/analytics.ts
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:

lib/email/retry.ts
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 hour
Don't retry hard bounces—they'll never succeed and will hurt your sender reputation. Only retry soft bounces (inbox full, temporary failures).

Building an email monitoring dashboard

Show email health at a glance:

app/admin/email-health/page.tsx
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:

terminal
# 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 dashboard
Most ESPs let you replay webhook events from their dashboard—use this to test your handler without sending real emails.

Common 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:

  1. Bounce handling: Track bounces, suppress hard bounces
  2. Delivery confirmation: Store delivery status for critical emails
  3. Alerts: Notify when password resets or verification emails fail
  4. Retry logic: Auto-retry soft bounces with exponential backoff
  5. Engagement tracking: Monitor open/click rates for deliverability issues
Want production-ready React Email templates to start with? Check out our SaaS template library—all designed for reliable sending with proper error handling built in.

Webhooks turn email from a black box into an observable system. Set them up early, before deliverability issues become customer complaints.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates