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:

  1. Verify Stripe webhook signatures
  2. Send a receipt on invoice.paid / checkout.session.completed
  3. Send a failed-payment email on invoice.payment_failed
  4. 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 / invoices table)
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

  1. stripe listen --forward-to localhost:3000/api/stripe/webhook
  2. Trigger stripe trigger invoice.paid / invoice.payment_failed
  3. Confirm Notify logs
  4. 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.id or invoice id + kind
  • [ ] No duplicate path between Checkout and invoice.paid
  • [ ] Verified from domain
  • [ ] 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

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