The problem nobody talks about
If you're running a subscription SaaS on Stripe, you probably have a silent revenue leak: involuntary churn.
That's when customers churn not because they want to, but because their card expired, got declined, or hit a spending limit. Stripe retries the charge a few times, sends some dunning emails, and if nothing works — subscription canceled.
The numbers are brutal:
- 20-40% of all subscription churn is involuntary
- Stripe's dunning emails have a ~20% open rate
- Most of those emails land in Promotions or Spam
I kept seeing this across every project I worked on and decided to fix it.
Why SMS beats email for payment recovery
The insight is simple: people don't check email in real-time, but they check texts immediately.
| Channel | Open Rate | Avg Response Time |
|---|---|---|
| 20-25% | Hours to days | |
| SMS | 45% | Under 5 minutes |
When a payment fails and you text the customer "Hey Alex, your $49 payment didn't go through — tap here to update your card", they do it right then. No logging in, no finding the right settings page. One tap → Stripe's Customer Portal → card updated → next retry succeeds.
The technical approach
Here's the architecture I ended up with:
Stripe webhook (invoice.payment_failed)
→ Validate event + extract customer data
→ Check retry attempt number (1st, 2nd, 3rd)
→ Select SMS template based on attempt
→ Generate Stripe Customer Portal session URL
→ Send SMS via provider (Twilio/MessageBird)
→ Log event for analytics
Key decisions:
1. Stripe Restricted API Keys
Don't use your full secret key. Create a restricted key with only the permissions you need:
Customers: Read-
Invoices: Read Customer portal sessions: Write
This is the minimum to read customer info, check the invoice, and generate a portal link.
2. Customer Portal Session URL
This is the magic piece. Stripe's Customer Portal lets your customer update their payment method without any authentication on your side:
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://yourapp.com/account',
});
// session.url → one-tap link for the SMS
The URL is short-lived and scoped to that customer. Perfect for SMS.
3. Escalating SMS templates
Don't send the same message three times. Escalate:
- Attempt 1 (friendly): "Hi {name}! Your ${amount} payment didn't go through. Update your card here: {link}"
- Attempt 2 (reminder, +24h): "Reminder: your ${amount} payment is still pending. Tap to update: {link}"
- Attempt 3 (urgent, +48h): "Last chance — update your card now or your subscription will be canceled: {link}"
Each attempt catches a different segment. Some people just need a nudge, others need urgency.
4. Webhook handling
The critical webhook event is invoice.payment_failed. Key fields:
// Inside your webhook handler
const invoice = event.data.object;
const customerId = invoice.customer;
const amountDue = (invoice.amount_due / 100).toFixed(2);
const attemptCount = invoice.attempt_count;
const subscription = invoice.subscription;
// Only act on subscription invoices, not one-time
if (!subscription) return;
// Get customer phone
const customer = await stripe.customers.retrieve(customerId);
const phone = customer.phone; // or from your own DB
5. Don't spam
Important guardrails:
- Only send for subscription payments, not one-time charges
- Max 3 SMS per failed invoice
- Don't send if the payment was already recovered between retries
- Respect timezone — nobody wants a payment text at 3 AM
- Check
invoice.statusbefore sending — if it'spaid, skip
Results
After implementing this on my own SaaS:
- Recovery rate went from ~18% (email only) → ~42% (email + SMS)
- Average time to card update: under 8 minutes after SMS
- Customer complaints about the texts: zero (they're grateful, actually)
I turned it into a product
After building this for the third time across different projects, I packaged it into RecoverPing (recoverping.com).
It's basically all of the above without writing code:
- Paste your Stripe restricted API key
- We register the webhook automatically
- Customize your SMS templates
- Done in 5 minutes
Plans start at $19/mo. Coming soon: WhatsApp and Telegram channels.
But if you prefer to build it yourself, everything above should get you there. The core idea is simple: reach your customers where they actually look.
TL;DR
- Involuntary churn is 20-40% of all subscription churn
- Stripe dunning emails: 20% open rate. SMS: 45%.
- Use
invoice.payment_failedwebhook + Stripe Customer Portal sessions + SMS - Escalate urgency across retries
- Went from 18% → 42% recovery rate
What's your approach to handling failed payments? Anyone tried other channels like WhatsApp or push notifications? Would love to hear what's working for you.
Top comments (0)