For expired_card failures, retries don't help — the card is dead. The only path to recovery is the customer doing something on their end. Most teams' card-update flows convert at 30–40%; well-built ones convert at 80%+. The difference is mostly about reducing friction, not about clever copy.
If you can get a customer with an expired card to see a card-update link within 48 hours of the failure, ~80% of them update without further intervention. The drop-off isn't motivation — they want the service to keep working — it's friction. Every step that requires the customer to think, type, or context-switch is a 10–20% drop in completion rate.
The compounding-friction reality:
The teams converting at 80%+ are using passwordless deep-links straight to a Stripe-hosted card-update page. No login, no navigation, no decision-making.
Generate a one-time URL via Stripe's Customer Portal API with configuration.features.payment_method_update.enabled = true and minimum other features enabled. The URL goes directly to the payment-method update page. No login, no routing through your app.
# Server-side, on a failed-charge webhook event
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url="https://yourapp.com/billing/thanks",
flow_data={
"type": "payment_method_update",
},
)
update_link = session.url
# email this link to the customer
This link expires (typically in 5 minutes by default — you can extend); each retry generates a fresh one. Stripe handles the actual payment-method UI, including 3DS for cards that need it.
The whole email is roughly:
Subject: Update payment for [CompanyName]
Hi [name],
The card on file for [CompanyName]'s [PlanName] plan ($[amount]/[period]) expired and the last charge didn't go through.
One-click update: Update payment method
The link goes straight to a secure Stripe page — no login required.
If you'd rather pay by ACH or wire transfer, reply to this email and we'll set it up.
— [Name from your team]
That's it. No marketing, no upsell, no "we love having you as a customer." The reader is irritated that the card expired (they didn't notice the new one in the wallet). They want one click.
The customer clicks the link, updates the card, the next retry succeeds. If your dunning automation immediately fires "your payment is back on track!" 30 seconds later, you've spammed them. One success email, optional, after 24h.
For high-value customers ($500+/month), SMS as a day-3 fallback boosts recovery another 5–8 percentage points. The format is identical — link to Stripe-hosted update page, two sentences of context. The SMS just has the link without the surrounding email noise.
SMS via Twilio costs ~$0.01 per message; absolutely worth it on a $500/mo recovery.
Some teams route the card-update link through their own app's billing page, where the customer logs in, sees their plan, then clicks "update payment method," which opens a Stripe modal. This is fine UX in normal contexts. For recovery, every intermediate page is a friction point that drops conversion 5–10%.
If the customer is in a card-recovery flow, they're not browsing your billing settings. Send them straight to the update page.
expired_card codesThe flow above is specific to expired_card, where the customer needs to enter a new card. For other codes:
insufficient_funds — don't send a card-update link (the card is fine; the account isn't). Email lightly: "your last charge didn't go through; we'll retry tomorrow." Most recover from the retry alone.do_not_honor / generic_decline — issuer-side block. Email: "we'd suggest contacting your card issuer to authorize charges from [your company name]." Don't send a card-update link unless the customer asks for one.fraudulent — no automated email. Manual review. The customer might genuinely have flagged the charge as fraud; you don't want an automated "please update your card" email going to someone who already disputed.The free DunningKit CLI generates these per-code email templates as a starting point, with Stripe Customer Portal session-creation code samples in Python, Node, and Go. pip install dunningkit; details on GitHub.