How to Handle Failed Payments with Stripe Dunning (2026)

03 Aug 2026 · Bank K.

Handle failed payments with Stripe dunning: smart retries, dunning emails, hard vs soft declines, and webhook logic to recover lost MRR. A 2026 dev guide.

Failed payments are the quietest way a SaaS loses money. Nobody cancels, nobody complains — a card just expires, a renewal fails, and your MRR drops while you’re not looking. Learning to handle failed payments with Stripe dunning is one of the highest-ROI things a solo founder can do, because the revenue is already yours. You just have to recover it.

The numbers back this up: businesses using Stripe’s recovery tools reclaim an average of around 57% of failed recurring payments, and a properly built dunning flow can push recovery into the 70–80% range versus the 30–40% most apps get by doing nothing. This is a guide to building that flow — the Stripe config, the webhook logic, and the email sequence that actually recovers revenue.

Why Payments Fail (And Why It Matters)

Before you can recover a failed payment, you need to know why it failed, because the response is completely different:

  • Soft declines are temporary — insufficient funds, a bank’s fraud system being cautious, a temporary hold. These are recoverable by retrying. The same card will probably work tomorrow.
  • Hard declines are permanent — card cancelled, account closed, stolen-card flag. Retrying a hard decline is pointless. Worse, hammering it can hurt your processing reputation. The only fix is the customer entering a new card.

The single biggest dunning mistake is treating both the same — blindly retrying a hard decline ten times. Stripe exposes the decline reason, so you can branch on it.

Step 1: Turn On Smart Retries

Stripe’s Smart Retries are the foundation, and you enable them with zero code. In the Dashboard under Billing → Revenue Recovery, switch on Smart Retries. Instead of retrying on a fixed schedule, Stripe uses machine learning trained on card-network signals to pick the moment a retry is most likely to succeed — say, right after a paycheck typically clears.

This alone beats a naive “retry every 3 days” cron, and it’s the highest-leverage ten-minute change you’ll make. You can also configure a custom retry schedule if you want explicit control, but for most indie SaaS, Smart Retries out of the box is the right call.

One thing to respect: card networks cap retry attempts. Mastercard allows 35 and Visa 15 within a rolling 30-day window, and exceeding the limits can trigger fines (up to $15,000 in egregious cases). Smart Retries stays within these limits automatically, which is another reason to use it rather than rolling your own loop.

Step 2: Listen for the Right Webhooks

Smart Retries handles the retrying. Your app handles the communicating and the access control. That happens through webhooks. The events you care about:

  • invoice.payment_failed — a renewal charge failed; dunning begins
  • invoice.payment_succeeded — a retry (or the customer updating their card) recovered the payment
  • customer.subscription.updated — status changed, often to past_due
  • customer.subscription.deleted — Stripe gave up and cancelled after exhausting retries

A minimal handler that branches on decline type:

// app/api/webhooks/stripe/route.js
switch (event.type) {
  case 'invoice.payment_failed': {
    const invoice = event.data.object;
    const charge = invoice.last_finalization_error
      || invoice.charge;

    // Mark the account as past_due (keep access for now)
    await db.user.update({
      where: { stripeCustomerId: invoice.customer },
      data: { billingStatus: 'past_due' },
    });

    // Branch on decline type
    const declineCode = invoice.last_payment_error?.decline_code;
    const hardDeclines = ['lost_card', 'stolen_card', 'pickup_card'];

    if (hardDeclines.includes(declineCode)) {
      // Permanent: stop hoping for a retry, push them to update the card now
      await sendDunningEmail(invoice.customer, 'update_card_urgent');
    } else {
      // Soft: a retry may recover it; gentle first nudge
      await sendDunningEmail(invoice.customer, 'payment_failed_friendly');
    }
    break;
  }

  case 'invoice.payment_succeeded': {
    // Recovered — restore full status
    await db.user.update({
      where: { stripeCustomerId: event.data.object.customer },
      data: { billingStatus: 'active' },
    });
    break;
  }

  case 'customer.subscription.deleted': {
    // Stripe exhausted retries — revoke access at period end per your policy
    await db.user.update({
      where: { stripeCustomerId: event.data.object.customer },
      data: { billingStatus: 'canceled' },
    });
    break;
  }
}

