Back to Blog

How to Send Emails from Stripe Webhooks (2026 Guide)

18 min read

Stripe handles payments. It does not handle customer communication. When someone subscribes, upgrades, has a failed payment, or cancels, you need to send them an email. Stripe sends basic receipts, but they're generic — you can't customize the content, branding, or timing. And they don't cover most of the emails your SaaS actually needs: welcome emails on first purchase, dunning sequences for failed payments, trial ending reminders, cancellation follow-ups, or upgrade confirmations.

This guide covers the full picture: setting up webhook handlers, organizing email templates by lifecycle event, handling idempotency, building production-ready error handling, and testing locally with the Stripe CLI. All code examples use TypeScript and let you switch between email providers.

The Architecture

Stripe Event → Your Webhook Endpoint → Verify Signature → Route Event → Send Email

Stripe fires webhook events for everything: payments, subscriptions, invoices, disputes. You listen for the ones you care about and send the appropriate email. The critical rule: always verify the webhook signature before processing any event.

Which Events to Listen For

Here are the Stripe events that matter for a SaaS app, and what email to send for each:

EventWhen It FiresEmail to Send
checkout.session.completedNew subscription createdWelcome + getting started
invoice.payment_succeededRecurring payment chargedPayment receipt
invoice.payment_failedCard declined or expiredUpdate payment method
customer.subscription.trial_will_end3 days before trial endsAdd payment method reminder
customer.subscription.updatedPlan upgraded or downgradedPlan change confirmation
customer.subscription.deletedSubscription cancelledWin-back / cancellation follow-up
charge.refundedRefund issuedRefund confirmation
customer.subscription.pausedSubscription pausedPause confirmation
customer.subscription.resumedSubscription resumedWelcome back

Configure these in the Stripe Dashboard under Developers > Webhooks > Add endpoint, then select only the events you handle.

Set Up Your Email Provider

Install Stripe and your email provider:

Terminal
npm install stripe sequenzy
Terminal
npm install stripe resend
Terminal
npm install stripe @sendgrid/mail

Add your keys to .env:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
APP_URL=https://app.yoursite.com
.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Initialize the clients:

lib/email.ts
import Sequenzy from "sequenzy";

// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();
lib/email.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
export const FROM = "Your App <noreply@yourdomain.com>";
lib/email.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };
export const FROM = "noreply@yourdomain.com";

Organize Email Templates

Before writing the webhook handler, organize your email templates. Each template is a function that returns HTML — keeps the webhook handler clean and templates testable.

// lib/stripe-emails.ts
 
function layout(content: string): string {
  return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f6f9fc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr><td align="center" style="padding:40px 20px;">
      <table role="presentation" width="480" cellpadding="0" cellspacing="0"
             style="background:#fff;border-radius:8px;overflow:hidden;">
        <tr><td style="padding:40px;">${content}</td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`;
}
 
function cta(text: string, href: string): string {
  return `<a href="${href}"
    style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
           border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
    ${text}
  </a>`;
}
 
function formatCents(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}
 
// --- Welcome / New Subscription ---
 
export function welcomeEmail(appUrl: string): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">You're all set!</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Thanks for subscribing. Your account is now active and ready to go.
    </p>
    ${cta("Go to Dashboard", `${appUrl}/dashboard`)}
  `);
}
 
// --- Payment Receipt ---
 
export function receiptEmail(params: {
  amount: number;
  invoiceUrl: string;
  planName?: string;
  period?: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Payment Received</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Thanks for your payment. Here's your receipt:
    </p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      ${params.planName ? `
      <tr>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;">Plan</td>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;text-align:right;color:#374151;">
          ${params.planName}
        </td>
      </tr>` : ""}
      ${params.period ? `
      <tr>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;">Period</td>
        <td style="padding:12px 0;border-bottom:1px solid #e5e7eb;text-align:right;color:#374151;">
          ${params.period}
        </td>
      </tr>` : ""}
      <tr>
        <td style="padding:12px 0;font-weight:600;color:#111827;">Total</td>
        <td style="padding:12px 0;text-align:right;font-weight:600;color:#111827;">
          ${formatCents(params.amount)}
        </td>
      </tr>
    </table>
    <a href="${params.invoiceUrl}" style="color:#f97316;font-size:14px;">View full invoice</a>
  `);
}
 
// --- Payment Failed ---
 
export function paymentFailedEmail(params: {
  amount: number;
  billingUrl: string;
  nextRetry?: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Payment Failed</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      We couldn't process your payment of <strong>${formatCents(params.amount)}</strong>.
      Please update your payment method to keep your account active.
    </p>
    ${params.nextRetry ? `
    <p style="font-size:14px;color:#6b7280;">
      We'll try again on ${params.nextRetry}. Update your card before then to avoid interruption.
    </p>` : ""}
    ${cta("Update Payment Method", params.billingUrl)}
  `);
}
 
