1. Most churn is not a breakup
A customer who loved the product last week does not wake up and cancel because the card expired. They bounce. Stripe retries. Nobody tells them in a channel they actually open. Two weeks later they are “churned” in your dashboard — and you start a win-back campaign for a person who never chose to leave.
Involuntary churn is the cheapest revenue to recover because the intent is still there. You do not need a discount. You need speed and a pay link.
2. The 15-minute playbook
- Catch the fail in seconds. Add a Stripe endpoint for invoice.payment_failedandinvoice.payment_succeeded. Verify the signature. No unsigned events.
- Email the customer immediately with the amount and the hosted invoice URL. One button. No lecture.
- Alert the founder on Telegram. You should know a $29 miss happened before they tweet about downtime.
- Let Stripe retry. Each retry re-fires the webhook. Map attempt count to the next step. Do not run a cron.
- Mark recovered on success. When the card works, flip the row. Your dashboard should show dollars won back, not a graveyard of failed attempts.
3. Decline codes tell you what to say
expired_card wants a new number. insufficient_funds wants a retry after payday. do_not_honor wants the customer to call their bank. If you send the same email for all three, you leave money on the table. Capture decline_code on the log and show it in analytics.
if (event.type === "invoice.payment_failed") {
const invoice = event.data.object;
await sendRecoveryEmail({
customerEmail: invoice.customer_email,
amount: format(invoice.amount_due, invoice.currency),
hostedInvoiceUrl: invoice.hosted_invoice_url,
});
}
if (event.type === "invoice.payment_succeeded") {
await markRecovered(userId, invoice.customer_email);
}That is the whole engine. Wire it once. Every failed charge after that is a recovery, not a write-off.