Note the pattern: on failure, you mark the account past_due but keep access. You don’t cut someone off the instant a renewal fails — that’s how you turn a recoverable blip into an angry cancellation. You give them a grace window to fix it. If you haven’t set up webhook verification yet, our Stripe webhooks guide for Next.js covers the signature checking and idempotency you’ll need here.

Step 3: The Dunning Email Sequence

Smart Retries recovers the easy cases automatically. The rest depend on the customer doing something — updating a card — and that means email. The data is clear that a layered sequence dramatically outperforms a single notice; a well-timed 4-email sequence recovers somewhere in the 35–45% range on its own.

A sequence that works:

  1. Within 60 minutes of the failure — friendly, low-alarm. “We couldn’t process your payment. We’ll try again automatically, but you can update your card now to be safe.” Link straight to the Stripe Customer Portal.
  2. Day 3 — slightly firmer, after a retry has likely failed. Restate what happens if it isn’t fixed (access pauses on X date).
  3. Day 5–6 — direct. “Your subscription will be paused soon. Update your card to keep access.”
  4. Final notice — the last call before cancellation, with a clear deadline.

Every email needs a one-click path to update the card. The cleanest way is a Stripe Customer Portal link — Stripe hosts the card-update UI and handles PCI compliance, so you just generate a portal session and redirect.

Keep the tone human, especially in email one. Most failures are innocent — an expired card, a hit limit. People who feel accused cancel; people who feel helped update their card.

Step 4: Prevent Failures Before They Happen

The cheapest failed payment is the one that never happens. Stripe surfaces card expiry via card.exp_month and card.exp_year. Send a heads-up 30 days and 7 days before a card expires: “Your card ending in 4242 expires next month — update it to avoid any interruption.” This proactive nudge alone can prevent 20–30% of future failures, because it fixes the problem before a charge ever fails.

Stripe’s Recovery Analytics dashboard then closes the loop — it shows failed-payment counts, your recovery rate, and recovered revenue over time, so you can see whether your sequence is actually working and tune it.

The Honest Part: This Is a Lot of Plumbing

Read back through this post and notice how much of it is wiring, not strategy: branching on decline codes, keeping past_due access logic in sync, deduplicating webhook retries so you don’t send the same dunning email twice, generating portal links, sequencing emails. None of it is conceptually hard. All of it is fiddly, and all of it sits at the seam between your auth system and your billing system.

This is precisely the layer Beag ships pre-built. It adds auth and Stripe billing to your micro-SaaS with dunning, the customer portal, and payment-state syncing already wired up — so a recovered payment flips the user back to active, an exhausted retry revokes access, and the dunning emails fire on schedule, without you hand-rolling the webhook maze. If you’d rather not spend a weekend building revenue-recovery plumbing, that’s the shortcut.

Bottom Line

Handling failed payments well is one of the few SaaS chores that pays for itself immediately — every dollar you recover is pure retained MRR you’ve already earned. The recipe: turn on Smart Retries, listen for invoice.payment_failed, branch on hard vs soft declines, keep access during a grace window, run a 4-email sequence with one-click card updates, and warn customers before their cards expire. Build it once and it quietly recovers revenue forever — or let Beag hand it to you ready-made.

FAQ

What is dunning in Stripe?

Dunning is the process of recovering failed recurring payments — automatically retrying the charge (Smart Retries) and communicating with the customer (dunning emails) to get them to update their payment method before you cancel their subscription.

How much revenue can a dunning flow recover?

Stripe’s tools recover roughly 57% of failed recurring payments on average. A proper layered dunning flow with retries plus a timed email sequence can reach 70–80%, versus the 30–40% most apps get with no real process.

Should I retry hard declines?

No. Hard declines (lost/stolen card, closed account) are permanent — retrying is futile and can hurt your processing reputation. Stop after a couple of attempts and immediately email the customer to enter a new card. Only retry soft declines like insufficient funds.

When should I revoke access after a failed payment?

Not immediately. Mark the account past_due and keep access during a grace window (often until period end) while retries and emails run. Revoke only after Stripe fires customer.subscription.deleted, per your stated policy.

Do I need to write all the webhook logic myself?

You can, but it’s a lot of fiddly plumbing — decline-code branching, idempotent retries, access syncing, portal links. A service like Beag ships this auth-and-billing layer pre-wired so you skip building it from scratch.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →