How to Send Emails from Stripe Webhooks (2026 Guide)

Stripe handles payments. It does not handle customer communication. When someone subscribes, upgrades, or has a failed payment, you need to send them an email. Stripe sends basic receipts, but they're generic and you can't customize the content, timing, or branding.
This guide covers how to send emails from Stripe webhook events: payment receipts, subscription confirmations, failed payment alerts, trial ending notices, and cancellation follow-ups. All with working code for Next.js, Express, and any Node.js server.
The Architecture
Stripe Event → Your Webhook Endpoint → Email Provider → Customer Inbox
Stripe fires webhook events for everything: payments, subscriptions, invoices, disputes. You listen for the ones you care about and send the appropriate email.
Set Up the Webhook Endpoint
First, install Stripe and your email provider:
npm install stripe sequenzynpm install stripe resendnpm install stripe @sendgrid/mail# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...The Webhook Handler
Here's a complete webhook handler that covers the most common Stripe events for a SaaS app:
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import Sequenzy from "sequenzy";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const sequenzy = new Sequenzy();
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 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await sequenzy.transactional.send({
to: session.customer_email!,
subject: "Welcome! Your subscription is active",
body: `
<h1>You're all set!</h1>
<p>Thanks for subscribing. Your account is now active.</p>
<a href="${process.env.APP_URL}/dashboard"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard
</a>
`,
});
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object;
if (invoice.billing_reason === "subscription_cycle") {
await sequenzy.transactional.send({
to: invoice.customer_email!,
subject: `Payment receipt - $${(invoice.amount_paid / 100).toFixed(2)}`,
body: `
<h2>Payment Received</h2>
<p>We received your payment of <strong>$${(invoice.amount_paid / 100).toFixed(2)}</strong>.</p>
<a href="${invoice.hosted_invoice_url}" style="color:#f97316;">View Invoice</a>
`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await sequenzy.transactional.send({
to: invoice.customer_email!,
subject: "Payment failed - action required",
body: `
<h2>Payment Failed</h2>
<p>We couldn't process your payment of $${(invoice.amount_due / 100).toFixed(2)}.</p>
<p>Please update your payment method to keep your account active.</p>
<a href="${process.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method
</a>
`,
});
break;
}
case "customer.subscription.trial_will_end": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) break;
await sequenzy.transactional.send({
to: customer.email!,
subject: "Your trial ends in 3 days",
body: `
<h2>Trial Ending Soon</h2>
<p>Your free trial ends in 3 days. Add a payment method to continue.</p>
<a href="${process.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Add Payment Method
</a>
`,
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) break;
await sequenzy.transactional.send({
to: customer.email!,
subject: "Your subscription has been cancelled",
body: `
<h2>We're sorry to see you go</h2>
<p>Your subscription has been cancelled. You can resubscribe at any time.</p>
<a href="${process.env.APP_URL}/pricing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Resubscribe
</a>
`,
});
break;
}
}
return NextResponse.json({ received: true });
}import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { Resend } from "resend";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = "Your App <noreply@yourdomain.com>";
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 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await resend.emails.send({
from: FROM, to: session.customer_email!,
subject: "Welcome! Your subscription is active",
html: `<h1>You're all set!</h1><p>Thanks for subscribing.</p>
<a href="${process.env.APP_URL}/dashboard"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard</a>`,
});
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object;
if (invoice.billing_reason === "subscription_cycle") {
await resend.emails.send({
from: FROM, to: invoice.customer_email!,
subject: `Payment receipt - $${(invoice.amount_paid / 100).toFixed(2)}`,
html: `<h2>Payment Received</h2>
<p>We received <strong>$${(invoice.amount_paid / 100).toFixed(2)}</strong>.</p>
<a href="${invoice.hosted_invoice_url}" style="color:#f97316;">View Invoice</a>`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await resend.emails.send({
from: FROM, to: invoice.customer_email!,
subject: "Payment failed - action required",
html: `<h2>Payment Failed</h2>
<p>We couldn't process $${(invoice.amount_due / 100).toFixed(2)}.</p>
<a href="${process.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method</a>`,
});
break;
}
case "customer.subscription.trial_will_end": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) break;
await resend.emails.send({
from: FROM, to: customer.email!,
subject: "Your trial ends in 3 days",
html: `<h2>Trial Ending Soon</h2><p>Add a payment method to continue.</p>
<a href="${process.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Add Payment Method</a>`,
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) break;
await resend.emails.send({
from: FROM, to: customer.email!,
subject: "Your subscription has been cancelled",
html: `<h2>We're sorry to see you go</h2><p>Resubscribe any time.</p>
<a href="${process.env.APP_URL}/pricing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Resubscribe</a>`,
});
break;
}
}
return NextResponse.json({ received: true });
}import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import sgMail from "@sendgrid/mail";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
const FROM = "noreply@yourdomain.com";
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 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
await sgMail.send({
to: session.customer_email!, from: FROM,
subject: "Welcome! Your subscription is active",
html: `<h1>You're all set!</h1><p>Thanks for subscribing.</p>
<a href="${process.env.APP_URL}/dashboard"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard</a>`,
});
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object;
if (invoice.billing_reason === "subscription_cycle") {
await sgMail.send({
to: invoice.customer_email!, from: FROM,
subject: `Payment receipt - $${(invoice.amount_paid / 100).toFixed(2)}`,
html: `<h2>Payment Received</h2>
<p>We received <strong>$${(invoice.amount_paid / 100).toFixed(2)}</strong>.</p>
<a href="${invoice.hosted_invoice_url}" style="color:#f97316;">View Invoice</a>`,
});
}
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await sgMail.send({
to: invoice.customer_email!, from: FROM,
subject: "Payment failed - action required",
html: `<h2>Payment Failed</h2>
<p>We couldn't process $${(invoice.amount_due / 100).toFixed(2)}.</p>
<a href="${process.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method</a>`,
});
break;
}
case "customer.subscription.trial_will_end": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) break;
await sgMail.send({
to: customer.email!, from: FROM,
subject: "Your trial ends in 3 days",
html: `<h2>Trial Ending Soon</h2><p>Add a payment method to continue.</p>
<a href="${process.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Add Payment Method</a>`,
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) break;
await sgMail.send({
to: customer.email!, from: FROM,
subject: "Your subscription has been cancelled",
html: `<h2>We're sorry to see you go</h2><p>Resubscribe any time.</p>
<a href="${process.env.APP_URL}/pricing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Resubscribe</a>`,
});
break;
}
}
return NextResponse.json({ received: true });
}Which Stripe Events to Listen For
Here are the events that matter for a SaaS app:
| Event | When | Email to Send |
|---|---|---|
checkout.session.completed | New subscription | Welcome + receipt |
invoice.payment_succeeded | Recurring payment | Payment receipt |
invoice.payment_failed | Card declined | Update payment method |
customer.subscription.trial_will_end | 3 days before trial ends | Add payment method |
customer.subscription.updated | Plan change | Confirmation |
customer.subscription.deleted | Subscription cancelled | Win-back |
charge.refunded | Refund issued | Refund confirmation |
Configure these in the Stripe Dashboard under Developers > Webhooks.
Express Version
Same logic, Express routes:
// 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();
router.post(
"/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, signature, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return res.status(400).send("Invalid signature");
}
// Same switch/case as above
await handleStripeEvent(event);
res.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 all of it.
Sequenzy has a native Stripe integration. You 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)
- Triggers automated sequences based on lifecycle events
Instead of writing webhook code for every event, you just set up sequences in the dashboard:
- Trial conversion sequence: triggers when someone starts a trial, stops when they pay
- Dunning sequence: triggers on failed payment, stops when payment succeeds
- Churn prevention: triggers when someone cancels, stops when they resubscribe
Zero webhook code needed. The integration handles everything.
Testing Stripe Webhooks Locally
Use the Stripe CLI to forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripeTrigger test events:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.trial_will_endGoing to Production
1. Verify Webhook Signatures
Always verify the stripe-signature header. Never skip this.
2. Return 200 Quickly
Stripe retries webhooks that don't return 200 within 20 seconds. Send emails asynchronously if possible.
3. Handle Idempotency
Stripe may send the same event multiple times. Use event.id to deduplicate.
const processedEvents = new Set<string>();
if (processedEvents.has(event.id)) {
return NextResponse.json({ received: true });
}
processedEvents.add(event.id);
// In production, use Redis or a database instead of a Set4. Verify Your Email Domain
Add SPF, DKIM, DMARC records. Payment emails going to spam is a terrible customer experience.
Wrapping Up
- Webhook handler for checkout, payment, subscription, and trial events
- Email templates for each lifecycle stage
- Stripe CLI for local testing
- Native integration with Sequenzy to skip webhook code entirely
- Idempotency and signature verification for production
For most SaaS apps, connecting Stripe to Sequenzy and setting up automated sequences is the simplest path. But if you need full control, the webhook handler above has you covered.