Smart Retries is one of the best things Stripe gives a small SaaS for free. On new accounts it's on by default, set to up to 8 retries within 2 weeks, and it picks retry times with a model trained on Stripe's whole network. For most soft declines (insufficient funds, a processing hiccup), the right move is to let it work and stay out of the way.

But there's a class of failed payments it can't touch, and the dashboard doesn't make that obvious.

When Stripe doesn't retry at all

Stripe's own docs list the cases. It won't retry a failed subscription payment when:

  • there's no payment method on file,
  • the issuer returned a hard decline code,
  • the card is India-issued (recurring payment rules), or
  • a Connect account was disconnected.

The hard decline codes are incorrect_number, lost_card, pickup_card, stolen_card, revocation_of_authorization, revocation_of_all_authorizations, authentication_required, highest_risk_level and transaction_not_allowed.

Here's the part that catches people: for those failures, retries still get scheduled and attempt_count still goes up on invoice.payment_failed. But nothing actually runs until the customer adds a new payment method. So the invoice looks like it's "in recovery" for two weeks while nothing is happening.

If a customer's card was reported lost on day 0, waiting for the retry schedule just means asking them for a new card two weeks late.

Three buckets instead of one

The fix is to sort every failure by its decline code as it happens:

Bucket Codes What to do
Wait Soft declines: insufficient_funds, processing_error, try_again_later, do_not_honor… Nothing yet. Let Smart Retries run. Emailing now mostly reaches people whose payment would have gone through on its own.
Ask now Stripe's hard declines (lost_card, stolen_card, authentication_required…) Send one personal note today with a link to update the card or confirm the payment.
Review Radar blocks (highest_risk_level) and fraudulent Have a person look before anyone contacts the customer.

expired_card isn't on Stripe's hard-decline list, and the card account updater refreshes many replaced cards on its own. It goes in Wait, but it's worth flagging cards before they expire (more on that below).

The code

A small classifier. The ASK_NOW set comes straight from Stripe's list of codes it won't retry:

// decline-buckets.js // Stripe won't retry these until the customer adds a new payment method. const ASK_NOW = new Set([ 'incorrect_number', 'lost_card', 'pickup_card', 'stolen_card', 'revocation_of_authorization', 'revocation_of_all_authorizations', 'authentication_required', 'transaction_not_allowed', ]); // Radar blocks and suspected fraud: a person should look first. const REVIEW = new Set(['highest_risk_level', 'fraudulent']); function bucket(declineCode) { if (!declineCode) return 'review'; // not an issuer decline: check the error type if (ASK_NOW.has(declineCode)) return 'ask_now'; if (REVIEW.has(declineCode)) return 'review'; return 'wait'; // soft decline: leave it to Smart Retries } module.exports = { bucket }; 

And the webhook handler. payment_intent.payment_failed carries the decline code on last_payment_error:

const express = require('express'); const Stripe = require('stripe'); const { bucket } = require('./decline-buckets'); const stripe = new Stripe(process.env.STRIPE_API_KEY); const app = express(); app.post('/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { return res.status(400).send(`Webhook error: ${err.message}`); } if (event.type === 'payment_intent.payment_failed') { const pi = event.data.object; const err = pi.last_payment_error || {}; const b = bucket(err.decline_code); console.log([pi.customer, pi.id, err.decline_code || err.code, b].join(',')); // ask_now -> queue one personal note today // wait -> do nothing until retries end // review -> send to a person } res.sendStatus(200); }); app.listen(4242); 

Two things to add before you run this for real:

  1. Dedupe by event ID. Stripe can deliver the same event more than once. The stripe-churn-signals repo stores processed IDs in a file so retries are ignored even after a restart.
  2. Filter to subscription payments if you also take one-off payments. Which field links a PaymentIntent to its invoice depends on your API version, so check it against the version your account is pinned to.

Also listen for invoice.payment_failed: when next_payment_attempt is null, the retries are over. That's the moment a "wait" invoice turns into an "ask now" one.

What the day-0 note should say

Keep it short and personal, from a real person rather than noreply@:

  • what happened in plain words ("your bank declined the renewal" / "the card on file was replaced"),
  • one link: the invoice's hosted_invoice_url, where they can pay with a new card on Stripe's own page,
  • no countdown threats.

Someone whose card was lost or stolen usually isn't ignoring you; they just haven't updated every subscription yet. Start with one note, not a sequence.

The ones you can catch before they fail

Card expiry isn't a webhook event. Once a week, list subscriptions whose default card expires within 30 days and send a heads-up to the ones worth the most. The repo's weekly checklist covers this along with other signals webhooks don't send.

Count recovery honestly

If you send notes, measure them properly. A payment that comes back during Smart Retries isn't something your note recovered. Count an invoice as recovered only if it's paid after your note, within a fixed window (we use 30 days), and keep the retry recoveries in a separate column. Otherwise every tool, including your own script, looks better than it is.

References

Disclosure: I work on North Metric, which does this for SaaS on Stripe: it starts where Stripe's retries stop, drafts the day-0 note for hard declines and missing cards, and counts only the money its own notes bring back. Everything above works without it.