Recovering expired_card Declines: Why Retrying Never Works and What Does
expired_card is the decline code where retrying is pure wasted motion. The only path that recovers it is getting a new card on file. Here is the card-update flow I am building into RecoverStack.
I'm Yarin, solo founder of RecoverStack, a flat-fee dunning tool for bootstrapped Stripe SaaS, and usually that means I have something to sell you about failed payments. On expired_card I mostly don't, and I'd rather name that up front than dress it up - there's no clever retry schedule that recovers this code. There's no timing trick, and no window I found that other people missed. In that case, the card is dead and the only thing that brings the money back is a new card on file.
So the thing left for us to get right is speed. Everything below is about compressing the gap between "the charge failed" and "there's a working card on the customer", because for this code that gap basically is the recovery rate. Which makes it the exact inverse of insufficient_funds, where the smartest move is usually to wait.
What expired_card actually tells us
The card's expiration date has passed. The issuer rejects the charge on the expiry check, before anyone looks into the current balance or a fraud score.
Worth knowing which field you're reading it off, because Stripe surfaces expiry at two different levels. expired_card is a top-level error code, where it reads "The card has expired. Check the expiration date or use a different card." It's also a decline_code, the more specific bank-side reason that rides along on a card_declined error. Stripe's own wording on that second case: "When a card is declined, the error returned also includes the decline_code attribute with the reason why the card was declined." My engine keys on decline_code first, and when that's missing it falls back to the charge's outcome.reason, not to the top-level code (I'd have guessed code too, but that's not where the useful reason lives). If you pattern-match on only one of these fields, you're quietly dropping some of your expiry failures into the generic bucket.
Stripe's own catalog is pretty blunt about the remedy. The expired_card entry reads "The card has expired," and under next steps, "The customer needs to use another card." That's the whole entry - there isn't a third option hiding in there.
What makes this code different from most of the others is that it's permanent. An insufficient_funds account usually refills on paydays. A processing_error clears on its own in an hour. An expired card does not un-expire, and retrying the identical card is not a matter of probability, it's simple logic. A good chunk of the codes I mapped building the decline engine sit in this same card-update category, where the customer has to physically do something before any amount of retrying matters.
Why the retry instinct is wasted motion here
Stripe's Smart Retries recommended default is 8 tries within 2 weeks, with the window configurable from 1 week up to 2 months. Smart Retries picks the individual attempt times with an AI model rather than a flat interval, so it isn't just a dumb fixed schedule. What it doesn't do, however, is change strategy by code, so every retryable failure gets the same retry-it treatment.
And it will point that at this code, which is the part I got wrong going in. Stripe does refuse to retry a set of "hard declines", and the same page lists them: incorrect_number, lost_card, pickup_card, stolen_card, revocation_of_authorization, revocation_of_all_authorizations, authentication_required, highest_risk_level, transaction_not_allowed. Nine codes, and expired_card is not one of them.
I assumed the opposite when I started out, and had to go read that reference again, but let me be clear: The default behavior on an expired card is 8 attempts, spread across 2 weeks, against a card that cannot possibly charge in any of them.
The cost of that isn't just the wasted API calls (those are free). The real cost is the time wasted.
Baremetrics' dunning recovery data puts per-email recovery at roughly 13% on day 0, about 11% on day 3 and again on day 7, then falling to around 4% by day 15. Read that against a two-week retry window and the arithmetic gets really unpleasant. If your first real customer-facing email only goes out after the retries have already given up, it lands at the 4% end of that curve instead of the 13% end, and you've spent the good part of the window retrying a card that was never going to charge anyway.
So in my engine, the card-update bucket has an empty retry array. That's the actual entry from src/recovery/decline-engine.service.ts, not pseudo-code:
expired_card: {
retries: [],
emailSequence: 'card_update',
category: 'card_update',
},
retries: [] means the retry scheduler is never called for this code at all. The email service is the only thing that fires.
The card account updater, and where it stops
Before the email path, there's also a path where nobody has to do anything. The card networks push updated card details downstream when a card is reissued, and Stripe's card account updater picks those up and refreshes the saved card with, in their words, minimal to no involvement of the customer. When that works, you recover the payment with zero emails sent and the customer never learns anything happened.
Three honest caveats, one of them about my own product:
-
I can't tell you how often the updater actually fires: I went looking for a coverage rate published by Stripe or by the networks themselves and there isn't one. Percentages do circulate, and as far as I can chase them they trace back to somebody's estimate rather than a primary source, so I'm not going to repeat one and launder it into a fact. The one real figure on Stripe's own page is a single case study: Postmates saw a 1.72% uplift from a card account updater, which Stripe puts at $60 million of revenue across a year. That's an uplift at enormous scale, not a coverage rate, and stretching it into one would be exactly the move I'm trying not to make.
-
RecoverStack does not handle the card updater today. There's no webhook handler for the network-update events, because I haven't built one. What is shipped is a generic payment-method-updated hook that reacts whenever a card changes on the customer, whatever the reason. If an updater-driven change happens to surface through that path, I catch it. If it doesn't, I miss it entirely. That's a real gap on my side, and it's going on the roadmap rather than into a feature list where the word "automatic" would quietly imply I'd already covered it.
-
There's also a consent question here, and honestly it's part of why I'm not rushing the updater. When a card gets reissued, the updater silently moves the subscription onto the new one, which is the whole point of it. But some customers let a card lapse on purpose, either because they wanted to move the charge to a different card, or because quietly letting it die felt mentally easier than clicking "cancel". The updater runs straight over both of those. A "recovery" that charges someone who was trying to leave isn't really a recovery, it's a dispute waiting to happen. The manual update-card flow doesn't have that failure mode, because the customer has to put the new card there themselves, and that's an explicit yes. It doesn't fully settle the build-it-or-not question, since the updater does pull back real money from people who just got a reissued card and would happily keep paying. But it's why "just turn on the updater" isn't the clean win it sounds like.
Which points at the version I'd actually build, and honestly writing this section is what made it click. The problem was never the updater - it's the silence. So, the shape I want isn't "turn on CAU and forget it", it's CAU paired with a plain "your card details were updated" email the moment it fires, ideally with a one-line "didn't expect this? switch cards or cancel here". That keeps the zero-effort recovery for the people who genuinely got a reissued card and want to keep paying, and it hands the person who was quietly leaving a real door instead of a silent charge. Stripe actually frames that silence as a feature, not a bug. Their whole pitch for the updater is that it fixes the card without ever bothering the customer, and for the happy majority who just got a reissued card, they're right that an email is mild noise. My bet is the small annoyance is worth it for the minority it protects from a charge they didn't want, but it is a bet, and the cohort will tell me if I'm wrong. That's the one going on the roadmap. I don't have it built yet, so for now the manual flow below is the whole story.
There's a smaller thing worth logging, though. An expired_card webhook arriving at all on an account with the updater enabled tells you the updater didn't have a new card for that customer, which usually means the issuing bank doesn't participate in the network update service. That's a useful signal about a chunk of your customer base, and it's free.
What actually recovers it: one link that does one thing
The update-card link in the dunning email is a one-time JWT. The token is claimed once and revoked on use, so a forwarded email doesn't hand somebody else a live session. It resolves to a Stripe Checkout session in setup mode:
const session = await stripe.checkout.sessions.create(
{
mode: 'setup',
customer: payload.stripe_customer_id,
payment_method_types: ['card'],
success_url: `${apiBaseUrl}/update-payment/success`,
cancel_url: `${apiBaseUrl}/update-payment/cancelled`,
},
{ stripeAccount: account.stripeAccountId },
);
Two choices in there that I went back and forth on:
-
Setup mode instead of payment mode, because it saves the card without charging it. The customer clicked a button that said "update my card", not one that said "pay now", and charging them on that click is an unpleasant surprise. The actual money moves when the failed invoice gets attempted against the new card.
-
Checkout instead of Stripe's Billing customer portal. The portal is a full subscription-management surface: swap plan, download invoices, cancel. Handing a cancel button to somebody at the exact moment they're already mildly annoyed that their card failed felt like a self-inflicted wound. Setup-mode Checkout is one field and one button. I'll be straight that this is based on UX reasoning and not measurement. I have no A/B data behind it and I'd change my mind if somebody showed me real numbers.
The sequence starts at hour zero
Three emails, at day 0, day 3, and day 7:
const CARD_UPDATE_STEPS = [
{ label: 'immediate', delayDays: 0 },
{ label: 'reminder', delayDays: 3 },
{ label: 'final_notice', delayDays: 7 },
];
The offsets are the first three points of the Baremetrics curve, and the sequence deliberately ends before the 4% tail. There's no silent phase, which is the thing that most separates this code from insufficient_funds, where staying quiet on the first attempt is right - the problem usually fixes itself and an alarming email teaches people to worry about your product. Here, nothing self-resolves, and every hour of silence is an hour of the curve you don't get back.
The escalation lives in the subject line and not in the tone. It runs from "Action required: Update your payment method" to a reminder to "Final notice: Update your payment to avoid cancellation". A card expiring is the most mundane thing that can happen to a customer, and it isn't a moral failing, so the copy stays boring on purpose.
One piece of this I had wrong in my own head for a while, and it's worth writing down because I suspect it's a common assumption. I assumed a card update pauses the sequence, it doesn't. When a new payment method lands, the webhook fires an immediate retry against the failed invoice. The sequence gets cancelled when that retry succeeds and invoice.payment_succeeded comes back. So it's update, then immediate retry, then success, then cancel, Not update -> pause.
That distinction sounds pedantic until the replacement card also fails, which genuinely happens sometimes (somebody grabs a second card from the same closed account, or the same issuer declines again). In that case the customer is still inside the sequence and still gets the day-3 reminder, and that's correct behavior rather than a bug. If the sequence had paused on the update instead, that customer would have gone quiet and churned.
The pre-failure version is the real win
The cleanest version of all of this is never seeing expired_card in the first place. Stripe fires customer.source.expiring ahead of the expiry date, and that event is already wired up on my side. Email the customer before the charge fails and you're talking to somebody who isn't annoyed yet. No failed invoice/dunning sequence/day-0-to-day-7 clock running against you.
I want to be careful about how confidently I say that, though, because I don't actually know if it's true - it's even plausible pre-failure does worse. "Your card expires next month" carries no urgency and is trivially easy to ignore, where "your payment failed" is a real event with a real consequences attached. It's completely possible the annoying version is the one that converts.
That's the single number I most want out of the founding cohort, and it's the one I'll publish first on recoverstack.dev/metrics whichever way it lands.
What I'm still unsure about
- Three emails in seven days: correct, or too many? I have no read on where the annoyance line sits for a code where the customer genuinely does have to act.
- Should the day-7 final notice name cancellation at all? It's the honest stake, and it might also be the phrase that makes somebody cancel instead of update out of annoyance.
- After a card update lands and the retry succeeds, should that customer's next failure be treated differently, given they've proven they respond to email? I lean yes and haven't built it.
- How much the card updater really covers in practice. Until I have accounts connected with it both on and off, "some" is the only honest answer I've got.
If you're running dunning on Stripe today and you have real numbers on any of these, especially the pre-failure versus post-failure question, I would much rather have your data than my reasoning. I'd also rather be corrected on a public post than find out I was wrong after the cohort launches. Reply to any RecoverStack email or hit me at yarin@recoverstack.dev.
Yarin
The full code-by-code reference this sits inside is the pillar, Stripe Decline Codes: What Each One Means and What I Plan to Do About It. The opposite case, where retrying is exactly the right move, is Stripe insufficient_funds: When and How to Retry. For the financial framing of why any of this is worth chasing, see How Much MRR Is Your Stripe Account Losing to Failed Payments?.
About the author. I'm Yarin Goldstein. 24, based in Israel, building RecoverStack solo as my first real business after 5 years of professional dev. RecoverStack is flat-fee dunning for bootstrapped Stripe SaaS, and the founding cohort is free for your first 90 days. The full story is at /about. The waitlist (40% off for life) is at /early-access, or run a free Stripe audit to see your own failure profile first.