Failed payments are the quietest kind of churn. Nobody decided to leave: a card expired, a bank flagged a renewal, a corporate card hit its limit. If nobody tells the customer, Stripe retries a few times, gives up, and the subscription ends.
Stripe will send a basic failed payment email for you (Settings β Billing β Subscriptions and emails). It's worth turning on, but it's one generic email. A short sequence that explains what happened, links straight to a page where the customer can fix it, and stops the moment they pay recovers noticeably more.
This post walks through building that flow on Stripe webhooks in Node.js: the events you need, the timing, the copy, and the edge cases that make dunning emails go wrong.
The events that matter
You need three webhook events:
| Event | What it tells you |
|---|---|
invoice.payment_failed |
A charge attempt on an invoice failed. Fires on the first attempt and on every automatic retry. |
invoice.paid |
The invoice was paid, either by a retry or by the customer. This is your "stop emailing" signal. |
customer.subscription.updated / customer.subscription.deleted
|
The subscription moved to past_due, unpaid or canceled after retries ran out. |
The invoice object on invoice.payment_failed carries most of what an email needs:
-
id: the invoice ID. It stays the same across every retry of that invoice, which makes it the right key for "one recovery flow per failed invoice". -
attempt_count:1on the first failure, then increments with each retry. -
next_payment_attempt: a Unix timestamp for the next automatic retry, ornullwhen Stripe will not retry again. -
hosted_invoice_url: Stripe's hosted page where the customer can pay this invoice with a new card. -
amount_dueandcurrency: for "Your $49.00 payment didn't go through". -
customerandcustomer_email.
Step 1: receive and verify the webhook
Verify the signature against the raw body. Parsing JSON first breaks verification, which is the number one "why does my Stripe webhook fail" issue.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Acknowledge fast, do the work in a queue.
await enqueue("stripe-event", { id: event.id, type: event.type, data: event.data.object });
res.json({ received: true });
}
);
Two things to get right here:
- Return 2xx quickly. Stripe retries webhooks it considers failed (for up to three days in live mode). If you send emails inline and your email provider is slow, Stripe times out, retries, and you send the same email twice.
-
Deduplicate by
event.id. Even with fast responses, Stripe can deliver an event more than once. Store processed event IDs (a unique index is enough) and skip repeats.
Step 2: decide what to send
Here's a sequence that works for most B2B SaaS:
| When | Goal | |
|---|---|---|
First failure (attempt_count === 1) |
"Your payment didn't go through" | Calm, factual, one button to update the card. |
| ~3 days later, still unpaid | "Quick reminder: update your card" | Shorter. Mention what they'd lose (their data, their team's access). |
Final retry (next_payment_attempt === null) |
"Last chance before your subscription is canceled" | Clear deadline, same single button. |
| After cancellation | "Your subscription was canceled" | How to reactivate in one click. No guilt. |
The key detail: drive the flow off the first failure, not every failure. invoice.payment_failed fires on every retry. If you send an email per event, a customer on Stripe's default Smart Retries schedule (Stripe's recommended default is up to 8 tries within 2 weeks) can get eight near-identical emails. Start one flow when attempt_count === 1, and use next_payment_attempt === null to trigger the final notice.
async function handleStripeEvent({ id, type, data }) {
if (await alreadyProcessed(id)) return;
if (type === "invoice.payment_failed") {
const invoice = data;
if (invoice.attempt_count === 1) {
await startDunningFlow({
invoiceId: invoice.id,
email: invoice.customer_email,
amount: formatMoney(invoice.amount_due, invoice.currency),
payUrl: invoice.hosted_invoice_url,
});
}
if (invoice.next_payment_attempt === null) {
await sendFinalNotice(invoice.id);
}
}
if (type === "invoice.paid") {
await stopDunningFlow(data.id); // keyed by invoice ID
}
await markProcessed(id);
}
function formatMoney(amountInCents, currency) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amountInCents / 100);
}
(Zero-decimal currencies like JPY don't divide by 100. If you bill in them, check the currency before formatting.)
startDunningFlow should schedule the follow-ups as delayed jobs (BullMQ, Sidekiq, Cloud Tasks, whatever you use) and re-check the invoice status before each send:
async function sendReminderIfStillUnpaid(invoiceId) {
const invoice = await stripe.invoices.retrieve(invoiceId);
if (invoice.status !== "open") return; // paid, voided or uncollectible: stop
await sendEmail("dunning-reminder", { invoice });
}
The re-check is cheap insurance. Webhooks arrive out of order sometimes, and "we already charged you, please pay" is the fastest way to get a support ticket.
Step 3: where the button should go
Your email needs one button that lets the customer fix the problem in under a minute. You have two good options:
-
hosted_invoice_url: Stripe's hosted invoice page. The customer can pay that exact invoice with a new card, no login needed. Best for the dunning emails themselves. - The Stripe customer portal: lets the customer replace their default payment method for future invoices too. Portal session URLs are short-lived, so don't paste one into an email. Link to a route in your app that creates a session on click:
app.get("/billing/update-card", requireLogin, async (req, res) => {
const session = await stripe.billingPortal.sessions.create({
customer: req.user.stripeCustomerId,
return_url: "https://app.example.com/settings/billing",
});
res.redirect(session.url);
});
Avoid sending people to a generic "log in to your account" page. Every extra step loses recoveries, and a customer whose card failed is not in the mood to hunt through settings.
Step 4: the copy
Dunning emails are transactional in tone even though they feel like marketing. A few things consistently help:
- Lead with the fact, not the apology. "Your $49.00 payment for Acme Pro didn't go through" beats "Uh oh! Something went wrong."
- Say what happens next and when. "We'll try again on November 4. If it still fails, your workspace will be paused on November 12."
- Never blame the customer. Most failures are expired cards or bank fraud filters. "Your bank declined the charge" is accurate and neutral.
- One button. "Update payment method". Not a menu of links.
- Send from a human-looking address, and let replies land in a real inbox. People reply to dunning emails with "I changed jobs, can you invoice my new company?", and those are recoveries too.
A first email can be this short:
Subject: Your Acme payment didn't go through
Hi Sam,
We tried to charge $49.00 for your Acme Pro plan, but your bank declined it. This usually means the card expired or was replaced.
[Update payment method]
We'll retry automatically on November 4. Nothing changes in your account until then.
If you have questions, just reply to this email.
Edge cases that cause double (or wrong) emails
These come up in almost every dunning implementation:
- Two invoices fail at once. A customer with two subscriptions can have two open invoices. Key the flow by invoice ID, not by customer, or the second failure gets swallowed (or the first payment stops both flows).
-
Customer pays between retries. Handled by
invoice.paidplus the status re-check before each send. -
Manual invoices. Invoices with
collection_method: "send_invoice"have no automatic retries, sonext_payment_attemptisnullfrom the start. Decide whether they get the same flow or a different "your invoice is overdue" email. -
One-off invoices. Not every failed invoice belongs to a subscription. Check
billing_reason(subscription_create,subscription_cycle,subscription_update) if you only want to chase renewals. -
API version drift. Stripe's
2025-03-31(basil) API moved some invoice fields, for example the subscription reference now lives underinvoice.parent.subscription_details. Pin your webhook endpoint's API version and read fields that exist in it.
Measuring it
Track two numbers:
- Recovery rate: failed invoices that end up paid Γ· failed invoices. Split it by "paid by automatic retry" and "paid after an email click". Automatic retries recover some without any email, and you want to know what the emails add.
- Time to recovery: days from first failure to payment. If most recoveries happen after email 1, your later emails can be gentler; if they cluster at the final notice, the deadline is doing the work.
If you'd rather not maintain this
Sequenzy, an email platform for SaaS, handles this flow out of the box. You connect Stripe, and failed invoices arrive as a saas.payment_failed event carrying invoiceId, attemptCount, amountDueFormatted, hostedInvoiceUrl and willRetry. A sequence triggered on the first attempt and stopped by the matching saas.purchase handles the "one flow per invoice, stop on payment" logic above without your own queue. The Stripe integration docs show the setup.
Whether you build it yourself or not, the pattern is the same: start on the first failure, key by invoice, re-check before every send, and make the button go straight to a page where the customer can pay.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.