How to Send Receipt and Invoice Emails From Your SaaS

Marcus Yakubu

When a customer pays, they expect a receipt. When an invoice fails, they need a clear next step. Both are transactional — triggered by payment events,…

When a customer pays, they expect a receipt. When an invoice fails, they need a clear next step. Both are transactional — triggered by payment events, not by a marketing segment.

This guide uses Stripe webhooks + Notify. You build the HTML; Notify delivers via POST https://notify.cx/api/email/send with x-api-key. Short recipe twin: Stripe → receipt. Longer narrative: Billing emails with Stripe + Notify.

Flow

Stripe event → verify signature → idempotency gate → Notify send → mark sent

Without the gate, Stripe retries = duplicate receipts.

Prerequisites

STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NOTIFY_API_KEY=your_api_key_here
NEXT_PUBLIC_APP_URL=https://yourapp.com

While iterating on HTML, use POST https://notify.cx/api/email/send/test (sandbox vs production).

Send helper

// lib/email.ts
export async function sendEmail(opts: {
  to: string;
  subject: string;
  message: string;
}) {
  const apiKey = process.env.NOTIFY_API_KEY;
  if (!apiKey) throw new Error('NOTIFY_API_KEY is not set');

  const res = await fetch('https://notify.cx/api/email/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': apiKey
    },
    body: JSON.stringify({
      from: 'billing@your-verified-domain.com',
      ...opts
    })
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

Idempotency table

create table billing_emails (
  id uuid primary key default gen_random_uuid(),
  stripe_object_id text not null,
  kind text not null check (kind in ('receipt', 'payment_failed')),
  sent_at timestamptz not null default now(),
  unique (stripe_object_id, kind)
);
// lib/db/billing-emails.ts
export async function alreadySentReceipt(stripeObjectId: string): Promise<boolean> {
  // SELECT 1 FROM billing_emails WHERE stripe_object_id = $1 AND kind = 'receipt'
  throw new Error('Implement alreadySentReceipt');
}

export async function markReceiptSent(stripeObjectId: string): Promise<void> {
  // INSERT … ON CONFLICT DO NOTHING (or check affected rows)
  throw new Error('Implement markReceiptSent');
}

export async function alreadySentPaymentFailed(
  stripeObjectId: string
): Promise<boolean> {
  throw new Error('Implement alreadySentPaymentFailed');
}

export async function markPaymentFailedSent(stripeObjectId: string): Promise<void> {
  throw new Error('Implement markPaymentFailedSent');
}

Insert (or unique-constrained update) before or immediately after a successful send so Stripe retries become no-ops.

Receipt after payment

import { sendEmail } from '@/lib/email';
import { alreadySentReceipt, markReceiptSent } from '@/lib/db/billing-emails';

async function sendInvoiceReceipt(invoice: {
  id: string;
  customer_email: string | null;
  amount_paid: number;
  currency: string;
  hosted_invoice_url?: string | null;
  number?: string | null;
}) {
  const email = invoice.customer_email;
  if (!email) return;
  if (await alreadySentReceipt(invoice.id)) return;

  const amount = (invoice.amount_paid / 100).toFixed(2);
  const currency = invoice.currency.toUpperCase();

  await sendEmail({
    to: email,
    subject: `Receipt — ${amount} ${currency}`,
    message: `
      <h1>Payment received</h1>
      <p>Amount: <strong>${amount} ${currency}</strong></p>
      ${
        invoice.hosted_invoice_url
          ? `<p><a href="${invoice.hosted_invoice_url}">View invoice</a></p>`
          : ''
      }
      <p>Invoice ${invoice.number ?? invoice.id}</p>
    `
  });

  await markReceiptSent(invoice.id);
}

Hook from invoice.paid (subscriptions) or checkout.session.completed (one-time). Pick one path per checkout flow so you don’t double-send.

Failed payment

async function sendPaymentFailed(invoice: {
  id: string;
  customer_email: string | null;
  amount_due: number;
  currency: string;
  hosted_invoice_url?: string | null;
}) {
  const email = invoice.customer_email;
  if (!email) return;
  if (await alreadySentPaymentFailed(invoice.id)) return;

  const amount = (invoice.amount_due / 100).toFixed(2);
  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} ${invoice.currency.toUpperCase()}</strong>.</p>
      <p><a href="${updateUrl}">Update payment method</a></p>
    `
  });

  await markPaymentFailedSent(invoice.id);
}

One clear email — not a six-step marketing dunning campaign on your transactional domain.

Webhook sketch (Next.js)

// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';

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 });
  }

  switch (event.type) {
    case 'invoice.paid':
      await sendInvoiceReceipt(event.data.object as Stripe.Invoice);
      break;
    case 'invoice.payment_failed':
      await sendPaymentFailed(event.data.object as Stripe.Invoice);
      break;
    default:
      break;
  }

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

Always verify the Stripe signature before trusting the payload. Always gate sends with the idempotency table.

Testing

  1. Use Stripe CLI: stripe listen --forward-to localhost:3000/api/stripe/webhook
  2. Trigger stripe trigger invoice.paid
  3. Confirm one Notify send and one billing_emails row
  4. Re-deliver the same event — second pass should no-op
  5. Repeat for invoice.payment_failed

Iterate on HTML with Notify’s sandbox endpoint first (sandbox vs production), then send a live receipt to yourself after domain verification.

Bounce handling

When volume grows, subscribe to Notify webhooks and stop emailing hard-bounced billing addresses until the customer updates their email. Docs: Webhooks.

Notify plans for context: Free 1,000 emails/mo, Pro $10 / 10,000 (webhooks included), Scale $50 / 100,000 — pricing.

Bottom line

Receipts are webhook hygiene + HTML you own + one transactional API call. Notify keeps that pipe the same as auth email — no marketing suite required. Product home: notify.cx.

Resources