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
- Stripe account with webhook endpoint pointed at your app
- Notify API key
- Verified domain for
billing@your-verified-domain.com— domain verification
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
- Use Stripe CLI:
stripe listen --forward-to localhost:3000/api/stripe/webhook - Trigger
stripe trigger invoice.paid - Confirm one Notify send and one
billing_emailsrow - Re-deliver the same event — second pass should no-op
- 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.
Comments
Loading comments…