DunningKit

← All articles · 5 min read · 2026-05

Card-update flows that actually convert

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.

The 80% rule

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.

What a high-converting flow looks like

Step 1: A unique short link in the email

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.

Step 2: Email body that doesn't get in the way

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.

Step 3: Don't trigger a re-send right after

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.

The case for SMS as a fallback

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.

The case against intermediate signup pages

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.

What to do for non-expired_card codes

The flow above is specific to expired_card, where the customer needs to enter a new card. For other codes:

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.

Notify me when v0.1 ships: