
I'm building Lirova (failed-payment recovery for Stripe subscriptions), and the single most surprising thing I learned is that the standard advice — "a payment failed, just retry it" — is not just weak, it's actively harmful if you do it blindly.
The decline code decides everything. Treating all failures the same is the actual bug.
Retry, but with timing:
insufficient_funds — the card usually works fine a few days later, ideally around a payday cycle. An instant retry just fails again and wastes an attempt.
Often fixes itself:
expired_card — Stripe's Card Account Updater pulls the new card number from the network when the bank reissues. The customer never sees anything. Retrying on a schedule catches these for free.
Never retry:
do_not_honor, lost_stolen, transaction_not_allowed — these are bank-level blocks. Retrying them is worse than pointless: repeated failed attempts on the same card hurt your retry reputation with the card networks, which means your legitimate retries start getting declined harder too. You're spending trust you can't get back.
The counterintuitive lesson: on hard declines, the dunning email matters more than any retry logic. You're not recovering the charge by force — you're asking a human to fix something only they can fix.
So "recovery" isn't "retry more." It's routing: read the decline code, retry what's timing-based, email what's a real block, and never touch the ones that punish you for trying.
I ran 1,000 synthetic failed renewals through my classifier to test this, and the split was stark — a meaningful chunk of "failures" should never be retried at all. Wrote up the full decline-routing logic and the numbers here: [lirova.app]
Curious if anyone here running subscriptions has looked at their own decline-code breakdown — most founders I talk to have never pulled it.