Stop Churn Before It Happens: The Highest-ROI Dunning Move Most SaaS Founders Ignore
Most dunning advice starts after a payment fails.
Send an email.
Retry the charge.
Escalate urgency.
All good advice.
But none of it addresses the highest-leverage move in churn recovery:
Prevent the payment from failing in the first place.
The Real Problem: Expired Cards
The single biggest preventable cause of payment failure?
Expired credit cards.
- ~10–12% of all declines come directly from expired cards
- ~30% of cards are reissued annually (new number, same user)
- Customers don’t update their details proactively
- The next billing cycle hits → payment fails → dunning begins
By the time you enter dunning, you’re already reacting.
The Fix Is Stupidly Simple
Stripe already gives you everything you need.
Every PaymentMethod object contains:
const paymentMethod = await stripe.paymentMethods.retrieve('pm_xxx');
// paymentMethod.card.exp_month → 7
// paymentMethod.card.exp_year → 2026
// paymentMethod.card.last4 → "4242"
// paymentMethod.card.brand → "visa"
You just need to:
- Pull all active subscribers
- Check card expiry dates
- Notify users before failure happens
Step 1: Find Expiring Cards
Here’s a working skeleton:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
async function findExpiringCards() {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear();
const target = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const targetMonth = target.getMonth() + 1;
const targetYear = target.getFullYear();
let hasMore = true;
let startingAfter = null;
const expiring = [];
while (hasMore) {
const params = { limit: 100, status: 'active' };
if (startingAfter) params.starting_after = startingAfter;
const subs = await stripe.subscriptions.list(params);
for (const sub of subs.data) {
const pm = sub.default_payment_method;
if (!pm || typeof pm === 'string') continue;
const { exp_month, exp_year, last4, brand } = pm.card;
const expiresThisMonth =
exp_year === currentYear && exp_month === currentMonth;
const expiresNextMonth =
exp_year === targetYear && exp_month === targetMonth;
if (expiresThisMonth || expiresNextMonth) {
expiring.push({
customerId: sub.customer,
subscriptionId: sub.id,
last4,
brand,
exp_month,
exp_year,
email: null // fetch separately
});
}
}
hasMore = subs.has_more;
if (subs.data.length > 0) {
startingAfter = subs.data[subs.data.length - 1].id;
}
}
return expiring;
}
Step 2: Give Them a Frictionless Fix
Don’t send users to login pages.
Don’t create unnecessary steps.
Generate a direct update link using Stripe Customer Portal:
async function getUpdateLink(customerId) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: 'https://yourapp.com/billing',
flow_data: {
type: 'payment_method_update',
},
});
return session.url;
}
This link is:
- Pre-authenticated
- Short-lived
- One-click → update → done
Step 3: The Email (Don’t Overthink It)
Plain text wins.
No templates. No branding. No fluff.
Subject: Your card ending in 4242 expires soon
Hey [name],
Quick heads up — the Visa ending in 4242 that you have on file for [product name] expires next month.
If you update it before your next billing date ([date]), everything stays seamless. Takes about 30 seconds:
[Update your card →]
If you've already received a new card from your bank, it likely has a different number even though the old one still works until expiry.
Thanks,
[your name]
That’s it.
Step 4: Run It Daily
Set it up as:
- Cron job
- Scheduled function
- Background worker
Cadence:
- Day -30 → first email
- Day -7 → reminder (optional)
Why This Works
You’re intercepting failure before it exists.
Compare:
| Approach | Timing | Conversion Difficulty |
|---|---|---|
| Traditional dunning | After failure | High |
| Pre-expiry alert | Before failure | Low |
The user hasn’t experienced a problem yet — so fixing it feels trivial.
Want This Without Building It Yourself?
If you’d rather not wire all of this up manually plus handle edge cases, reminders, and multiple recovery layers that’s exactly why I built SaveMRR.
It doesn’t just catch expiring cards.
It runs multiple recovery mechanisms in the background so you’re not leaking revenue silently every month.
🔍 What it does:
- Pre-expiry detection (like the flow above)
- Smart recovery sequences
- Continuous monitoring of failed payments
- Zero maintenance once set up
Pricing:
$19/month — built for bootstrapped SaaS founders
Free Revenue Scan:
Connects to Stripe (read-only access)
Uses restricted API keys
AES-256 encrypted
Shows exactly how much revenue you’re losing
Takes ~60 seconds.
No credit card required.
What About Stripe Account Updater?
Stripe’s Account Updater helps by:
- Automatically updating card details when banks reissue cards
But:
- It’s not universal
- Depends on issuer + network support
- Misses a non-trivial percentage
Pre-expiry emails cover the gap.
The Bottom Line
If you’re running a SaaS on Stripe and not doing this:
You are choosing to lose recoverable revenue every month.
This is not an optimization.
This is baseline infrastructure.
Final Take
Before you:
- Optimize retry logic
- Add more dunning emails
- Build complex churn flows
Fix the simplest leak:
Catch expiring cards before they fail.
Everything else comes after.
Top comments (0)