Stripe fires dozens of events during a customer's lifecycle. Payment succeeded, invoice created, charge refunded, card declined. Most SaaS apps listen for these events, update their database, and then... nothing. The customer never hears about it.
That silence is expensive. Users expect a receipt the moment they pay. When a payment fails, they need to know before their access gets revoked without warning. And when you refund someone, a confirmation email is the difference between "great support" and a chargeback dispute.
This guide walks through building Stripe webhook-driven email flows in Next.js App Router with React Email: from signature verification to production-ready receipt and recovery templates.
73%
Expect instant receipts
Consumers expect a payment confirmation within minutes of a transaction.
40%
Recoverable failures
Failed payments that can be recovered with timely email outreach.
$3.5T
Lost to failed payments
Annual global revenue lost to involuntary churn from payment failures.
Stripe events that need emails
Stripe emits over 200 event types. You do not need to handle all of them. For email notifications, four events cover the critical payment lifecycle:
checkout.session.completed— first-time purchase or new subscription. Send a welcome receipt.invoice.paid— recurring payment succeeded. Send a payment receipt with line items.invoice.payment_failed— card declined or payment method expired. Send a recovery email with an update-billing CTA.charge.refunded— refund processed. Send a confirmation so the customer knows the money is coming back.
invoice.paid and invoice.payment_failed. These two events handle the majority of payment communication. Add the others once the core flow works.Setting up the webhook route handler
The webhook endpoint lives at app/api/webhooks/stripe/route.ts. Every incoming request must be verified against Stripe's signing secret before you process it. Skip this step and anyone can forge events.
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { dispatchStripeEmail } from "@/lib/stripe-emails";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("[Stripe Webhook] Signature verification failed:", err);
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
// Process the event asynchronously but acknowledge immediately
try {
await dispatchStripeEmail(event);
} catch (err) {
console.error(`[Stripe Webhook] Failed to process ${event.type}:`, err);
// Return 500 so Stripe retries the event
return NextResponse.json(
{ error: "Processing failed" },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}req.text(), not req.json(). Stripe's signature verification hashes the raw body. If you parse it first, the hash won't match and every event will fail verification.Disable Next.js body parsing
Next.js App Router does not auto-parse request bodies the way the Pages Router did, so in most cases the handler above works without additional config. If you are migrating from Pages Router, make sure you are not using the old config export pattern.
Building the payment receipt email
A good receipt does three things: confirms the charge amount, shows what was purchased, and gives the customer a way to reach support. Here is a production-ready React Email template for payment receipts.
import {
Html, Head, Preview, Body, Container, Section,
Row, Column, Heading, Text, Hr, Link, Img,
} from "@react-email/components";
interface LineItem {
description: string;
amount: string;
}
interface PaymentReceiptProps {
customerName: string;
invoiceNumber: string;
paymentDate: string;
lineItems: LineItem[];
total: string;
currency: string;
dashboardUrl: string;
}
export default function PaymentReceipt({
customerName = "Alex",
invoiceNumber = "INV-2024-0042",
paymentDate = "March 9, 2026",
lineItems = [
{ description: "Pro Plan (monthly)", amount: "$29.00" },
{ description: "Additional seats x3", amount: "$27.00" },
],
total = "$56.00",
currency = "USD",
dashboardUrl = "https://app.yourproduct.com/billing",
}: PaymentReceiptProps) {
return (
<Html>
<Head />
<Preview>
Receipt for {total} {currency} — {invoiceNumber}
</Preview>
<Body style={main}>
<Container style={container}>
<Img
src="https://yourproduct.com/logo.png"
width={120}
height={36}
alt="YourProduct"
/>
<Heading style={heading}>Payment received</Heading>
<Text style={paragraph}>Hi {customerName},</Text>
<Text style={paragraph}>
We received your payment of {total} {currency}. Here is your
receipt for invoice {invoiceNumber}.
</Text>
<Section style={tableSection}>
{lineItems.map((item, i) => (
<Row key={i} style={tableRow}>
<Column style={descriptionCol}>
<Text style={itemText}>{item.description}</Text>
</Column>
<Column style={amountCol}>
<Text style={amountText}>{item.amount}</Text>
</Column>
</Row>
))}
<Hr style={divider} />
<Row style={tableRow}>
<Column style={descriptionCol}>
<Text style={totalLabel}>Total</Text>
</Column>
<Column style={amountCol}>
<Text style={totalAmount}>
{total} {currency}
</Text>
</Column>
</Row>
</Section>
<Text style={paragraph}>
View your full billing history on your{" "}
<Link href={dashboardUrl} style={link}>
billing dashboard
</Link>
.
</Text>
<Hr style={divider} />
<Text style={footer}>
YourProduct, Inc. · 123 Main St, San Francisco, CA 94105
</Text>
<Text style={footer}>
Questions about this charge?{" "}
<Link href="mailto:billing@yourproduct.com" style={link}>
Contact billing support
</Link>
</Text>
</Container>
</Body>
</Html>
);
}
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "40px auto",
padding: "40px",
maxWidth: "520px",
borderRadius: "8px",
};
const heading = {
fontSize: "22px",
fontWeight: "600" as const,
color: "#1a1a1a",
margin: "24px 0 16px",
};
const paragraph = {
fontSize: "15px",
lineHeight: "24px",
color: "#4a4a4a",
margin: "0 0 12px",
};
const tableSection = { margin: "24px 0" };
const tableRow = { width: "100%" as const };
const descriptionCol = { width: "70%" };
const amountCol = { width: "30%", textAlign: "right" as const };
const itemText = { fontSize: "14px", color: "#4a4a4a", margin: "4px 0" };
const amountText = { fontSize: "14px", color: "#4a4a4a", margin: "4px 0" };
const totalLabel = { fontSize: "14px", fontWeight: "600" as const, color: "#1a1a1a", margin: "4px 0" };
const totalAmount = { fontSize: "14px", fontWeight: "600" as const, color: "#1a1a1a", margin: "4px 0" };
const divider = { borderColor: "#e6e6e6", margin: "16px 0" };
const link = { color: "#2563eb", textDecoration: "underline" };
const footer = { fontSize: "12px", color: "#999", margin: "0 0 4px" };Keep receipts simple. The customer already bought. The email should confirm what they paid, show line items, and provide a support escape hatch. No upsells, no marketing banners.
Building the failed payment recovery email
Recovery emails have a harder job than receipts. You need to communicate urgency without making the user feel accused, and the CTA has to take them directly to a page where they can fix the problem in under a minute.
import {
Html, Head, Preview, Body, Container,
Heading, Text, Button, Hr, Link, Img,
} from "@react-email/components";
interface PaymentFailedProps {
customerName: string;
planName: string;
amount: string;
currency: string;
nextRetryDate: string;
updatePaymentUrl: string;
}
export default function PaymentFailed({
customerName = "Alex",
planName = "Pro Plan",
amount = "$29.00",
currency = "USD",
nextRetryDate = "March 12, 2026",
updatePaymentUrl = "https://app.yourproduct.com/billing/update",
}: PaymentFailedProps) {
return (
<Html>
<Head />
<Preview>
We couldn't process your {amount} payment
</Preview>
<Body style={main}>
<Container style={container}>
<Img
src="https://yourproduct.com/logo.png"
width={120}
height={36}
alt="YourProduct"
/>
<Heading style={heading}>
Payment didn't go through
</Heading>
<Text style={paragraph}>Hi {customerName},</Text>
<Text style={paragraph}>
We tried to charge {amount} {currency} for your{" "}
{planName} subscription, but the payment didn't go
through. This can happen with expired cards, spending
limits, or temporary bank holds.
</Text>
<Text style={paragraph}>
We'll automatically retry on{" "}
<strong>{nextRetryDate}</strong>. To avoid any
interruption, you can update your payment method now:
</Text>
<Button href={updatePaymentUrl} style={button}>
Update payment method
</Button>
<Text style={mutedText}>
If you've already updated your card, you can ignore
this email. We'll retry automatically.
</Text>
<Hr style={divider} />
<Text style={footer}>
Need help?{" "}
<Link
href="mailto:support@yourproduct.com"
style={link}
>
Contact support
</Link>{" "}
·{" "}
<Link
href="https://app.yourproduct.com/billing"
style={link}
>
Manage subscription
</Link>
</Text>
</Container>
</Body>
</Html>
);
}
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "40px auto",
padding: "40px",
maxWidth: "520px",
borderRadius: "8px",
};
const heading = {
fontSize: "22px",
fontWeight: "600" as const,
color: "#1a1a1a",
margin: "24px 0 16px",
};
const paragraph = {
fontSize: "15px",
lineHeight: "24px",
color: "#4a4a4a",
margin: "0 0 16px",
};
const button = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "15px",
fontWeight: "600" as const,
textDecoration: "none",
textAlign: "center" as const,
display: "block",
padding: "12px 24px",
margin: "24px 0",
};
const mutedText = {
fontSize: "13px",
color: "#999",
margin: "0 0 16px",
};
const divider = { borderColor: "#e6e6e6", margin: "24px 0" };
const link = { color: "#2563eb", textDecoration: "underline" };
const footer = { fontSize: "12px", color: "#999", margin: "0" };The event-to-email dispatcher
Instead of a sprawling if/else chain in your webhook handler, create a clean dispatcher that maps Stripe event types to email-sending functions. This keeps the webhook route thin and makes it trivial to add new event types later.
import Stripe from "stripe";
import { Resend } from "resend";
import PaymentReceipt from "@/emails/payment-receipt";
import PaymentFailed from "@/emails/payment-failed";
const resend = new Resend(process.env.RESEND_API_KEY);
type EventHandler = (event: Stripe.Event) => Promise<void>;
const eventHandlers: Record<string, EventHandler> = {
"invoice.paid": handleInvoicePaid,
"invoice.payment_failed": handlePaymentFailed,
"charge.refunded": handleChargeRefunded,
};
export async function dispatchStripeEmail(event: Stripe.Event) {
const handler = eventHandlers[event.type];
if (!handler) {
// Event type we don't send emails for — skip silently
return;
}
console.log(`[Stripe Email] Processing ${event.type} (${event.id})`);
await handler(event);
}
async function handleInvoicePaid(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.customer_email) return;
const lineItems = (invoice.lines?.data ?? []).map((line) => ({
description: line.description ?? "Subscription",
amount: formatCurrency(line.amount, invoice.currency),
}));
await resend.emails.send({
from: "billing@yourproduct.com",
to: invoice.customer_email,
subject: `Receipt for ${formatCurrency(invoice.amount_paid, invoice.currency)}`,
react: PaymentReceipt({
customerName: invoice.customer_name ?? "there",
invoiceNumber: invoice.number ?? invoice.id,
paymentDate: new Date(invoice.created * 1000).toLocaleDateString(
"en-US",
{ year: "numeric", month: "long", day: "numeric" }
),
lineItems,
total: formatCurrency(invoice.amount_paid, invoice.currency),
currency: invoice.currency.toUpperCase(),
dashboardUrl: `https://app.yourproduct.com/billing`,
}),
});
}
async function handlePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.customer_email) return;
await resend.emails.send({
from: "billing@yourproduct.com",
to: invoice.customer_email,
subject: `Action needed: payment for ${invoice.lines?.data[0]?.description ?? "your subscription"} didn't go through`,
react: PaymentFailed({
customerName: invoice.customer_name ?? "there",
planName: invoice.lines?.data[0]?.description ?? "your subscription",
amount: formatCurrency(invoice.amount_due, invoice.currency),
currency: invoice.currency.toUpperCase(),
nextRetryDate: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString(
"en-US",
{ year: "numeric", month: "long", day: "numeric" }
)
: "soon",
updatePaymentUrl: "https://app.yourproduct.com/billing/update",
}),
});
}
async function handleChargeRefunded(event: Stripe.Event) {
const charge = event.data.object as Stripe.Charge;
if (!charge.billing_details?.email) return;
// Use a simple text email for refunds, or create a dedicated template
await resend.emails.send({
from: "billing@yourproduct.com",
to: charge.billing_details.email,
subject: `Refund of ${formatCurrency(charge.amount_refunded, charge.currency)} processed`,
text: [
`Hi ${charge.billing_details.name ?? "there"},`,
"",
`We've processed a refund of ${formatCurrency(charge.amount_refunded, charge.currency)} ${charge.currency.toUpperCase()} to your ${charge.payment_method_details?.card?.brand ?? "card"} ending in ${charge.payment_method_details?.card?.last4 ?? "****"}.`,
"",
"Refunds typically appear on your statement within 5-10 business days.",
"",
"Questions? Reply to this email or contact support@yourproduct.com",
].join("\n"),
});
}
function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
}The pattern is intentionally simple: a record of event types to handler functions. When you need to handle customer.subscription.deleted six months from now, you add one entry and one function. No refactoring required.
Testing locally with Stripe CLI
You cannot test webhooks by refreshing localhost. Stripe needs to send HTTP requests to your endpoint, and your local machine is not publicly accessible. The Stripe CLI solves this by forwarding events from Stripe to your local dev server.
Install the Stripe CLI
On macOS: brew install stripe/stripe-cli/stripe. On other platforms, grab the binary from the Stripe CLI docs.
Log in and forward events
# Authenticate with your Stripe account
stripe login
# Forward webhook events to your local endpoint
stripe listen --forward-to localhost:3020/api/webhooks/stripeCopy the webhook signing secret that appears in the output and add it to your .env.local as STRIPE_WEBHOOK_SECRET.
Trigger test events
# Trigger a successful payment event
stripe trigger invoice.paid
# Trigger a failed payment event
stripe trigger invoice.payment_failed
# Trigger a refund event
stripe trigger charge.refundedEach command fires a realistic Stripe event with test data. Watch your terminal for the email dispatch logs.
Verify the emails
If you are using Resend, check the Resend dashboard to see the rendered email. During development, you can also render React Email templates locally with npx email dev to preview without sending.
stripe listen in a separate terminal tab while your Next.js dev server is running. You need both processes active to test the full flow.Production patterns you need before launch
The code above works. In production, it will eventually break in ways that are annoying to debug. These patterns prevent the most common failures.
Idempotency: handle duplicate events
Stripe guarantees at-least-once delivery, which means you will receive duplicate events. If your handler sends an email every time it runs, customers will get duplicate receipts.
// Simple idempotency using a Set (use Redis or a database in production)
const processedEvents = new Set<string>();
export async function dispatchStripeEmail(event: Stripe.Event) {
// Skip if we've already processed this event
if (processedEvents.has(event.id)) {
console.log(`[Stripe Email] Skipping duplicate ${event.id}`);
return;
}
const handler = eventHandlers[event.type];
if (!handler) return;
await handler(event);
// Mark as processed after successful handling
processedEvents.add(event.id);
// In production, store event.id in Redis with a TTL:
// await redis.set(`stripe:event:${event.id}`, "1", "EX", 86400);
}Set above is for illustration only. It resets on every deploy and does not work across multiple server instances. Use Redis or your database to store processed event IDs with a 24-48 hour TTL.Error handling and retries
When your email send fails, you have two choices: return a 500 status so Stripe retries the webhook, or swallow the error and handle retries yourself. The first option is simpler and usually correct.
Stripe retries failed webhooks with exponential backoff over 72 hours, up to a maximum of roughly 16 attempts. As long as your handler is idempotent, letting Stripe retry is the easiest path to reliability.
- Return 200 only after successful email dispatch
- Store processed event IDs for deduplication
- Log the full event.id and event.type on errors
- Use a dead-letter queue for events that fail repeatedly
- Set up alerts for webhook failure rates above 1%
- Return 200 before processing (you lose the retry safety net)
- Rely on in-memory state for deduplication in production
- Swallow errors silently — you won't know when emails stop sending
- Process webhooks synchronously if they take more than 10 seconds
- Hardcode email addresses — always read from the Stripe event payload
Environment variables checklist
Your webhook flow depends on several secrets. Here is the full list for your .env.local:
# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email provider (Resend example)
RESEND_API_KEY=re_...STRIPE_WEBHOOK_SECRET is different for local CLI forwarding vs. your production webhook endpoint in the Stripe dashboard.Architecture at a glance
The full flow from Stripe event to customer inbox looks like this:
- Stripe fires an event (e.g.
invoice.paid) to your webhook URL. - Route handler verifies the signature using
stripe.webhooks.constructEvent(). - Dispatcher checks for duplicates using the event ID.
- Handler extracts data from the event payload (customer email, invoice details, amounts).
- React Email renders the template with the extracted data as props.
- Resend (or your provider) delivers the email and you log the result.
Three files, one flow. The webhook route stays thin, the dispatcher stays organized, and the email templates stay in React where you can preview and test them independently.
Production checklist
Before you ship Stripe webhook emails to real customers, verify every item on this list:
- Webhook signature verification is enabled and tested with an invalid signature.
- Event deduplication is backed by Redis or your database, not in-memory state.
- The
STRIPE_WEBHOOK_SECRETin production matches the endpoint registered in the Stripe dashboard (not the CLI secret). - Email templates render correctly with missing or null fields (test with
customer_name: null). - The "from" address uses a domain with SPF, DKIM, and DMARC configured. See our SPF/DKIM/DMARC guide for setup.
- Failed webhook processing returns 500 so Stripe retries the event.
- Monitoring is set up: alert on webhook failure rates and email delivery errors.
- Recovery emails link directly to the billing update page, not a generic settings page.
- Receipt emails include a support contact so customers can dispute charges without initiating a chargeback.
The hardest part of Stripe webhook emails is not the code. It is making sure the emails actually send in production, handle edge cases gracefully, and reach the inbox instead of spam. Get the plumbing right once and every new event type is a one-function addition.
Templates and next steps
If you want production-ready receipt and failed payment templates you can drop into your project without starting from scratch, check out our invoice/receipt template and failed payment recovery template. They are built with React Email, tested across major email clients, and designed for the exact Stripe webhook flows covered in this post.
For the broader billing email lifecycle, pair these with a subscription renewal reminder to reduce surprise charges and a welcome email for post-checkout onboarding.