// --- Trial Ending ---
 
export function trialEndingEmail(params: {
  daysLeft: number;
  billingUrl: string;
  planName?: string;
}): string {
  const dayText = params.daysLeft === 1 ? "1 day" : `${params.daysLeft} days`;
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Your Trial Ends in ${dayText}</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      ${params.planName
        ? `Your free trial of the <strong>${params.planName}</strong> plan ends soon.`
        : "Your free trial ends soon."}
      Add a payment method to keep your account active.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      If you don't add a payment method, your account will be downgraded when the trial expires.
    </p>
    ${cta("Add Payment Method", params.billingUrl)}
  `);
}
 
// --- Subscription Cancelled ---
 
export function cancellationEmail(params: {
  endDate: string;
  pricingUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Your Subscription Has Been Cancelled</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      We're sorry to see you go. Your access continues until <strong>${params.endDate}</strong>.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Changed your mind? You can resubscribe at any time.
    </p>
    ${cta("Resubscribe", params.pricingUrl)}
  `);
}
 
// --- Plan Change ---
 
export function planChangeEmail(params: {
  oldPlan: string;
  newPlan: string;
  newAmount: number;
  appUrl: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Plan Updated</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your plan has been changed from <strong>${params.oldPlan}</strong> to
      <strong>${params.newPlan}</strong>.
    </p>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your new rate is <strong>${formatCents(params.newAmount)}/month</strong>.
      The change takes effect immediately.
    </p>
    ${cta("Go to Dashboard", `${params.appUrl}/dashboard`)}
  `);
}
 
// --- Refund ---
 
export function refundEmail(params: {
  amount: number;
  reason?: string;
}): string {
  return layout(`
    <h1 style="font-size:24px;color:#111827;margin:0 0 16px;">Refund Processed</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      We've issued a refund of <strong>${formatCents(params.amount)}</strong> to your original payment method.
    </p>
    ${params.reason ? `
    <p style="font-size:14px;color:#6b7280;">Reason: ${params.reason}</p>` : ""}
    <p style="font-size:14px;color:#6b7280;margin-top:16px;">
      Refunds typically appear on your statement within 5-10 business days.
    </p>
  `);
}

The Complete Webhook Handler

Now the webhook handler is clean — it routes events and calls template functions:

app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sequenzy } from "@/lib/email";
import {
welcomeEmail,
receiptEmail,
paymentFailedEmail,
trialEndingEmail,
cancellationEmail,
planChangeEmail,
refundEmail,
} from "@/lib/stripe-emails";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(
    body, signature, process.env.STRIPE_WEBHOOK_SECRET!
  );
} catch {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

try {
  await handleStripeEvent(event);
} catch (error) {
  console.error(`Webhook handler failed for ${event.type}:`, error);
  // Still return 200 so Stripe doesn't retry
  // Log the error for investigation
}

return NextResponse.json({ received: true });
}

async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    if (!session.customer_email) break;

    await sequenzy.transactional.send({
      to: session.customer_email,
      subject: "Welcome! Your subscription is active",
      body: welcomeEmail(APP_URL),
    });

    // Also add them as a subscriber for marketing
    await sequenzy.subscribers.create({
      email: session.customer_email,
      tags: ["customer", "stripe"],
      customAttributes: {
        plan: session.metadata?.plan ?? "unknown",
        stripeCustomerId: session.customer as string,
      },
    });
    break;
  }

  case "invoice.payment_succeeded": {
    const invoice = event.data.object as Stripe.Invoice;
    if (!invoice.customer_email) break;
    // Only send receipts for recurring payments, not the first one
    if (invoice.billing_reason !== "subscription_cycle") break;

    await sequenzy.transactional.send({
      to: invoice.customer_email,
      subject: `Payment receipt — ${formatCents(invoice.amount_paid)}`,
      body: receiptEmail({
        amount: invoice.amount_paid,
        invoiceUrl: invoice.hosted_invoice_url ?? `${APP_URL}/billing`,
        planName: invoice.lines.data[0]?.description ?? undefined,
        period: formatPeriod(invoice.period_start, invoice.period_end),
      }),
    });
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    if (!invoice.customer_email) break;

    await sequenzy.transactional.send({
      to: invoice.customer_email,
      subject: "Payment failed — action required",
      body: paymentFailedEmail({
        amount: invoice.amount_due,
        billingUrl: `${APP_URL}/billing`,
        nextRetry: invoice.next_payment_attempt
          ? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()
          : undefined,
      }),
    });
    break;
  }

  case "customer.subscription.trial_will_end": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const trialEnd = subscription.trial_end
      ? new Date(subscription.trial_end * 1000)
      : null;
    const daysLeft = trialEnd
      ? Math.ceil((trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
      : 3;

    await sequenzy.transactional.send({
      to: customer.email,
      subject: `Your trial ends in ${daysLeft} days`,
      body: trialEndingEmail({
        daysLeft,
        billingUrl: `${APP_URL}/billing`,
        planName: subscription.items.data[0]?.price?.nickname ?? undefined,
      }),
    });
    break;
  }

  case "customer.subscription.updated": {
    const subscription = event.data.object as Stripe.Subscription;
    const previous = event.data.previous_attributes as Partial<Stripe.Subscription> | undefined;

    // Only send email if the plan actually changed
    if (!previous?.items) break;

    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const oldPlan = previous.items?.data[0]?.price?.nickname ?? "Previous plan";
    const newPlan = subscription.items.data[0]?.price?.nickname ?? "New plan";
    const newAmount = subscription.items.data[0]?.price?.unit_amount ?? 0;

    await sequenzy.transactional.send({
      to: customer.email,
      subject: `Plan updated to ${newPlan}`,
      body: planChangeEmail({
        oldPlan,
        newPlan,
        newAmount,
        appUrl: APP_URL,
      }),
    });
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const endDate = subscription.current_period_end
      ? new Date(subscription.current_period_end * 1000).toLocaleDateString()
      : "soon";

    await sequenzy.transactional.send({
      to: customer.email,
      subject: "Your subscription has been cancelled",
      body: cancellationEmail({
        endDate,
        pricingUrl: `${APP_URL}/pricing`,
      }),
    });

    // Update subscriber tags
    await sequenzy.subscribers.tags.add({
      email: customer.email,
      tag: "cancelled",
    });
    break;
  }

  case "charge.refunded": {
    const charge = event.data.object as Stripe.Charge;
    if (!charge.receipt_email && !charge.billing_details?.email) break;

    const email = charge.receipt_email ?? charge.billing_details?.email!;
    const refunded = charge.amount_refunded;

    await sequenzy.transactional.send({
      to: email,
      subject: `Refund processed — ${formatCents(refunded)}`,
      body: refundEmail({
        amount: refunded,
        reason: charge.refunds?.data[0]?.reason ?? undefined,
      }),
    });
    break;
  }
}
}

function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}

function formatPeriod(start: number, end: number): string {
const s = new Date(start * 1000).toLocaleDateString();
const e = new Date(end * 1000).toLocaleDateString();
return `${s} — ${e}`;
}
app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { resend, FROM } from "@/lib/email";
import {
welcomeEmail,
receiptEmail,
paymentFailedEmail,
trialEndingEmail,
cancellationEmail,
planChangeEmail,
refundEmail,
} from "@/lib/stripe-emails";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(
    body, signature, process.env.STRIPE_WEBHOOK_SECRET!
  );
} catch {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

try {
  await handleStripeEvent(event);
} catch (error) {
  console.error(`Webhook handler failed for ${event.type}:`, error);
}

return NextResponse.json({ received: true });
}

async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    if (!session.customer_email) break;

    await resend.emails.send({
      from: FROM,
      to: session.customer_email,
      subject: "Welcome! Your subscription is active",
      html: welcomeEmail(APP_URL),
    });
    break;
  }

  case "invoice.payment_succeeded": {
    const invoice = event.data.object as Stripe.Invoice;
    if (!invoice.customer_email) break;
    if (invoice.billing_reason !== "subscription_cycle") break;

    await resend.emails.send({
      from: FROM,
      to: invoice.customer_email,
      subject: `Payment receipt — ${formatCents(invoice.amount_paid)}`,
      html: receiptEmail({
        amount: invoice.amount_paid,
        invoiceUrl: invoice.hosted_invoice_url ?? `${APP_URL}/billing`,
        planName: invoice.lines.data[0]?.description ?? undefined,
        period: formatPeriod(invoice.period_start, invoice.period_end),
      }),
    });
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    if (!invoice.customer_email) break;

    await resend.emails.send({
      from: FROM,
      to: invoice.customer_email,
      subject: "Payment failed — action required",
      html: paymentFailedEmail({
        amount: invoice.amount_due,
        billingUrl: `${APP_URL}/billing`,
        nextRetry: invoice.next_payment_attempt
          ? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()
          : undefined,
      }),
    });
    break;
  }

  case "customer.subscription.trial_will_end": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    await resend.emails.send({
      from: FROM,
      to: customer.email,
      subject: "Your trial ends in 3 days",
      html: trialEndingEmail({
        daysLeft: 3,
        billingUrl: `${APP_URL}/billing`,
      }),
    });
    break;
  }

  case "customer.subscription.updated": {
    const subscription = event.data.object as Stripe.Subscription;
    const previous = event.data.previous_attributes as Partial<Stripe.Subscription> | undefined;
    if (!previous?.items) break;

    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const oldPlan = previous.items?.data[0]?.price?.nickname ?? "Previous plan";
    const newPlan = subscription.items.data[0]?.price?.nickname ?? "New plan";
    const newAmount = subscription.items.data[0]?.price?.unit_amount ?? 0;

    await resend.emails.send({
      from: FROM,
      to: customer.email,
      subject: `Plan updated to ${newPlan}`,
      html: planChangeEmail({ oldPlan, newPlan, newAmount, appUrl: APP_URL }),
    });
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const endDate = subscription.current_period_end
      ? new Date(subscription.current_period_end * 1000).toLocaleDateString()
      : "soon";

    await resend.emails.send({
      from: FROM,
      to: customer.email,
      subject: "Your subscription has been cancelled",
      html: cancellationEmail({ endDate, pricingUrl: `${APP_URL}/pricing` }),
    });
    break;
  }

  case "charge.refunded": {
    const charge = event.data.object as Stripe.Charge;
    const email = charge.receipt_email ?? charge.billing_details?.email;
    if (!email) break;

    await resend.emails.send({
      from: FROM,
      to: email,
      subject: `Refund processed — ${formatCents(charge.amount_refunded)}`,
      html: refundEmail({
        amount: charge.amount_refunded,
        reason: charge.refunds?.data[0]?.reason ?? undefined,
      }),
    });
    break;
  }
}
}

function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}

function formatPeriod(start: number, end: number): string {
const s = new Date(start * 1000).toLocaleDateString();
const e = new Date(end * 1000).toLocaleDateString();
return `${s} — ${e}`;
}
app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sgMail, FROM } from "@/lib/email";
import {
welcomeEmail,
receiptEmail,
paymentFailedEmail,
trialEndingEmail,
cancellationEmail,
planChangeEmail,
refundEmail,
} from "@/lib/stripe-emails";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.APP_URL!;

export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(
    body, signature, process.env.STRIPE_WEBHOOK_SECRET!
  );
} catch {
  return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}

try {
  await handleStripeEvent(event);
} catch (error) {
  console.error(`Webhook handler failed for ${event.type}:`, error);
}

return NextResponse.json({ received: true });
}

async function handleStripeEvent(event: Stripe.Event) {
switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    if (!session.customer_email) break;

    await sgMail.send({
      to: session.customer_email,
      from: FROM,
      subject: "Welcome! Your subscription is active",
      html: welcomeEmail(APP_URL),
    });
    break;
  }

  case "invoice.payment_succeeded": {
    const invoice = event.data.object as Stripe.Invoice;
    if (!invoice.customer_email) break;
    if (invoice.billing_reason !== "subscription_cycle") break;

    await sgMail.send({
      to: invoice.customer_email,
      from: FROM,
      subject: `Payment receipt — ${formatCents(invoice.amount_paid)}`,
      html: receiptEmail({
        amount: invoice.amount_paid,
        invoiceUrl: invoice.hosted_invoice_url ?? `${APP_URL}/billing`,
        planName: invoice.lines.data[0]?.description ?? undefined,
        period: formatPeriod(invoice.period_start, invoice.period_end),
      }),
    });
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;
    if (!invoice.customer_email) break;

    await sgMail.send({
      to: invoice.customer_email,
      from: FROM,
      subject: "Payment failed — action required",
      html: paymentFailedEmail({
        amount: invoice.amount_due,
        billingUrl: `${APP_URL}/billing`,
        nextRetry: invoice.next_payment_attempt
          ? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()
          : undefined,
      }),
    });
    break;
  }

  case "customer.subscription.trial_will_end": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    await sgMail.send({
      to: customer.email,
      from: FROM,
      subject: "Your trial ends in 3 days",
      html: trialEndingEmail({
        daysLeft: 3,
        billingUrl: `${APP_URL}/billing`,
      }),
    });
    break;
  }

  case "customer.subscription.updated": {
    const subscription = event.data.object as Stripe.Subscription;
    const previous = event.data.previous_attributes as Partial<Stripe.Subscription> | undefined;
    if (!previous?.items) break;

    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const oldPlan = previous.items?.data[0]?.price?.nickname ?? "Previous plan";
    const newPlan = subscription.items.data[0]?.price?.nickname ?? "New plan";
    const newAmount = subscription.items.data[0]?.price?.unit_amount ?? 0;

    await sgMail.send({
      to: customer.email,
      from: FROM,
      subject: `Plan updated to ${newPlan}`,
      html: planChangeEmail({ oldPlan, newPlan, newAmount, appUrl: APP_URL }),
    });
    break;
  }

  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customer = await stripe.customers.retrieve(
      subscription.customer as string
    );
    if (customer.deleted || !customer.email) break;

    const endDate = subscription.current_period_end
      ? new Date(subscription.current_period_end * 1000).toLocaleDateString()
      : "soon";

    await sgMail.send({
      to: customer.email,
      from: FROM,
      subject: "Your subscription has been cancelled",
      html: cancellationEmail({ endDate, pricingUrl: `${APP_URL}/pricing` }),
    });
    break;
  }

  case "charge.refunded": {
    const charge = event.data.object as Stripe.Charge;
    const email = charge.receipt_email ?? charge.billing_details?.email;
    if (!email) break;

    await sgMail.send({
      to: email,
      from: FROM,
      subject: `Refund processed — ${formatCents(charge.amount_refunded)}`,
      html: refundEmail({
        amount: charge.amount_refunded,
        reason: charge.refunds?.data[0]?.reason ?? undefined,
      }),
    });
    break;
  }
}
}

function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}

function formatPeriod(start: number, end: number): string {
const s = new Date(start * 1000).toLocaleDateString();
const e = new Date(end * 1000).toLocaleDateString();
return `${s} — ${e}`;
}

Express Version

If you're using Express instead of Next.js, the handler is similar — the key difference is using express.raw() to get the raw body for signature verification:

// routes/stripe-webhook.ts
import express from "express";
import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = express.Router();
 
// IMPORTANT: Use express.raw() for the webhook route
// The raw body is required for signature verification
router.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["stripe-signature"] as string;
 
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (err) {
      console.error("Webhook signature verification failed:", err);
      return res.status(400).send("Invalid signature");
    }
 
    try {
      await handleStripeEvent(event);
    } catch (error) {
      console.error(`Failed to handle ${event.type}:`, error);
    }
 
    res.json({ received: true });
  }
);
 
export default router;

If you're using express.json() globally, make sure the webhook route is registered before the global JSON parser, or exclude it:

// app.ts
import stripeWebhook from "./routes/stripe-webhook";
 
// Register webhook route BEFORE express.json()
app.use("/api/webhooks/stripe", stripeWebhook);
 
// Then apply JSON parsing to everything else
app.use(express.json());

Handle Idempotency

Stripe may send the same event multiple times. Your webhook handler should handle duplicates gracefully. Use event.id to deduplicate:

// lib/idempotency.ts
 
// In production, use Redis or your database instead of a Map
const processedEvents = new Map<string, number>();
 
export function isDuplicate(eventId: string): boolean {
  if (processedEvents.has(eventId)) {
    return true;
  }
  processedEvents.set(eventId, Date.now());
  return false;
}
 
// Clean up old entries periodically
setInterval(() => {
  const oneHourAgo = Date.now() - 60 * 60 * 1000;
  for (const [id, timestamp] of processedEvents) {
    if (timestamp < oneHourAgo) {
      processedEvents.delete(id);
    }
  }
}, 60 * 60 * 1000);

Database-Backed Idempotency

For production, use your database:

// lib/idempotency-db.ts
import { db } from "@/lib/db";
 
export async function processEventOnce(
  eventId: string,
  handler: () => Promise<void>
): Promise<boolean> {
  // Try to insert the event ID — if it already exists, skip
  try {
    await db.execute(
      `INSERT INTO processed_stripe_events (event_id, processed_at)
       VALUES (?, NOW())`,
      [eventId]
    );
  } catch {
    // Duplicate key — event already processed
    return false;
  }
 
  await handler();
  return true;
}
-- Migration
CREATE TABLE processed_stripe_events (
  event_id VARCHAR(255) PRIMARY KEY,
  processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_processed_at (processed_at)
);
 
-- Clean up events older than 24 hours (optional cron job)
DELETE FROM processed_stripe_events
WHERE processed_at < DATE_SUB(NOW(), INTERVAL 24 HOUR);

Use it in your webhook handler:

const wasProcessed = await processEventOnce(event.id, () =>
  handleStripeEvent(event)
);
 
if (!wasProcessed) {
  console.log(`Skipping duplicate event: ${event.id}`);
}

Return 200 Quickly

Stripe expects your webhook to return a 200 response within 20 seconds. If it doesn't, Stripe retries — which can cause duplicate emails. For slow email sends, process asynchronously:

// Option 1: Fire and forget (simple, but you lose error tracking)
export async function POST(request: NextRequest) {
  const event = verifyAndParseEvent(request);
 
  // Don't await — return immediately
  handleStripeEvent(event).catch((error) => {
    console.error(`Background handler failed for ${event.type}:`, error);
  });
 
  return NextResponse.json({ received: true });
}
 
// Option 2: Queue for processing (reliable, recommended for production)
export async function POST(request: NextRequest) {
  const event = verifyAndParseEvent(request);
 
  // Store the event for background processing
  await db.insert(webhookQueue).values({
    eventId: event.id,
    eventType: event.type,
    payload: JSON.stringify(event),
    status: "pending",
  });
 
  return NextResponse.json({ received: true });
}

The Smarter Approach: Native Stripe Integration

Writing webhook handlers for every Stripe event is a lot of boilerplate. If you're using Sequenzy, you can skip most of it.

Sequenzy has a native Stripe integration. Connect your Stripe account in the dashboard (Settings > Integrations), and it automatically:

  • Tracks all payment events — purchase, cancellation, churn, failed payment, upgrade, downgrade
  • Applies status tags to subscribers — customer, trial, cancelled, churned, past-due
  • Syncs subscription data — MRR, plan name, billing interval as subscriber attributes
  • Triggers automated sequences based on lifecycle events

Instead of writing webhook code for every event, you set up sequences in the dashboard:

SequenceTriggerStops WhenPurpose
Trial ConversionTag trial addedUser gets customer tagConvert trial to paid
DunningTag past-due addedUser no longer past-dueRecover failed payments
Win-BackTag cancelled addedUser gets customer tagRe-engage cancelled users
Churn RecoveryTag churned addedUser gets customer tagWin back churned users

Zero webhook code for lifecycle emails. The integration handles tagging, event tracking, and sequence triggering automatically.

You still need a webhook handler for custom logic — like updating your own database, provisioning accounts, or sending one-off transactional emails with app-specific data. But the repetitive lifecycle email sequences are handled for you.

Testing Stripe Webhooks Locally

Use the Stripe CLI to forward events to your local server:

# Install the Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Log in
stripe login
 
# Forward events to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a webhook signing secret (whsec_...). Use it as your STRIPE_WEBHOOK_SECRET in development.

Trigger specific test events:

# New subscription
stripe trigger checkout.session.completed
 
# Payment receipt
stripe trigger invoice.payment_succeeded
 
# Failed payment
stripe trigger invoice.payment_failed
 
# Trial ending
stripe trigger customer.subscription.trial_will_end
 
# Cancellation
stripe trigger customer.subscription.deleted
 
# Refund
stripe trigger charge.refunded

Custom Test Payloads

For more control, create fixtures:

# Create a fixture file
cat > stripe-fixtures/checkout-completed.json << 'EOF'
{
  "_meta": { "template_version": 0 },
  "fixtures": [{
    "name": "checkout_session",
    "path": "/v1/checkout/sessions",
    "method": "post",
    "params": {
      "mode": "subscription",
      "customer_email": "test@example.com",
      "line_items": [{
        "price": "price_xxx",
        "quantity": 1
      }],
      "metadata": {
        "plan": "Pro"
      }
    }
  }]
}
EOF
 
stripe fixtures stripe-fixtures/checkout-completed.json

Error Handling

Wrap Email Sends

Don't let a failed email send crash your webhook handler. Stripe will retry, causing duplicate processing for events that succeeded before the email failure:

async function sendEmailSafe(
  sendFn: () => Promise<unknown>,
  context: { eventType: string; email: string }
): Promise<void> {
  try {
    await sendFn();
  } catch (error) {
    // Log the error but don't throw — the webhook handler should still succeed
    console.error(`Failed to send ${context.eventType} email to ${context.email}:`, error);
 
    // Optionally: queue for retry, send to error tracking, etc.
    // await errorTracker.capture(error, context);
  }
}
 
// Usage in your handler
case "checkout.session.completed": {
  const session = event.data.object as Stripe.Checkout.Session;
  if (!session.customer_email) break;
 
  await sendEmailSafe(
    () => sequenzy.transactional.send({
      to: session.customer_email!,
      subject: "Welcome!",
      body: welcomeEmail(APP_URL),
    }),
    { eventType: "checkout.session.completed", email: session.customer_email }
  );
  break;
}

Handle Missing Customer Email

Not all Stripe objects have a customer email directly. Sometimes you need to look it up:

async function getCustomerEmail(
  stripe: Stripe,
  customerId: string | Stripe.Customer | Stripe.DeletedCustomer | null
): Promise<string | null> {
  if (!customerId) return null;
 
  if (typeof customerId === "string") {
    const customer = await stripe.customers.retrieve(customerId);
    if (customer.deleted) return null;
    return customer.email;
  }
 
  if ("deleted" in customerId && customerId.deleted) return null;
  return (customerId as Stripe.Customer).email;
}

Going to Production

1. Verify Webhook Signatures — Always

Never skip signature verification. Without it, anyone can send fake events to your webhook endpoint and trigger emails to arbitrary addresses.

2. Verify Your Email Domain

Add SPF, DKIM, and DMARC DNS records through your email provider's dashboard. Payment emails going to spam is one of the worst customer experiences.

3. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. This protects your main domain's reputation.

4. Disable Stripe's Default Emails

If you're sending your own receipts and notifications, disable Stripe's built-in emails to avoid double-sending. Go to Settings > Emails in the Stripe Dashboard and turn off:

  • Successful payments
  • Refunds
  • Disputes
  • Invoices (if you send your own)

5. Register Only Events You Handle

In the Stripe webhook configuration, only subscribe to events your handler processes. This reduces unnecessary webhook calls and potential noise.

Production Checklist

StepWhatWhy
Signature verificationstripe.webhooks.constructEvent()Prevent spoofed events
IdempotencyDeduplicate by event.idHandle Stripe retries safely
Quick responseReturn 200 within 20 secondsPrevent Stripe retry loops
Domain verificationSPF, DKIM, DMARC recordsInbox delivery, not spam
Disable Stripe emailsTurn off defaults you're replacingNo duplicate emails
Error isolationDon't let email failures crash the handlerProcess other events normally
LoggingLog event types and outcomesDebug failures in production
Monitor webhook healthCheck Stripe Dashboard > WebhooksCatch failures early

FAQ

Should I use Stripe's built-in emails or send my own?

Send your own. Stripe's built-in emails are generic — you can't customize the design, add your branding, include app-specific data, or control the timing. For a professional SaaS, custom emails that match your brand and include relevant context (like the user's plan name and dashboard link) make a big difference.

Why does my webhook need request.text() instead of request.json()?

Stripe's signature verification requires the raw request body as a string. If you parse it as JSON first, the signature won't match because JSON.stringify doesn't guarantee the same byte order. Always read the raw body with .text(), verify the signature, then parse with JSON.parse().

How do I get the customer's email from a Stripe event?

It depends on the event type. invoice.payment_succeeded has customer_email directly. customer.subscription.trial_will_end only has a customer ID, so you need to call stripe.customers.retrieve() to get the email. Always check for customer.deleted before accessing the email.

Stripe is retrying my webhook and sending duplicate emails. How do I fix this?

Two common causes: (1) Your handler takes longer than 20 seconds to respond — return 200 immediately and process asynchronously. (2) Your handler throws an error — Stripe sees a 500 and retries. Use idempotency (deduplicate by event.id) and wrap email sends in try-catch so they don't crash the handler.

Should I send the email synchronously in the webhook or queue it?

For low volume (under ~100 webhooks/minute), synchronous is fine — email API calls are fast (under 1 second). For higher volume or if you need guaranteed delivery, queue the email (in your database or a job queue like BullMQ) and process it asynchronously. This also lets you retry failed sends without waiting for Stripe to retry the webhook.

How do I test Stripe webhooks in development?

Use the Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe. It gives you a local webhook secret and forwards real Stripe test events to your server. Trigger specific events with stripe trigger checkout.session.completed.

What's the difference between checkout.session.completed and invoice.payment_succeeded?

checkout.session.completed fires once when a new subscription is created via Checkout. invoice.payment_succeeded fires for every successful payment, including recurring renewals. Use checkout.session.completed for welcome emails and invoice.payment_succeeded (filtered by billing_reason === "subscription_cycle") for recurring receipts.

How do I handle subscription upgrades and downgrades?

Listen for customer.subscription.updated and check event.data.previous_attributes to see what changed. If previous_attributes.items exists, the plan changed. Compare the old and new price nicknames to determine if it was an upgrade or downgrade.

Can I send emails for Stripe Connect (marketplace) events?

Yes. For Connect webhooks, use a separate endpoint with a different webhook secret. The event structure is the same, but events are wrapped in a data.account field indicating which connected account triggered it. You'll need to look up the connected account's customer email separately.

What happens if my email provider is down when a Stripe webhook fires?

If you're sending synchronously and the email API fails, you have two options: (1) Let it fail silently (log the error, return 200 to Stripe) and accept the lost email. (2) Queue failed emails for retry. For critical emails like payment receipts, option 2 is recommended. The database queue pattern with a retry mechanism ensures no email is lost.

Wrapping Up

Here's what we covered:

  1. Webhook handler for all major Stripe lifecycle events — checkout, receipts, failed payments, trials, cancellations, upgrades, and refunds
  2. Email templates organized as functions for clean, testable code
  3. Idempotency with database-backed deduplication to handle Stripe retries
  4. Express version with the raw body parser for signature verification
  5. Error handling that isolates email failures from webhook processing
  6. Native integration with Sequenzy to skip webhook boilerplate for lifecycle emails
  7. Stripe CLI for local testing with real event payloads
  8. Production checklist: signature verification, domain setup, disabling Stripe's default emails

For most SaaS apps, connecting Stripe to Sequenzy and setting up automated sequences is the simplest path — zero webhook code for lifecycle emails. But when you need full control over timing, content, or custom logic, the webhook handler above has you covered. For failed payment recovery specifically, see our failed payment recovery guide.

Frequently Asked Questions

Which Stripe webhook events should I listen for to send emails?

The essentials: checkout.session.completed (purchase), invoice.payment_succeeded (renewal receipt), invoice.payment_failed (dunning), customer.subscription.deleted (cancellation), and customer.subscription.trial_will_end (trial expiry warning). Start with these and add more as needed.

How do I verify Stripe webhook signatures?

Use stripe.webhooks.constructEvent(body, signature, webhookSecret) with the raw request body (not parsed JSON), the stripe-signature header, and your webhook signing secret from the Stripe dashboard. This prevents forged webhook events.

Should I disable Stripe's default emails?

Yes, if you're sending custom emails. Go to Stripe Dashboard > Settings > Emails and disable receipts and invoice emails. Otherwise, customers receive duplicate emails—one from Stripe and one from you—which looks unprofessional.

How do I handle duplicate Stripe webhook deliveries?

Store processed event IDs in your database and check before processing. Stripe may retry webhooks if your endpoint doesn't respond with 200 within 20 seconds. Use the event.id as a unique key to ensure idempotent processing.

How do I get the customer's email from a Stripe webhook?

For checkout events, use session.customer_email or session.customer_details.email. For subscription events, fetch the customer with stripe.customers.retrieve(subscription.customer) to get the email. Always handle cases where email might be null.

How do I handle Stripe's test mode webhooks?

Use Stripe CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe) to forward test events to your local server. Stripe CLI generates a temporary webhook secret for local development. Test all your email flows with test mode before going live.

Should I send emails synchronously in the Stripe webhook handler?

Return 200 to Stripe as quickly as possible—ideally within 5 seconds. For single transactional emails, synchronous sending is usually fast enough. For complex flows, return 200 immediately and process emails via a background job queue.

How do I send different emails based on the subscription plan?

Extract the price_id or product_id from the webhook payload and map it to email templates. Store a plan-to-template mapping in your config. Include plan-specific details (features, limits, next billing amount) in the email content.

How do I handle Stripe subscription trial emails?

Listen for customer.subscription.trial_will_end (fired 3 days before trial ends) to send a trial-ending reminder. Use customer.subscription.created with status: 'trialing' for the initial trial welcome email. Track the trial end date for custom reminder schedules.

What happens if my webhook endpoint is down during a Stripe event?

Stripe retries failed webhooks up to 20 times over about 3 days with exponential backoff. Your endpoint will eventually receive the event when it comes back online. For mission-critical flows, also poll the Stripe API periodically to catch any missed events.