DEV Community

Cover image for I stopped 40% of my payment failures before they happened with one Stripe API call and a cron job
houseofmvps
houseofmvps

Posted on

I stopped 40% of my payment failures before they happened with one Stripe API call and a cron job

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"
Enter fullscreen mode Exit fullscreen mode

You just need to:

  1. Pull all active subscribers
  2. Check card expiry dates
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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.

👉 https://savemrr.co


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)