Add Billing Emails to a Stripe SaaS (Receipts, Failed Payments)
Wire Stripe webhooks to Notify so successful payments get receipts and failed invoices get a clear transactional email — with idempotency so retries do not spam customers.
Stripe already collects money. Your customers still expect an email when a charge succeeds — and a clear note when it fails. Those messages are transactional, not a newsletter. This guide wires Stripe webhooks to Notify in a Next.js (or any Node) backend.
You will:
- Verify Stripe webhook signatures
- Send a receipt on
invoice.paid/checkout.session.completed - Send a failed-payment email on
invoice.payment_failed - Gate sends with idempotency so Stripe retries do not duplicate mail
Why not “just use Stripe’s emails”?
Stripe’s built-in customer emails are fine for many apps. Roll your own when you need:
- Brand HTML that matches your product (you own the markup)
- Copy that links into your dashboard, not only Stripe-hosted pages
- The same delivery logs/webhooks as password resets and other app mail
- A single transactional provider across auth + billing
If Stripe’s defaults are enough, use them. This post is for teams that want receipts in their voice.
Architecture
Stripe → POST /api/stripe/webhook (signature verified)
→ load customer email + amount
→ idempotency check (DB)
→ Notify send
→ mark receipt_sent_at / dunning_sent_at
Prerequisites
- Stripe account + webhook endpoint secret
NOTIFY_API_KEY(Credentials)- Verified domain for
billing@your-domain(domain verification) - A place to store send markers (column on
subscriptions/invoicestable)
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NOTIFY_API_KEY=your_api_key_here
Shared Notify helper
// lib/notify.ts
export async function sendEmail(opts: {
to: string;
subject: string;
message: string;
from?: string;
}) {
const response = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!
},
body: JSON.stringify({
from: opts.from ?? 'billing@your-verified-domain.com',
to: opts.to,
subject: opts.subject,
message: opts.message
})
});
if (!response.ok) {
throw new Error(`Notify error: ${await response.text()}`);
}
return response.json();
}
Idempotency columns
Stripe retries webhooks. Without a gate, customers get three receipts.
alter table billing_events
add column stripe_event_id text unique,
add column receipt_sent_at timestamptz,
add column payment_failed_email_sent_at timestamptz;
Or store stripe_invoice_id + email_kind as a unique pair. The rule: one successful Notify send per logical event.
Webhook route
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { sendEmail } from '@/lib/notify';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Persist event.id first with ON CONFLICT DO NOTHING for at-least-once safety
const inserted = await tryInsertBillingEvent(event.id);
if (!inserted) {
return Response.json({ received: true, duplicate: true });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await sendCheckoutReceipt(session);
break;
}
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice;
await sendInvoiceReceipt(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await sendPaymentFailedEmail(invoice);
break;
}
default:
break;
}
return Response.json({ received: true });
}
Use raw body for signature verification (App Router: request.text(), not request.json() first).
Receipt on Checkout
async function sendCheckoutReceipt(session: Stripe.Checkout.Session) {
const email = session.customer_details?.email;
if (!email) return;
if (await alreadySentReceipt(session.id)) return;
const amount = ((session.amount_total ?? 0) / 100).toFixed(2);
const currency = (session.currency ?? 'usd').toUpperCase();
const dashboardUrl = `${process.env.NEXT_PUBLIC_APP_URL}/billing`;
await sendEmail({
to: email,
subject: `Receipt — ${amount} ${currency}`,
message: `
<h1>Thanks for your payment</h1>
<p>We received <strong>${amount} ${currency}</strong>.</p>
<p>Reference: ${session.id}</p>
<p><a href="${dashboardUrl}">View billing</a></p>
`
});
await markReceiptSent(session.id);
}
Receipt on invoice.paid
Subscriptions usually emit invoices. Prefer invoice.paid for recurring charges:
async function sendInvoiceReceipt(invoice: Stripe.Invoice) {
const email = invoice.customer_email;
if (!email || !invoice.id) return;
if (await alreadySentReceipt(invoice.id)) return;
const amount = ((invoice.amount_paid ?? 0) / 100).toFixed(2);
const currency = (invoice.currency ?? 'usd').toUpperCase();
const hosted = invoice.hosted_invoice_url;
await sendEmail({
to: email,
subject: `Invoice receipt — ${amount} ${currency}`,
message: `
<h1>Payment received</h1>
<p>Amount: <strong>${amount} ${currency}</strong></p>
${hosted ? `<p><a href="${hosted}">View invoice</a></p>` : ''}
<p>Invoice ${invoice.number ?? invoice.id}</p>
`
});
await markReceiptSent(invoice.id);
}
Pick either Checkout receipts or invoice receipts for a given flow so you do not double-email. For Subscription Checkout, invoice.paid is usually enough.
Failed payment (dunning-lite)
async function sendPaymentFailedEmail(invoice: Stripe.Invoice) {
const email = invoice.customer_email;
if (!email || !invoice.id) return;
if (await alreadySentPaymentFailed(invoice.id)) return;
const amount = ((invoice.amount_due ?? 0) / 100).toFixed(2);
const currency = (invoice.currency ?? 'usd').toUpperCase();
const updateUrl =
invoice.hosted_invoice_url ??
`${process.env.NEXT_PUBLIC_APP_URL}/billing`;
await sendEmail({
to: email,
subject: 'Action needed: payment failed',
message: `
<h1>We could not process your payment</h1>
<p>Amount due: <strong>${amount} ${currency}</strong>.</p>
<p><a href="${updateUrl}">Update payment method</a></p>
<p>If you already fixed this, you can ignore this email.</p>
`
});
await markPaymentFailedEmailSent(invoice.id);
}
This is one clear transactional message — not a six-step marketing dunning sequence. If you need sophisticated retry copy, keep orchestration in your app or Stripe Smart Retries; still send through the same transactional API.
Testing locally
stripe listen --forward-to localhost:3000/api/stripe/webhook- Trigger
stripe trigger invoice.paid/invoice.payment_failed - Confirm Notify logs
- Fire the same event twice — assert only one email
Use Notify sandbox/test send while iterating on HTML if your domain is not verified yet.
Production checklist
- [ ] Webhook signature verification required
- [ ] Idempotency on
event.idor invoice id + kind - [ ] No duplicate path between Checkout and
invoice.paid - [ ] Verified
fromdomain - [ ] Support link / billing portal URL in every billing email
- [ ] Bounce handling when volume grows (webhooks)
- [ ] Never log full card details (you won’t have them — keep it that way)
Related recipes
- Short form: Stripe payment → receipt email
- Broader receipt patterns: How to send receipt emails
- Next.js send basics: Notify + Next.js
Bottom line
Billing email is webhook hygiene plus one transactional send. Stripe tells you what happened; Notify delivers the customer-facing message; your database makes retries safe.
Free tier covers early volume; Pro is $10 / 10,000 emails when receipts and auth mail share the same pipe. Pricing · Quick start