Handling Failed Payments and Dunning
A failed payment is not a lost customer — it is usually an expired card, and how you respond decides whether you recover the revenue or churn the account. Part of the Integrating Stripe with Python APIs guide, this page covers dunning for a subscription API: Stripe smart retries, handling the invoice.payment_failed webhook, running a grace period, and the downgrade-or-suspend logic that recovers revenue without nuking access on the first hiccup. The webhook handler below assumes you have already secured your endpoint — if you have not, start with verifying Stripe webhook signatures, because an unverified dunning endpoint lets anyone reactivate a suspended account with a forged POST.
What dunning actually is
Dunning is the structured process of recovering a failed recurring payment before giving up on the subscription. It has two halves. Stripe's side runs smart retries: it re-attempts the charge on a schedule optimized from its network data, sends the configured emails, and quietly swaps in new card numbers through the network card-updater when an issuer reissues a card. Your side runs the access state machine: when a payment fails you move the account to a grace period rather than cutting it off, keep service running while Stripe retries, and only downgrade or suspend once every retry is exhausted.
The distinction that matters commercially is between voluntary and involuntary churn. Voluntary churn is a customer clicking cancel — a product problem. Involuntary churn is a payment failing for a purely technical reason: expired card, insufficient funds on the billing date, a bank declining a foreign transaction, or a 3D Secure challenge nobody answered. That is a plumbing problem, and plumbing problems are fixable in code. Involuntary failures hit 4% to 9% of monthly invoices on a typical API business, and a dunning flow built the way this page describes recovers roughly three quarters of them.
The state machine your code maintains is the heart of it: active → past_due → grace → recovered on success, or → suspended when retries run out. Stripe drives the transitions through webhooks; you enforce what each state means for API access, layered on top of the tier enforcement from designing API pricing tiers.
The revenue actually at stake
Run the numbers before you decide how much engineering this deserves. Take a modest API business: 400 paying accounts on a $49/month plan, so $19,600 of monthly recurring revenue. A 6% involuntary failure rate means 24 invoices fail every month. With no dunning at all — cancel on first decline — you lose $1,176 of MRR per month, and because those customers churn permanently, the annualized damage compounds well past $14,000.
Now add the layers. Smart retries alone recover a large share, because the most common failure is a temporary decline that clears within days. The card-updater catches reissued cards without the customer lifting a finger. A prompt email with a link to the hosted invoice converts most of the remainder. Your grace period rescues the tail: the accounts whose retry succeeds on day five, after a naive implementation would already have returned 402 and triggered a support ticket.
The chart below distributes 100 failed charges across those levers using the proportions this stack typically produces. Seventy-seven recoveries at $49 is $3,773 of MRR preserved for every hundred failures — for maybe two hundred lines of Python. That beats any caching win you will get from caching API responses with Redis, and it is far cheaper than acquiring the replacement customers.
Step 1: Configure Stripe smart retries
Most of the recovery happens in the Stripe dashboard before you write any code. Under Billing settings, enable smart retries so Stripe re-attempts failed charges on a data-optimized schedule rather than a fixed one, and choose explicitly what happens when retries are exhausted: mark the subscription past_due, cancel it, or leave it unpaid. Pick cancel only if you want a clean customer.subscription.deleted signal to drive suspension; pick past_due if you plan to keep chasing manually. Enable the emails too — Stripe's dunning emails are already deliverability-tuned, and you will not beat them with a first attempt from your own domain.
The retry window is the number that shapes your code. Stripe's schedule spreads up to four attempts across roughly eight days, shifting each one to the hour with the highest observed success probability for that card's issuer. Your DUNNING_GRACE_DAYS must outlast that window. Set grace to five days when retries end at day eight and you suspend an account three days before Stripe's final, most likely-to-succeed attempt — the single most expensive off-by-one in subscription billing. Eight days matches the schedule exactly.
Step 2: Handle the payment-failure webhooks
Listen for invoice.payment_failed to start the grace period and invoice.payment_succeeded (or customer.subscription.updated flipping back to active) to clear it. Verify the signature, then drive your state machine. Keep the handler fast and idempotent — return 200 quickly so Stripe does not retry the webhook itself, and make each branch converge on the same row so a replayed event is harmless. The general pattern is covered in depth in building an idempotent webhook receiver; dunning is the case where getting it wrong is most expensive, because a duplicated subscription.deleted can suspend an account that has already paid.
import os
import time
import stripe
from fastapi import FastAPI, Request, Response, HTTPException
app = FastAPI()
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
GRACE_DAYS = int(os.getenv("DUNNING_GRACE_DAYS", "8"))
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request) -> Response:
payload = await request.body()
sig = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except (ValueError, stripe.SignatureVerificationError) as exc:
raise HTTPException(status_code=400, detail=f"Invalid webhook: {exc}")
obj = event["data"]["object"]
customer_id = obj.get("customer")
match event["type"]:
case "invoice.payment_failed":
# Prefer Stripe's own next-attempt time; fall back to a fixed window.
next_attempt = obj.get("next_payment_attempt")
grace_until = (
int(next_attempt) + 86400
if next_attempt
else int(time.time()) + GRACE_DAYS * 86400
)
set_account_state(customer_id, status="past_due", grace_until=grace_until)
notify_billing_problem(customer_id, obj.get("hosted_invoice_url"))
case "invoice.payment_succeeded" | "invoice.paid":
set_account_state(customer_id, status="active", grace_until=None)
case "customer.subscription.deleted":
set_account_state(customer_id, status="suspended", grace_until=None)
case _:
pass # Unhandled types still get a 200 so Stripe stops resending.
return Response(status_code=200)
def set_account_state(customer_id: str, status: str, grace_until: int | None) -> None:
... # Atomic DB update keyed on customer_id; also bust your auth cache.
def notify_billing_problem(customer_id: str, invoice_url: str | None) -> None:
... # Email the customer a link to fix their card.
Two details earn their keep here. Reading next_payment_attempt off the invoice ties your grace deadline to Stripe's actual retry plan instead of a guess, so smart retries and your suspension logic can never disagree. And busting the auth cache inside set_account_state is mandatory: if your API key lookup is cached in Redis for five minutes, a recovered customer keeps getting 402 for five minutes after paying, which generates exactly the support ticket the grace period was meant to avoid.
Step 3: Enforce grace and suspension at the edge
The webhook records state; your request path enforces it. During the grace window the account stays usable, optionally degraded; once grace expires with no recovery, you suspend and return 402. This sits in the same dependency where you already validate the API key, so it costs one dictionary lookup and no extra network hop — never call the Stripe API from a request path to check billing status, because you will add 150 ms of latency to every call and hit Stripe's rate limits under load.
import os
import time
from fastapi import HTTPException
DEGRADE_IN_GRACE = os.getenv("DEGRADE_IN_GRACE", "true").lower() == "true"
BILLING_PORTAL_URL = os.getenv("BILLING_PORTAL_URL", "https://billing.example.com/")
def enforce_billing_state(account: dict) -> None:
"""account = {"status": ..., "grace_until": int|None} loaded with the API key."""
match account["status"]:
case "active":
return
case "past_due" if time.time() < (account.get("grace_until") or 0):
if DEGRADE_IN_GRACE:
account["rate_limit_factor"] = 0.5 # soft pressure, not a wall
return
case "past_due":
raise HTTPException(
status_code=402,
detail=f"Payment past due. Update your card at {BILLING_PORTAL_URL}",
)
case _:
raise HTTPException(
status_code=402,
detail=f"Subscription suspended. Reactivate at {BILLING_PORTAL_URL}",
)
Returning 402 Payment Required with a clear message and a portal link is itself a recovery tool: it converts a quiet retry failure into a visible prompt the customer's own on-call engineer will act on. Halving the rate limit during grace is deliberate too — it keeps production working while making the problem noticeable enough to escalate internally, which is exactly the pressure that gets a finance team to update a card.
| Env var | Default | Production | Why |
|---|---|---|---|
DUNNING_GRACE_DAYS | 8 | 8 | Must outlast Stripe's last retry |
DEGRADE_IN_GRACE | true | true | Visible pressure without an outage |
ENTITLEMENT_CACHE_TTL | 300 | 60 | Shorter TTL bounds stale suspensions |
BILLING_PORTAL_URL | — | set it | The 402 body needs a fix-it link |
Keep ENTITLEMENT_CACHE_TTL at 60 seconds even though the webhook busts the cache explicitly. The bust is the fast path; the TTL is the safety net for the day that invalidation call fails silently, and a one-minute ceiling on wrong answers is cheap — see cache invalidation strategies.
Failure modes that quietly cost money
Out-of-order webhooks. Stripe does not guarantee ordering. A payment_succeeded can land before the payment_failed that preceded it, flipping a healthy account back to past_due. Store the event's created timestamp on the account row and ignore any event older than the one you last applied.
Suspending on the wrong signal. customer.subscription.updated firing with status: past_due is not a cancellation — it is Stripe telling you retries are still running. Suspend only on customer.subscription.deleted, or on invoice.marked_uncollectible if you configured that ending instead.
3D Secure declines. A failure with payment_intent.status == "requires_action" is not a dead card; the customer must complete an authentication challenge. Those need a different email — one that links to the hosted invoice's confirmation page — and they are worth separating in your metrics because their recovery rate is far higher than a genuine decline.
Annual plans. A failed annual invoice is worth twelve times a monthly one and deserves a human email, not just the automated sequence. Branch on obj["amount_due"] above a threshold and route those to a real inbox.
Multiple subscriptions per customer. Keying account state on customer collapses a customer with two subscriptions into one status. Key on the subscription id if you sell more than one plan — the same modelling problem as usage-based vs seat-based pricing.
Invisible dunning. If you cannot answer "how many accounts are in grace right now?", you are flying blind. Emit a structured log line on every transition — see structured logging with structlog — and surface the count in tracking API usage and analytics.
Verify it before real money depends on it
You cannot wait eight real days to find out whether your grace maths is right. Stripe test clocks let you fast-forward a test-mode customer through the entire retry schedule in seconds, which is the only honest way to test this flow end to end — the full workflow lives in testing Stripe integrations with test clocks.
import os
import time
import stripe
stripe.api_key = os.getenv("STRIPE_TEST_SECRET_KEY")
DAY = 86400
clock = stripe.test_helpers.TestClock.create(frozen_time=int(time.time()))
customer = stripe.Customer.create(test_clock=clock.id, email=os.getenv("TEST_EMAIL"))
# Attach the 4000000000000341 card (charges fail on the first attempt), then subscribe.
for day in (1, 3, 5, 8):
stripe.test_helpers.TestClock.advance(clock.id, frozen_time=clock.frozen_time + day * DAY)
# Poll your own DB and assert the expected status at each hop.
Assert three things at each hop: the account status matches the state machine, grace_until sits after Stripe's last retry, and a live request returns 200 on days 1 through 7 and 402 on day 8. Wire those assertions into pytest using testing async FastAPI endpoints with httpx so a refactor cannot quietly break revenue recovery.
Builder verdict
Never cut off access on the first failed payment. Configure smart retries with the card-updater on, run an eight-day grace period driven by invoice.payment_failed and invoice.payment_succeeded, derive the deadline from Stripe's own next_payment_attempt, notify the customer immediately with a link to fix their card, degrade instead of blocking while grace runs, and suspend only when Stripe gives up. The whole system is a small state machine plus one dependency in your request path, and the discipline that makes it reliable is the same as the rest of your Stripe billing setup: verify every signature, keep every handler idempotent, and enforce recorded state at the edge. Two hundred lines here protect more revenue than any feature you ship this quarter.
FAQ
How much revenue does a dunning flow actually recover? On a $49/month plan with 400 accounts and a 6% monthly failure rate, no dunning loses about $1,176 of MRR every month and churns those customers permanently. Smart retries, the card-updater, a prompt email and an eight-day grace window together recover roughly 77% of failures, which is around $3,773 kept per hundred failed charges.
How long should my grace period be?
Eight days, matching Stripe's default smart-retry window. Anything shorter suspends accounts before Stripe's final and statistically strongest attempt, which converts recoverable revenue into churn. Read next_payment_attempt off the failed invoice and add a day, so the deadline tracks Stripe's real schedule instead of a constant you have to maintain.
Should I suspend access on the first failed payment?
No. The first failure is usually an expired card or a temporary decline that Stripe recovers on retry. Move the account to past_due, halve its rate limit so the problem is visible, email a link to the hosted invoice, and suspend only on customer.subscription.deleted. Suspending immediately trades a few dollars of served compute for a whole customer's lifetime value.
Which webhooks do I need, and what does handling them cost?invoice.payment_failed, invoice.payment_succeeded or invoice.paid, and customer.subscription.deleted. At a few hundred subscriptions that is a few dozen events per month — effectively free compute, well inside any free tier from the options in best platforms to host Python APIs for free. The cost is engineering time, not hosting.
What is the migration risk if I change billing providers later?
The state machine is the asset, not the Stripe calls. Keep status, grace_until and the last applied event timestamp in your own database, and let the webhook handler be the only Stripe-aware code. Swapping providers then means rewriting one handler instead of auditing every request path for billing checks.
Related
Same track:
- Integrating Stripe with Python APIs — the full checkout, webhook and subscription setup this page extends.
- Testing Stripe Integrations with Test Clocks — fast-forward a subscription through the retry schedule before shipping.
- Stripe Metered Billing Configuration — how usage records turn into the invoice that can fail.
Other tracks:
- Verifying Stripe Webhook Signatures — secure the endpoint before it can change account state.
- Building an Idempotent Webhook Receiver — make replayed dunning events converge instead of corrupting state.