[{"data":1,"prerenderedAt":2334},["ShallowReactive",2],{"page-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fhandling-failed-payments-and-dunning\u002F":3,"faq-schema-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fhandling-failed-payments-and-dunning\u002F":2313},{"id":4,"title":5,"body":6,"description":2302,"extension":2303,"meta":2304,"navigation":572,"path":2309,"seo":2310,"stem":2311,"__hash__":2312},"content\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fhandling-failed-payments-and-dunning\u002Findex.md","Handling Failed Payments and Dunning",{"type":7,"value":8,"toc":2290},"minimark",[9,13,33,36,41,53,56,72,216,218,222,225,232,240,334,336,340,361,372,481,483,487,516,1202,1221,1325,1327,1331,1337,1629,1636,1733,1743,1851,1853,1857,1877,1897,1907,1917,1931,1946,1948,1952,1959,2131,2148,2150,2154,2171,2173,2177,2183,2192,2204,2225,2236,2240,2245,2267,2272,2286],[10,11,5],"h1",{"id":12},"handling-failed-payments-and-dunning",[14,15,16,17,22,23,27,28,32],"p",{},"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 ",[18,19,21],"a",{"href":20},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002F","Integrating Stripe with Python APIs"," guide, this page covers dunning for a subscription API: Stripe smart retries, handling the ",[24,25,26],"code",{},"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 ",[18,29,31],{"href":30},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F","verifying Stripe webhook signatures",", because an unverified dunning endpoint lets anyone reactivate a suspended account with a forged POST.",[34,35],"hr",{},[37,38,40],"h2",{"id":39},"what-dunning-actually-is","What dunning actually is",[14,42,43,44,48,49,52],{},"Dunning is the structured process of recovering a failed recurring payment before giving up on the subscription. It has two halves. ",[45,46,47],"strong",{},"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. ",[45,50,51],{},"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.",[14,54,55],{},"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.",[14,57,58,59,62,63,66,67,71],{},"The state machine your code maintains is the heart of it: ",[24,60,61],{},"active → past_due → grace → recovered"," on success, or ",[24,64,65],{},"→ 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 ",[18,68,70],{"href":69},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002F","designing API pricing tiers",".",[73,74,82,83,82,87,82,91,82,110,82,117,82,126,82,134,82,141,82,147,82,151,82,156,82,161,82,165,82,171,82,176,82,181,82,186,82,190,82,194,82,199,82,202,82,206,82,210],"svg",{"viewBox":75,"role":76,"ariaLabelledBy":77,"xmlns":80,"style":81},"0 0 720 250","img",[78,79],"dunfp-a-t","dunfp-a-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[84,85,86],"title",{"id":78},"Dunning state machine",[88,89,90],"desc",{"id":79},"An active subscription moves to past due with a grace deadline on payment failure, then either recovers when an invoice is paid or becomes suspended when Stripe exhausts its retries.",[92,93,94,95,82],"defs",{},"\n    ",[96,97,104,105,94],"marker",{"id":98,"viewBox":99,"refX":100,"refY":101,"markerWidth":102,"markerHeight":102,"orient":103},"dunfp-a-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[106,107],"path",{"d":108,"fill":109},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[111,112],"rect",{"x":113,"y":113,"width":114,"height":115,"fill":116},"0","720","250","var(--c-surface)",[111,118],{"x":119,"y":120,"width":121,"height":122,"rx":123,"fill":116,"stroke":124,"style":125},"20","105","150","50","10","var(--c-blue)","stroke-width:2;",[127,128,133],"text",{"x":129,"y":130,"fill":131,"style":132},"95","135","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","active",[135,136],"line",{"x1":137,"y1":138,"x2":139,"y2":138,"stroke":109,"style":140},"170","130","280","stroke-width:2;marker-end:url(#dunfp-a-arrow);",[127,142,146],{"x":143,"y":144,"fill":109,"style":145},"225","104","text-anchor:middle;font-size:10;font-family:var(--font-sans);","invoice.",[127,148,150],{"x":143,"y":149,"fill":109,"style":145},"116","payment_failed",[111,152],{"x":153,"y":120,"width":154,"height":122,"rx":123,"fill":116,"stroke":155,"style":125},"285","160","var(--c-yellow)",[127,157,160],{"x":158,"y":159,"fill":131,"style":132},"365","127","past_due",[127,162,164],{"x":158,"y":163,"fill":109,"style":145},"143","grace_until set",[135,166],{"x1":167,"y1":168,"x2":169,"y2":170,"stroke":109,"style":140},"445","118","545","78",[127,172,175],{"x":173,"y":174,"fill":109,"style":145},"490","88","invoice.paid",[111,177],{"x":169,"y":178,"width":179,"height":122,"rx":123,"fill":116,"stroke":180,"style":125},"40","155","var(--c-teal)",[127,182,185],{"x":183,"y":184,"fill":131,"style":132},"623","63","recovered",[127,187,189],{"x":183,"y":188,"fill":109,"style":145},"79","grace cleared",[135,191],{"x1":167,"y1":192,"x2":169,"y2":193,"stroke":109,"style":140},"142","182",[127,195,198],{"x":196,"y":197,"fill":109,"style":145},"487","196","retries exhausted",[111,200],{"x":169,"y":137,"width":179,"height":122,"rx":123,"fill":116,"stroke":201,"style":125},"var(--c-coral)",[127,203,205],{"x":183,"y":204,"fill":131,"style":132},"193","suspended",[127,207,209],{"x":183,"y":208,"fill":109,"style":145},"209","402 at the edge",[127,211,215],{"x":212,"y":213,"fill":109,"style":214},"360","240","text-anchor:middle;font-size:11;font-family:var(--font-sans);","Stripe drives the transitions; your code decides what each state allows.",[34,217],{},[37,219,221],{"id":220},"the-revenue-actually-at-stake","The revenue actually at stake",[14,223,224],{},"Run the numbers before you decide how much engineering this deserves. Take a modest API business: 400 paying accounts on a $49\u002Fmonth 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.",[14,226,227,228,231],{},"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 ",[24,229,230],{},"402"," and triggered a support ticket.",[14,233,234,235,239],{},"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 ",[18,236,238],{"href":237},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","caching API responses with Redis",", and it is far cheaper than acquiring the replacement customers.",[73,241,82,246,82,249,82,252,82,255,82,260,82,266,82,271,82,277,82,282,82,286,82,290,82,294,82,298,82,301,82,305,82,309,82,313,82,317,82,321,82,325,82,329],{"viewBox":242,"role":76,"ariaLabelledBy":243,"xmlns":80,"style":81},"0 0 720 290",[244,245],"dunfp-b-t","dunfp-b-d",[84,247,248],{"id":244},"Where 100 failed charges end up",[88,250,251],{"id":245},"A horizontal bar chart splitting 100 failed charges into 38 saved by smart retries, 21 by the card updater, 12 by email, 6 inside the grace window, and 23 unrecovered.",[111,253],{"x":113,"y":113,"width":114,"height":254,"fill":116},"290",[127,256,259],{"x":119,"y":257,"fill":131,"style":258},"30","font-size:13;font-family:var(--font-sans);","Outcome of 100 failed charges, grace period enabled",[135,261],{"x1":115,"y1":262,"x2":115,"y2":263,"stroke":264,"style":265},"48","254","var(--c-border)","stroke-width:1;",[127,267,270],{"x":213,"y":268,"fill":131,"style":269},"74","text-anchor:end;font-size:12;font-family:var(--font-sans);","Stripe smart retries",[111,272],{"x":115,"y":273,"width":274,"height":275,"rx":276,"fill":180},"56","152","26","3",[127,278,281],{"x":279,"y":268,"fill":131,"style":280},"412","font-size:12;font-family:var(--font-sans);","38",[127,283,285],{"x":213,"y":284,"fill":131,"style":269},"114","Automatic card updater",[111,287],{"x":115,"y":288,"width":289,"height":275,"rx":276,"fill":124},"96","84",[127,291,293],{"x":292,"y":284,"fill":131,"style":280},"344","21",[127,295,297],{"x":213,"y":296,"fill":131,"style":269},"154","Email + invoice link",[111,299],{"x":115,"y":300,"width":262,"height":275,"rx":276,"fill":124},"136",[127,302,304],{"x":303,"y":296,"fill":131,"style":280},"308","12",[127,306,308],{"x":213,"y":307,"fill":131,"style":269},"194","Saved inside grace",[111,310],{"x":115,"y":311,"width":312,"height":275,"rx":276,"fill":180},"176","24",[127,314,316],{"x":315,"y":307,"fill":131,"style":280},"284","6",[127,318,320],{"x":213,"y":319,"fill":131,"style":269},"234","Unrecovered",[111,322],{"x":115,"y":323,"width":324,"height":275,"rx":276,"fill":201},"216","92",[127,326,328],{"x":327,"y":319,"fill":131,"style":280},"352","23",[127,330,333],{"x":119,"y":331,"fill":109,"style":332},"278","font-size:11;font-family:var(--font-sans);","77 of 100 recovered = $3,773 of a $49\u002Fmo plan kept per 100 failures",[34,335],{},[37,337,339],{"id":338},"step-1-configure-stripe-smart-retries","Step 1: Configure Stripe smart retries",[14,341,342,343,346,347,349,350,353,354,357,358,360],{},"Most of the recovery happens in the Stripe dashboard before you write any code. Under Billing settings, enable ",[45,344,345],{},"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 ",[24,348,160],{},", cancel it, or leave it unpaid. Pick ",[24,351,352],{},"cancel"," only if you want a clean ",[24,355,356],{},"customer.subscription.deleted"," signal to drive suspension; pick ",[24,359,160],{}," 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.",[14,362,363,364,367,368,371],{},"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 ",[24,365,366],{},"DUNNING_GRACE_DAYS"," must ",[45,369,370],{},"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.",[73,373,82,377,82,380,82,383,82,385,82,388,82,393,82,397,82,400,82,403,82,406,82,412,82,416,82,423,82,428,82,431,82,434,82,437,82,440,82,443,82,446,82,449,82,452,82,455,82,458,82,461,82,465,82,468,82,471,82,474,82,477],{"viewBox":75,"role":76,"ariaLabelledBy":374,"xmlns":80,"style":81},[375,376],"dunfp-c-t","dunfp-c-d",[84,378,379],{"id":375},"Stripe retry schedule against your grace window",[88,381,382],{"id":376},"A timeline showing the first charge failing on day zero, retries on days three, five and seven, and a final action on day eight, with the grace window covering days zero to seven.",[111,384],{"x":113,"y":113,"width":114,"height":115,"fill":116},[111,386],{"x":119,"y":119,"width":115,"height":262,"rx":387,"fill":116,"stroke":124,"style":125},"8",[127,389,26],{"x":390,"y":391,"fill":131,"style":392},"145","42","text-anchor:middle;font-size:12;font-family:var(--font-sans);",[127,394,396],{"x":390,"y":395,"fill":109,"style":145},"58","status = past_due, grace opens",[111,398],{"x":399,"y":119,"width":115,"height":262,"rx":387,"fill":116,"stroke":201,"style":125},"450",[127,401,356],{"x":402,"y":391,"fill":131,"style":214},"575",[127,404,405],{"x":402,"y":395,"fill":109,"style":145},"status = suspended, 402 at edge",[135,407],{"x1":408,"y1":409,"x2":410,"y2":274,"stroke":264,"style":411},"110","68","72","stroke-width:1;stroke-dasharray:4 3;",[135,413],{"x1":414,"y1":409,"x2":415,"y2":274,"stroke":264,"style":411},"560","608",[111,417],{"x":418,"y":419,"width":420,"height":421,"rx":316,"fill":180,"style":422},"70","100","410","60","fill-opacity:0.16;",[127,424,427],{"x":425,"y":426,"fill":109,"style":214},"275","94","grace window — access stays on",[135,429],{"x1":178,"y1":154,"x2":430,"y2":154,"stroke":264,"style":125},"690",[432,433],"circle",{"cx":418,"cy":154,"r":316,"fill":201},[432,435],{"cx":436,"cy":154,"r":316,"fill":124},"220",[432,438],{"cx":439,"cy":154,"r":316,"fill":124},"350",[432,441],{"cx":442,"cy":154,"r":316,"fill":124},"480",[432,444],{"cx":445,"cy":154,"r":316,"fill":201},"610",[127,447,448],{"x":418,"y":193,"fill":131,"style":392},"Day 0",[127,450,451],{"x":436,"y":193,"fill":131,"style":392},"Day 3",[127,453,454],{"x":439,"y":193,"fill":131,"style":392},"Day 5",[127,456,457],{"x":442,"y":193,"fill":131,"style":392},"Day 7",[127,459,460],{"x":445,"y":193,"fill":131,"style":392},"Day 8",[127,462,464],{"x":418,"y":463,"fill":109,"style":214},"200","charge fails",[127,466,467],{"x":436,"y":463,"fill":109,"style":214},"retry 1",[127,469,470],{"x":439,"y":463,"fill":109,"style":214},"retry 2",[127,472,473],{"x":442,"y":463,"fill":109,"style":214},"retry 3",[127,475,476],{"x":445,"y":463,"fill":109,"style":214},"final action",[127,478,480],{"x":212,"y":479,"fill":109,"style":214},"230","Set grace to outlast Stripe's final retry, never shorter.",[34,482],{},[37,484,486],{"id":485},"step-2-handle-the-payment-failure-webhooks","Step 2: Handle the payment-failure webhooks",[14,488,489,490,492,493,496,497,500,501,503,504,506,507,511,512,515],{},"Listen for ",[24,491,26],{}," to start the grace period and ",[24,494,495],{},"invoice.payment_succeeded"," (or ",[24,498,499],{},"customer.subscription.updated"," flipping back to ",[24,502,133],{},") to clear it. Verify the signature, then drive your state machine. Keep the handler fast and idempotent — return ",[24,505,463],{}," 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 ",[18,508,510],{"href":509},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F","building an idempotent webhook receiver","; dunning is the case where getting it wrong is most expensive, because a duplicated ",[24,513,514],{},"subscription.deleted"," can suspend an account that has already paid.",[517,518,523],"pre",{"className":519,"code":520,"language":521,"meta":522,"style":522},"language-python shiki shiki-themes github-light github-dark","import os\nimport time\nimport stripe\nfrom fastapi import FastAPI, Request, Response, HTTPException\n\napp = FastAPI()\nstripe.api_key = os.getenv(\"STRIPE_SECRET_KEY\")\nWEBHOOK_SECRET = os.getenv(\"STRIPE_WEBHOOK_SECRET\")\nGRACE_DAYS = int(os.getenv(\"DUNNING_GRACE_DAYS\", \"8\"))\n\n@app.post(\"\u002Fwebhooks\u002Fstripe\")\nasync def stripe_webhook(request: Request) -> Response:\n    payload = await request.body()\n    sig = request.headers.get(\"stripe-signature\")\n    try:\n        event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)\n    except (ValueError, stripe.SignatureVerificationError) as exc:\n        raise HTTPException(status_code=400, detail=f\"Invalid webhook: {exc}\")\n\n    obj = event[\"data\"][\"object\"]\n    customer_id = obj.get(\"customer\")\n\n    match event[\"type\"]:\n        case \"invoice.payment_failed\":\n            # Prefer Stripe's own next-attempt time; fall back to a fixed window.\n            next_attempt = obj.get(\"next_payment_attempt\")\n            grace_until = (\n                int(next_attempt) + 86400\n                if next_attempt\n                else int(time.time()) + GRACE_DAYS * 86400\n            )\n            set_account_state(customer_id, status=\"past_due\", grace_until=grace_until)\n            notify_billing_problem(customer_id, obj.get(\"hosted_invoice_url\"))\n        case \"invoice.payment_succeeded\" | \"invoice.paid\":\n            set_account_state(customer_id, status=\"active\", grace_until=None)\n        case \"customer.subscription.deleted\":\n            set_account_state(customer_id, status=\"suspended\", grace_until=None)\n        case _:\n            pass  # Unhandled types still get a 200 so Stripe stops resending.\n\n    return Response(status_code=200)\n\n\ndef set_account_state(customer_id: str, status: str, grace_until: int | None) -> None:\n    ...  # Atomic DB update keyed on customer_id; also bust your auth cache.\n\ndef notify_billing_problem(customer_id: str, invoice_url: str | None) -> None:\n    ...  # Email the customer a link to fix their card.\n","python","",[24,524,525,537,545,553,567,574,586,604,621,647,652,667,682,696,712,721,736,757,802,807,830,846,851,865,876,883,898,909,924,933,954,960,984,995,1011,1034,1044,1066,1074,1083,1088,1105,1110,1115,1153,1162,1167,1194],{"__ignoreMap":522},[526,527,529,533],"span",{"class":135,"line":528},1,[526,530,532],{"class":531},"szBVR","import",[526,534,536],{"class":535},"sVt8B"," os\n",[526,538,540,542],{"class":135,"line":539},2,[526,541,532],{"class":531},[526,543,544],{"class":535}," time\n",[526,546,548,550],{"class":135,"line":547},3,[526,549,532],{"class":531},[526,551,552],{"class":535}," stripe\n",[526,554,556,559,562,564],{"class":135,"line":555},4,[526,557,558],{"class":531},"from",[526,560,561],{"class":535}," fastapi ",[526,563,532],{"class":531},[526,565,566],{"class":535}," FastAPI, Request, Response, HTTPException\n",[526,568,570],{"class":135,"line":569},5,[526,571,573],{"emptyLinePlaceholder":572},true,"\n",[526,575,577,580,583],{"class":135,"line":576},6,[526,578,579],{"class":535},"app ",[526,581,582],{"class":531},"=",[526,584,585],{"class":535}," FastAPI()\n",[526,587,589,592,594,597,601],{"class":135,"line":588},7,[526,590,591],{"class":535},"stripe.api_key ",[526,593,582],{"class":531},[526,595,596],{"class":535}," os.getenv(",[526,598,600],{"class":599},"sZZnC","\"STRIPE_SECRET_KEY\"",[526,602,603],{"class":535},")\n",[526,605,607,611,614,616,619],{"class":135,"line":606},8,[526,608,610],{"class":609},"sj4cs","WEBHOOK_SECRET",[526,612,613],{"class":531}," =",[526,615,596],{"class":535},[526,617,618],{"class":599},"\"STRIPE_WEBHOOK_SECRET\"",[526,620,603],{"class":535},[526,622,624,627,629,632,635,638,641,644],{"class":135,"line":623},9,[526,625,626],{"class":609},"GRACE_DAYS",[526,628,613],{"class":531},[526,630,631],{"class":609}," int",[526,633,634],{"class":535},"(os.getenv(",[526,636,637],{"class":599},"\"DUNNING_GRACE_DAYS\"",[526,639,640],{"class":535},", ",[526,642,643],{"class":599},"\"8\"",[526,645,646],{"class":535},"))\n",[526,648,650],{"class":135,"line":649},10,[526,651,573],{"emptyLinePlaceholder":572},[526,653,655,659,662,665],{"class":135,"line":654},11,[526,656,658],{"class":657},"sScJk","@app.post",[526,660,661],{"class":535},"(",[526,663,664],{"class":599},"\"\u002Fwebhooks\u002Fstripe\"",[526,666,603],{"class":535},[526,668,670,673,676,679],{"class":135,"line":669},12,[526,671,672],{"class":531},"async",[526,674,675],{"class":531}," def",[526,677,678],{"class":657}," stripe_webhook",[526,680,681],{"class":535},"(request: Request) -> Response:\n",[526,683,685,688,690,693],{"class":135,"line":684},13,[526,686,687],{"class":535},"    payload ",[526,689,582],{"class":531},[526,691,692],{"class":531}," await",[526,694,695],{"class":535}," request.body()\n",[526,697,699,702,704,707,710],{"class":135,"line":698},14,[526,700,701],{"class":535},"    sig ",[526,703,582],{"class":531},[526,705,706],{"class":535}," request.headers.get(",[526,708,709],{"class":599},"\"stripe-signature\"",[526,711,603],{"class":535},[526,713,715,718],{"class":135,"line":714},15,[526,716,717],{"class":531},"    try",[526,719,720],{"class":535},":\n",[526,722,724,727,729,732,734],{"class":135,"line":723},16,[526,725,726],{"class":535},"        event ",[526,728,582],{"class":531},[526,730,731],{"class":535}," stripe.Webhook.construct_event(payload, sig, ",[526,733,610],{"class":609},[526,735,603],{"class":535},[526,737,739,742,745,748,751,754],{"class":135,"line":738},17,[526,740,741],{"class":531},"    except",[526,743,744],{"class":535}," (",[526,746,747],{"class":609},"ValueError",[526,749,750],{"class":535},", stripe.SignatureVerificationError) ",[526,752,753],{"class":531},"as",[526,755,756],{"class":535}," exc:\n",[526,758,760,763,766,770,772,775,777,780,782,785,788,791,794,797,800],{"class":135,"line":759},18,[526,761,762],{"class":531},"        raise",[526,764,765],{"class":535}," HTTPException(",[526,767,769],{"class":768},"s4XuR","status_code",[526,771,582],{"class":531},[526,773,774],{"class":609},"400",[526,776,640],{"class":535},[526,778,779],{"class":768},"detail",[526,781,582],{"class":531},[526,783,784],{"class":531},"f",[526,786,787],{"class":599},"\"Invalid webhook: ",[526,789,790],{"class":609},"{",[526,792,793],{"class":535},"exc",[526,795,796],{"class":609},"}",[526,798,799],{"class":599},"\"",[526,801,603],{"class":535},[526,803,805],{"class":135,"line":804},19,[526,806,573],{"emptyLinePlaceholder":572},[526,808,810,813,815,818,821,824,827],{"class":135,"line":809},20,[526,811,812],{"class":535},"    obj ",[526,814,582],{"class":531},[526,816,817],{"class":535}," event[",[526,819,820],{"class":599},"\"data\"",[526,822,823],{"class":535},"][",[526,825,826],{"class":599},"\"object\"",[526,828,829],{"class":535},"]\n",[526,831,833,836,838,841,844],{"class":135,"line":832},21,[526,834,835],{"class":535},"    customer_id ",[526,837,582],{"class":531},[526,839,840],{"class":535}," obj.get(",[526,842,843],{"class":599},"\"customer\"",[526,845,603],{"class":535},[526,847,849],{"class":135,"line":848},22,[526,850,573],{"emptyLinePlaceholder":572},[526,852,854,857,859,862],{"class":135,"line":853},23,[526,855,856],{"class":531},"    match",[526,858,817],{"class":535},[526,860,861],{"class":599},"\"type\"",[526,863,864],{"class":535},"]:\n",[526,866,868,871,874],{"class":135,"line":867},24,[526,869,870],{"class":531},"        case",[526,872,873],{"class":599}," \"invoice.payment_failed\"",[526,875,720],{"class":535},[526,877,879],{"class":135,"line":878},25,[526,880,882],{"class":881},"sJ8bj","            # Prefer Stripe's own next-attempt time; fall back to a fixed window.\n",[526,884,886,889,891,893,896],{"class":135,"line":885},26,[526,887,888],{"class":535},"            next_attempt ",[526,890,582],{"class":531},[526,892,840],{"class":535},[526,894,895],{"class":599},"\"next_payment_attempt\"",[526,897,603],{"class":535},[526,899,901,904,906],{"class":135,"line":900},27,[526,902,903],{"class":535},"            grace_until ",[526,905,582],{"class":531},[526,907,908],{"class":535}," (\n",[526,910,912,915,918,921],{"class":135,"line":911},28,[526,913,914],{"class":609},"                int",[526,916,917],{"class":535},"(next_attempt) ",[526,919,920],{"class":531},"+",[526,922,923],{"class":609}," 86400\n",[526,925,927,930],{"class":135,"line":926},29,[526,928,929],{"class":531},"                if",[526,931,932],{"class":535}," next_attempt\n",[526,934,936,939,941,944,946,949,952],{"class":135,"line":935},30,[526,937,938],{"class":531},"                else",[526,940,631],{"class":609},[526,942,943],{"class":535},"(time.time()) ",[526,945,920],{"class":531},[526,947,948],{"class":609}," GRACE_DAYS",[526,950,951],{"class":531}," *",[526,953,923],{"class":609},[526,955,957],{"class":135,"line":956},31,[526,958,959],{"class":535},"            )\n",[526,961,963,966,969,971,974,976,979,981],{"class":135,"line":962},32,[526,964,965],{"class":535},"            set_account_state(customer_id, ",[526,967,968],{"class":768},"status",[526,970,582],{"class":531},[526,972,973],{"class":599},"\"past_due\"",[526,975,640],{"class":535},[526,977,978],{"class":768},"grace_until",[526,980,582],{"class":531},[526,982,983],{"class":535},"grace_until)\n",[526,985,987,990,993],{"class":135,"line":986},33,[526,988,989],{"class":535},"            notify_billing_problem(customer_id, obj.get(",[526,991,992],{"class":599},"\"hosted_invoice_url\"",[526,994,646],{"class":535},[526,996,998,1000,1003,1006,1009],{"class":135,"line":997},34,[526,999,870],{"class":531},[526,1001,1002],{"class":599}," \"invoice.payment_succeeded\"",[526,1004,1005],{"class":531}," |",[526,1007,1008],{"class":599}," \"invoice.paid\"",[526,1010,720],{"class":535},[526,1012,1014,1016,1018,1020,1023,1025,1027,1029,1032],{"class":135,"line":1013},35,[526,1015,965],{"class":535},[526,1017,968],{"class":768},[526,1019,582],{"class":531},[526,1021,1022],{"class":599},"\"active\"",[526,1024,640],{"class":535},[526,1026,978],{"class":768},[526,1028,582],{"class":531},[526,1030,1031],{"class":609},"None",[526,1033,603],{"class":535},[526,1035,1037,1039,1042],{"class":135,"line":1036},36,[526,1038,870],{"class":531},[526,1040,1041],{"class":599}," \"customer.subscription.deleted\"",[526,1043,720],{"class":535},[526,1045,1047,1049,1051,1053,1056,1058,1060,1062,1064],{"class":135,"line":1046},37,[526,1048,965],{"class":535},[526,1050,968],{"class":768},[526,1052,582],{"class":531},[526,1054,1055],{"class":599},"\"suspended\"",[526,1057,640],{"class":535},[526,1059,978],{"class":768},[526,1061,582],{"class":531},[526,1063,1031],{"class":609},[526,1065,603],{"class":535},[526,1067,1069,1071],{"class":135,"line":1068},38,[526,1070,870],{"class":531},[526,1072,1073],{"class":535}," _:\n",[526,1075,1077,1080],{"class":135,"line":1076},39,[526,1078,1079],{"class":531},"            pass",[526,1081,1082],{"class":881},"  # Unhandled types still get a 200 so Stripe stops resending.\n",[526,1084,1086],{"class":135,"line":1085},40,[526,1087,573],{"emptyLinePlaceholder":572},[526,1089,1091,1094,1097,1099,1101,1103],{"class":135,"line":1090},41,[526,1092,1093],{"class":531},"    return",[526,1095,1096],{"class":535}," Response(",[526,1098,769],{"class":768},[526,1100,582],{"class":531},[526,1102,463],{"class":609},[526,1104,603],{"class":535},[526,1106,1108],{"class":135,"line":1107},42,[526,1109,573],{"emptyLinePlaceholder":572},[526,1111,1113],{"class":135,"line":1112},43,[526,1114,573],{"emptyLinePlaceholder":572},[526,1116,1118,1121,1124,1127,1130,1133,1135,1138,1141,1143,1146,1149,1151],{"class":135,"line":1117},44,[526,1119,1120],{"class":531},"def",[526,1122,1123],{"class":657}," set_account_state",[526,1125,1126],{"class":535},"(customer_id: ",[526,1128,1129],{"class":609},"str",[526,1131,1132],{"class":535},", status: ",[526,1134,1129],{"class":609},[526,1136,1137],{"class":535},", grace_until: ",[526,1139,1140],{"class":609},"int",[526,1142,1005],{"class":531},[526,1144,1145],{"class":609}," None",[526,1147,1148],{"class":535},") -> ",[526,1150,1031],{"class":609},[526,1152,720],{"class":535},[526,1154,1156,1159],{"class":135,"line":1155},45,[526,1157,1158],{"class":609},"    ...",[526,1160,1161],{"class":881},"  # Atomic DB update keyed on customer_id; also bust your auth cache.\n",[526,1163,1165],{"class":135,"line":1164},46,[526,1166,573],{"emptyLinePlaceholder":572},[526,1168,1170,1172,1175,1177,1179,1182,1184,1186,1188,1190,1192],{"class":135,"line":1169},47,[526,1171,1120],{"class":531},[526,1173,1174],{"class":657}," notify_billing_problem",[526,1176,1126],{"class":535},[526,1178,1129],{"class":609},[526,1180,1181],{"class":535},", invoice_url: ",[526,1183,1129],{"class":609},[526,1185,1005],{"class":531},[526,1187,1145],{"class":609},[526,1189,1148],{"class":535},[526,1191,1031],{"class":609},[526,1193,720],{"class":535},[526,1195,1197,1199],{"class":135,"line":1196},48,[526,1198,1158],{"class":609},[526,1200,1201],{"class":881},"  # Email the customer a link to fix their card.\n",[14,1203,1204,1205,1208,1209,1213,1214,1217,1218,1220],{},"Two details earn their keep here. Reading ",[24,1206,1207],{},"next_payment_attempt"," off the invoice ties your grace deadline to Stripe's ",[1210,1211,1212],"em",{},"actual"," retry plan instead of a guess, so smart retries and your suspension logic can never disagree. And busting the auth cache inside ",[24,1215,1216],{},"set_account_state"," is mandatory: if your API key lookup is cached in Redis for five minutes, a recovered customer keeps getting ",[24,1219,230],{}," for five minutes after paying, which generates exactly the support ticket the grace period was meant to avoid.",[73,1222,82,1227,82,1230,82,1233,82,1240,82,1243,82,1246,82,1251,82,1254,82,1258,82,1262,82,1266,82,1268,82,1272,82,1276,82,1278,82,1280,82,1282,82,1286,82,1290,82,1295,82,1298,82,1302,82,1306,82,1311,82,1314,82,1318,82,1322],{"viewBox":1223,"role":76,"ariaLabelledBy":1224,"xmlns":80,"style":81},"0 0 720 300",[1225,1226],"dunfp-d-t","dunfp-d-d",[84,1228,1229],{"id":1225},"Webhook handling sequence",[88,1231,1232],{"id":1226},"Stripe posts the payment failure event to the FastAPI handler, which updates Postgres, deletes the cached entitlement, and returns 200 within two seconds.",[92,1234,94,1235,82],{},[96,1236,104,1238,94],{"id":1237,"viewBox":99,"refX":100,"refY":101,"markerWidth":102,"markerHeight":102,"orient":103},"dunfp-d-arrow",[106,1239],{"d":108,"fill":109},[111,1241],{"x":113,"y":113,"width":114,"height":1242,"fill":116},"300",[111,1244],{"x":257,"y":119,"width":1245,"height":281,"rx":387,"fill":116,"stroke":201,"style":125},"120",[127,1247,1250],{"x":1248,"y":1249,"fill":131,"style":392},"90","44","Stripe",[111,1252],{"x":1253,"y":119,"width":154,"height":281,"rx":387,"fill":116,"stroke":124,"style":125},"190",[127,1255,1257],{"x":1256,"y":1249,"fill":131,"style":392},"270","FastAPI handler",[111,1259],{"x":1260,"y":119,"width":1261,"height":281,"rx":387,"fill":116,"stroke":180,"style":125},"390","140",[127,1263,1265],{"x":1264,"y":1249,"fill":131,"style":392},"460","Postgres",[111,1267],{"x":414,"y":119,"width":1261,"height":281,"rx":387,"fill":116,"stroke":155,"style":125},[127,1269,1271],{"x":1270,"y":1249,"fill":131,"style":392},"630","Redis cache",[135,1273],{"x1":1248,"y1":395,"x2":1248,"y2":1274,"stroke":264,"style":1275},"212","stroke-width:1;stroke-dasharray:5 4;",[135,1277],{"x1":1256,"y1":395,"x2":1256,"y2":1274,"stroke":264,"style":1275},[135,1279],{"x1":1264,"y1":395,"x2":1264,"y2":1274,"stroke":264,"style":1275},[135,1281],{"x1":1270,"y1":395,"x2":1270,"y2":1274,"stroke":264,"style":1275},[127,1283,1285],{"x":1284,"y":289,"fill":109,"style":145},"179","POST invoice.payment_failed",[135,1287],{"x1":1248,"y1":324,"x2":1288,"y2":324,"stroke":109,"style":1289},"268","stroke-width:2;marker-end:url(#dunfp-d-arrow);",[127,1291,1294],{"x":1292,"y":1293,"fill":109,"style":145},"364","119","UPDATE status, grace_until",[135,1296],{"x1":1256,"y1":159,"x2":1297,"y2":159,"stroke":109,"style":1289},"458",[127,1299,1301],{"x":1300,"y":296,"fill":109,"style":145},"449","DEL entitlement key",[135,1303],{"x1":1256,"y1":1304,"x2":1305,"y2":1304,"stroke":109,"style":1289},"162","628",[127,1307,1310],{"x":1308,"y":1309,"fill":109,"style":145},"180","189","200 OK in under 2s",[135,1312],{"x1":1288,"y1":1313,"x2":324,"y2":1313,"stroke":109,"style":1289},"197",[111,1315],{"x":137,"y":1316,"width":1317,"height":273,"rx":387,"fill":116,"stroke":155,"style":125},"228","500",[127,1319,1321],{"x":1320,"y":115,"fill":131,"style":214},"420","Idempotent by design: a replayed event lands on the same row.",[127,1323,1324],{"x":1320,"y":1288,"fill":109,"style":214},"Stripe retries delivery for up to 3 days without a 2xx.",[34,1326],{},[37,1328,1330],{"id":1329},"step-3-enforce-grace-and-suspension-at-the-edge","Step 3: Enforce grace and suspension at the edge",[14,1332,1333,1334,1336],{},"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 ",[24,1335,230],{},". 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.",[517,1338,1340],{"className":519,"code":1339,"language":521,"meta":522,"style":522},"import os\nimport time\nfrom fastapi import HTTPException\n\nDEGRADE_IN_GRACE = os.getenv(\"DEGRADE_IN_GRACE\", \"true\").lower() == \"true\"\nBILLING_PORTAL_URL = os.getenv(\"BILLING_PORTAL_URL\", \"https:\u002F\u002Fbilling.example.com\u002F\")\n\ndef enforce_billing_state(account: dict) -> None:\n    \"\"\"account = {\"status\": ..., \"grace_until\": int|None} loaded with the API key.\"\"\"\n    match account[\"status\"]:\n        case \"active\":\n            return\n        case \"past_due\" if time.time() \u003C (account.get(\"grace_until\") or 0):\n            if DEGRADE_IN_GRACE:\n                account[\"rate_limit_factor\"] = 0.5  # soft pressure, not a wall\n            return\n        case \"past_due\":\n            raise HTTPException(\n                status_code=402,\n                detail=f\"Payment past due. Update your card at {BILLING_PORTAL_URL}\",\n            )\n        case _:\n            raise HTTPException(\n                status_code=402,\n                detail=f\"Subscription suspended. Reactivate at {BILLING_PORTAL_URL}\",\n            )\n",[24,1341,1342,1348,1354,1365,1369,1395,1414,1418,1437,1442,1454,1463,1468,1502,1512,1531,1535,1543,1551,1563,1582,1586,1592,1598,1608,1625],{"__ignoreMap":522},[526,1343,1344,1346],{"class":135,"line":528},[526,1345,532],{"class":531},[526,1347,536],{"class":535},[526,1349,1350,1352],{"class":135,"line":539},[526,1351,532],{"class":531},[526,1353,544],{"class":535},[526,1355,1356,1358,1360,1362],{"class":135,"line":547},[526,1357,558],{"class":531},[526,1359,561],{"class":535},[526,1361,532],{"class":531},[526,1363,1364],{"class":535}," HTTPException\n",[526,1366,1367],{"class":135,"line":555},[526,1368,573],{"emptyLinePlaceholder":572},[526,1370,1371,1374,1376,1378,1381,1383,1386,1389,1392],{"class":135,"line":569},[526,1372,1373],{"class":609},"DEGRADE_IN_GRACE",[526,1375,613],{"class":531},[526,1377,596],{"class":535},[526,1379,1380],{"class":599},"\"DEGRADE_IN_GRACE\"",[526,1382,640],{"class":535},[526,1384,1385],{"class":599},"\"true\"",[526,1387,1388],{"class":535},").lower() ",[526,1390,1391],{"class":531},"==",[526,1393,1394],{"class":599}," \"true\"\n",[526,1396,1397,1400,1402,1404,1407,1409,1412],{"class":135,"line":576},[526,1398,1399],{"class":609},"BILLING_PORTAL_URL",[526,1401,613],{"class":531},[526,1403,596],{"class":535},[526,1405,1406],{"class":599},"\"BILLING_PORTAL_URL\"",[526,1408,640],{"class":535},[526,1410,1411],{"class":599},"\"https:\u002F\u002Fbilling.example.com\u002F\"",[526,1413,603],{"class":535},[526,1415,1416],{"class":135,"line":588},[526,1417,573],{"emptyLinePlaceholder":572},[526,1419,1420,1422,1425,1428,1431,1433,1435],{"class":135,"line":606},[526,1421,1120],{"class":531},[526,1423,1424],{"class":657}," enforce_billing_state",[526,1426,1427],{"class":535},"(account: ",[526,1429,1430],{"class":609},"dict",[526,1432,1148],{"class":535},[526,1434,1031],{"class":609},[526,1436,720],{"class":535},[526,1438,1439],{"class":135,"line":623},[526,1440,1441],{"class":599},"    \"\"\"account = {\"status\": ..., \"grace_until\": int|None} loaded with the API key.\"\"\"\n",[526,1443,1444,1446,1449,1452],{"class":135,"line":649},[526,1445,856],{"class":531},[526,1447,1448],{"class":535}," account[",[526,1450,1451],{"class":599},"\"status\"",[526,1453,864],{"class":535},[526,1455,1456,1458,1461],{"class":135,"line":654},[526,1457,870],{"class":531},[526,1459,1460],{"class":599}," \"active\"",[526,1462,720],{"class":535},[526,1464,1465],{"class":135,"line":669},[526,1466,1467],{"class":531},"            return\n",[526,1469,1470,1472,1475,1478,1481,1484,1487,1490,1493,1496,1499],{"class":135,"line":684},[526,1471,870],{"class":531},[526,1473,1474],{"class":599}," \"past_due\"",[526,1476,1477],{"class":531}," if",[526,1479,1480],{"class":535}," time.time() ",[526,1482,1483],{"class":531},"\u003C",[526,1485,1486],{"class":535}," (account.get(",[526,1488,1489],{"class":599},"\"grace_until\"",[526,1491,1492],{"class":535},") ",[526,1494,1495],{"class":531},"or",[526,1497,1498],{"class":609}," 0",[526,1500,1501],{"class":535},"):\n",[526,1503,1504,1507,1510],{"class":135,"line":698},[526,1505,1506],{"class":531},"            if",[526,1508,1509],{"class":609}," DEGRADE_IN_GRACE",[526,1511,720],{"class":535},[526,1513,1514,1517,1520,1523,1525,1528],{"class":135,"line":714},[526,1515,1516],{"class":535},"                account[",[526,1518,1519],{"class":599},"\"rate_limit_factor\"",[526,1521,1522],{"class":535},"] ",[526,1524,582],{"class":531},[526,1526,1527],{"class":609}," 0.5",[526,1529,1530],{"class":881},"  # soft pressure, not a wall\n",[526,1532,1533],{"class":135,"line":723},[526,1534,1467],{"class":531},[526,1536,1537,1539,1541],{"class":135,"line":738},[526,1538,870],{"class":531},[526,1540,1474],{"class":599},[526,1542,720],{"class":535},[526,1544,1545,1548],{"class":135,"line":759},[526,1546,1547],{"class":531},"            raise",[526,1549,1550],{"class":535}," HTTPException(\n",[526,1552,1553,1556,1558,1560],{"class":135,"line":804},[526,1554,1555],{"class":768},"                status_code",[526,1557,582],{"class":531},[526,1559,230],{"class":609},[526,1561,1562],{"class":535},",\n",[526,1564,1565,1568,1570,1572,1575,1578,1580],{"class":135,"line":809},[526,1566,1567],{"class":768},"                detail",[526,1569,582],{"class":531},[526,1571,784],{"class":531},[526,1573,1574],{"class":599},"\"Payment past due. Update your card at ",[526,1576,1577],{"class":609},"{BILLING_PORTAL_URL}",[526,1579,799],{"class":599},[526,1581,1562],{"class":535},[526,1583,1584],{"class":135,"line":832},[526,1585,959],{"class":535},[526,1587,1588,1590],{"class":135,"line":848},[526,1589,870],{"class":531},[526,1591,1073],{"class":535},[526,1593,1594,1596],{"class":135,"line":853},[526,1595,1547],{"class":531},[526,1597,1550],{"class":535},[526,1599,1600,1602,1604,1606],{"class":135,"line":867},[526,1601,1555],{"class":768},[526,1603,582],{"class":531},[526,1605,230],{"class":609},[526,1607,1562],{"class":535},[526,1609,1610,1612,1614,1616,1619,1621,1623],{"class":135,"line":878},[526,1611,1567],{"class":768},[526,1613,582],{"class":531},[526,1615,784],{"class":531},[526,1617,1618],{"class":599},"\"Subscription suspended. Reactivate at ",[526,1620,1577],{"class":609},[526,1622,799],{"class":599},[526,1624,1562],{"class":535},[526,1626,1627],{"class":135,"line":885},[526,1628,959],{"class":535},[14,1630,1631,1632,1635],{},"Returning ",[24,1633,1634],{},"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.",[1637,1638,1639,1658],"table",{},[1640,1641,1642],"thead",{},[1643,1644,1645,1649,1652,1655],"tr",{},[1646,1647,1648],"th",{},"Env var",[1646,1650,1651],{},"Default",[1646,1653,1654],{},"Production",[1646,1656,1657],{},"Why",[1659,1660,1661,1679,1697,1715],"tbody",{},[1643,1662,1663,1668,1672,1676],{},[1664,1665,1666],"td",{},[24,1667,366],{},[1664,1669,1670],{},[24,1671,387],{},[1664,1673,1674],{},[24,1675,387],{},[1664,1677,1678],{},"Must outlast Stripe's last retry",[1643,1680,1681,1685,1690,1694],{},[1664,1682,1683],{},[24,1684,1373],{},[1664,1686,1687],{},[24,1688,1689],{},"true",[1664,1691,1692],{},[24,1693,1689],{},[1664,1695,1696],{},"Visible pressure without an outage",[1643,1698,1699,1704,1708,1712],{},[1664,1700,1701],{},[24,1702,1703],{},"ENTITLEMENT_CACHE_TTL",[1664,1705,1706],{},[24,1707,1242],{},[1664,1709,1710],{},[24,1711,421],{},[1664,1713,1714],{},"Shorter TTL bounds stale suspensions",[1643,1716,1717,1721,1724,1727],{},[1664,1718,1719],{},[24,1720,1399],{},[1664,1722,1723],{},"—",[1664,1725,1726],{},"set it",[1664,1728,1729,1730,1732],{},"The ",[24,1731,230],{}," body needs a fix-it link",[14,1734,1735,1736,1738,1739,71],{},"Keep ",[24,1737,1703],{}," 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 ",[18,1740,1742],{"href":1741},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fcache-invalidation-strategies\u002F","cache invalidation strategies",[73,1744,82,1748,82,1751,82,1754,82,1761,82,1763,82,1768,82,1771,82,1774,82,1777,82,1781,82,1785,82,1790,82,1794,82,1797,82,1800,82,1803,82,1806,82,1809,82,1814,82,1819,82,1823,82,1828,82,1833,82,1837,82,1839,82,1842,82,1844,82,1848],{"viewBox":1223,"role":76,"ariaLabelledBy":1745,"xmlns":80,"style":81},[1746,1747],"dunfp-e-t","dunfp-e-d",[84,1749,1750],{"id":1746},"Request admission decision tree",[88,1752,1753],{"id":1747},"A decision tree: an authenticated request is served fully when the account is active, served with a degraded quota while inside the grace deadline, and rejected with 402 otherwise.",[92,1755,94,1756,82],{},[96,1757,104,1759,94],{"id":1758,"viewBox":99,"refX":100,"refY":101,"markerWidth":102,"markerHeight":102,"orient":103},"dunfp-e-arrow",[106,1760],{"d":108,"fill":109},[111,1762],{"x":113,"y":113,"width":114,"height":1242,"fill":116},[111,1764],{"x":1765,"y":1766,"width":1767,"height":391,"rx":387,"fill":116,"stroke":124,"style":125},"255","18","210",[127,1769,1770],{"x":212,"y":1249,"fill":131,"style":392},"API request, key valid",[135,1772],{"x1":212,"y1":421,"x2":212,"y2":170,"stroke":109,"style":1773},"stroke-width:2;marker-end:url(#dunfp-e-arrow);",[111,1775],{"x":115,"y":1776,"width":436,"height":1249,"rx":387,"fill":116,"stroke":155,"style":125},"80",[127,1778,1780],{"x":212,"y":1779,"fill":131,"style":392},"107","status == 'active'?",[135,1782],{"x1":1242,"y1":1783,"x2":1304,"y2":1784,"stroke":109,"style":1773},"124","156",[127,1786,1789],{"x":1787,"y":1788,"fill":109,"style":145},"215","132","yes",[111,1791],{"x":421,"y":1792,"width":1253,"height":1793,"rx":387,"fill":116,"stroke":180,"style":125},"158","46",[127,1795,1796],{"x":179,"y":1308,"fill":131,"style":392},"200 — full access",[127,1798,1799],{"x":179,"y":197,"fill":109,"style":145},"normal quota",[135,1801],{"x1":1320,"y1":1783,"x2":1802,"y2":1784,"stroke":109,"style":1773},"558",[127,1804,1805],{"x":1317,"y":1788,"fill":109,"style":145},"no",[111,1807],{"x":1808,"y":1792,"width":143,"height":1793,"rx":387,"fill":116,"stroke":155,"style":125},"470",[127,1810,1813],{"x":1811,"y":1812,"fill":131,"style":392},"582","186","now \u003C grace_until?",[135,1815],{"x1":1816,"y1":1817,"x2":230,"y2":1818,"stroke":109,"style":1773},"520","204","242",[127,1820,1789],{"x":1821,"y":1822,"fill":109,"style":145},"455","222",[111,1824],{"x":1825,"y":1826,"width":1827,"height":1793,"rx":387,"fill":116,"stroke":124,"style":125},"245","244","235",[127,1829,1832],{"x":1830,"y":1831,"fill":131,"style":392},"362","266","200 — degraded quota",[127,1834,1836],{"x":1830,"y":1835,"fill":109,"style":145},"282","rate_limit_factor 0.5",[135,1838],{"x1":1270,"y1":1817,"x2":1270,"y2":1818,"stroke":109,"style":1773},[127,1840,1805],{"x":1841,"y":1316,"fill":109,"style":145},"646",[111,1843],{"x":1317,"y":1826,"width":463,"height":1793,"rx":387,"fill":116,"stroke":201,"style":125},[127,1845,1847],{"x":1846,"y":1831,"fill":131,"style":392},"600","402 — suspended",[127,1849,1850],{"x":1846,"y":1835,"fill":109,"style":145},"link to billing portal",[34,1852],{},[37,1854,1856],{"id":1855},"failure-modes-that-quietly-cost-money","Failure modes that quietly cost money",[14,1858,1859,1862,1863,1866,1867,1869,1870,1872,1873,1876],{},[45,1860,1861],{},"Out-of-order webhooks."," Stripe does not guarantee ordering. A ",[24,1864,1865],{},"payment_succeeded"," can land before the ",[24,1868,150],{}," that preceded it, flipping a healthy account back to ",[24,1871,160],{},". Store the event's ",[24,1874,1875],{},"created"," timestamp on the account row and ignore any event older than the one you last applied.",[14,1878,1879,1882,1883,1885,1886,1889,1890,1892,1893,1896],{},[45,1880,1881],{},"Suspending on the wrong signal."," ",[24,1884,499],{}," firing with ",[24,1887,1888],{},"status: past_due"," is not a cancellation — it is Stripe telling you retries are still running. Suspend only on ",[24,1891,356],{},", or on ",[24,1894,1895],{},"invoice.marked_uncollectible"," if you configured that ending instead.",[14,1898,1899,1902,1903,1906],{},[45,1900,1901],{},"3D Secure declines."," A failure with ",[24,1904,1905],{},"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.",[14,1908,1909,1912,1913,1916],{},[45,1910,1911],{},"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 ",[24,1914,1915],{},"obj[\"amount_due\"]"," above a threshold and route those to a real inbox.",[14,1918,1919,1922,1923,1926,1927,71],{},[45,1920,1921],{},"Multiple subscriptions per customer."," Keying account state on ",[24,1924,1925],{},"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 ",[18,1928,1930],{"href":1929},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fusage-based-vs-seat-based-pricing\u002F","usage-based vs seat-based pricing",[14,1932,1933,1936,1937,1941,1942,71],{},[45,1934,1935],{},"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 ",[18,1938,1940],{"href":1939},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging with structlog"," — and surface the count in ",[18,1943,1945],{"href":1944},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F","tracking API usage and analytics",[34,1947],{},[37,1949,1951],{"id":1950},"verify-it-before-real-money-depends-on-it","Verify it before real money depends on it",[14,1953,1954,1955,71],{},"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 ",[18,1956,1958],{"href":1957},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002F","testing Stripe integrations with test clocks",[517,1960,1962],{"className":519,"code":1961,"language":521,"meta":522,"style":522},"import os\nimport time\nimport stripe\n\nstripe.api_key = os.getenv(\"STRIPE_TEST_SECRET_KEY\")\nDAY = 86400\n\nclock = stripe.test_helpers.TestClock.create(frozen_time=int(time.time()))\ncustomer = stripe.Customer.create(test_clock=clock.id, email=os.getenv(\"TEST_EMAIL\"))\n# Attach the 4000000000000341 card (charges fail on the first attempt), then subscribe.\n\nfor day in (1, 3, 5, 8):\n    stripe.test_helpers.TestClock.advance(clock.id, frozen_time=clock.frozen_time + day * DAY)\n    # Poll your own DB and assert the expected status at each hop.\n",[24,1963,1964,1970,1976,1982,1986,1999,2008,2012,2032,2063,2068,2072,2102,2126],{"__ignoreMap":522},[526,1965,1966,1968],{"class":135,"line":528},[526,1967,532],{"class":531},[526,1969,536],{"class":535},[526,1971,1972,1974],{"class":135,"line":539},[526,1973,532],{"class":531},[526,1975,544],{"class":535},[526,1977,1978,1980],{"class":135,"line":547},[526,1979,532],{"class":531},[526,1981,552],{"class":535},[526,1983,1984],{"class":135,"line":555},[526,1985,573],{"emptyLinePlaceholder":572},[526,1987,1988,1990,1992,1994,1997],{"class":135,"line":569},[526,1989,591],{"class":535},[526,1991,582],{"class":531},[526,1993,596],{"class":535},[526,1995,1996],{"class":599},"\"STRIPE_TEST_SECRET_KEY\"",[526,1998,603],{"class":535},[526,2000,2001,2004,2006],{"class":135,"line":576},[526,2002,2003],{"class":609},"DAY",[526,2005,613],{"class":531},[526,2007,923],{"class":609},[526,2009,2010],{"class":135,"line":588},[526,2011,573],{"emptyLinePlaceholder":572},[526,2013,2014,2017,2019,2022,2025,2027,2029],{"class":135,"line":606},[526,2015,2016],{"class":535},"clock ",[526,2018,582],{"class":531},[526,2020,2021],{"class":535}," stripe.test_helpers.TestClock.create(",[526,2023,2024],{"class":768},"frozen_time",[526,2026,582],{"class":531},[526,2028,1140],{"class":609},[526,2030,2031],{"class":535},"(time.time()))\n",[526,2033,2034,2037,2039,2042,2045,2047,2050,2053,2055,2058,2061],{"class":135,"line":623},[526,2035,2036],{"class":535},"customer ",[526,2038,582],{"class":531},[526,2040,2041],{"class":535}," stripe.Customer.create(",[526,2043,2044],{"class":768},"test_clock",[526,2046,582],{"class":531},[526,2048,2049],{"class":535},"clock.id, ",[526,2051,2052],{"class":768},"email",[526,2054,582],{"class":531},[526,2056,2057],{"class":535},"os.getenv(",[526,2059,2060],{"class":599},"\"TEST_EMAIL\"",[526,2062,646],{"class":535},[526,2064,2065],{"class":135,"line":649},[526,2066,2067],{"class":881},"# Attach the 4000000000000341 card (charges fail on the first attempt), then subscribe.\n",[526,2069,2070],{"class":135,"line":654},[526,2071,573],{"emptyLinePlaceholder":572},[526,2073,2074,2077,2080,2083,2085,2088,2090,2092,2094,2096,2098,2100],{"class":135,"line":669},[526,2075,2076],{"class":531},"for",[526,2078,2079],{"class":535}," day ",[526,2081,2082],{"class":531},"in",[526,2084,744],{"class":535},[526,2086,2087],{"class":609},"1",[526,2089,640],{"class":535},[526,2091,276],{"class":609},[526,2093,640],{"class":535},[526,2095,101],{"class":609},[526,2097,640],{"class":535},[526,2099,387],{"class":609},[526,2101,1501],{"class":535},[526,2103,2104,2107,2109,2111,2114,2116,2118,2121,2124],{"class":135,"line":684},[526,2105,2106],{"class":535},"    stripe.test_helpers.TestClock.advance(clock.id, ",[526,2108,2024],{"class":768},[526,2110,582],{"class":531},[526,2112,2113],{"class":535},"clock.frozen_time ",[526,2115,920],{"class":531},[526,2117,2079],{"class":535},[526,2119,2120],{"class":531},"*",[526,2122,2123],{"class":609}," DAY",[526,2125,603],{"class":535},[526,2127,2128],{"class":135,"line":698},[526,2129,2130],{"class":881},"    # Poll your own DB and assert the expected status at each hop.\n",[14,2132,2133,2134,2136,2137,2139,2140,2142,2143,2147],{},"Assert three things at each hop: the account status matches the state machine, ",[24,2135,978],{}," sits after Stripe's last retry, and a live request returns ",[24,2138,463],{}," on days 1 through 7 and ",[24,2141,230],{}," on day 8. Wire those assertions into pytest using ",[18,2144,2146],{"href":2145},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Ftesting-async-fastapi-endpoints-with-httpx\u002F","testing async FastAPI endpoints with httpx"," so a refactor cannot quietly break revenue recovery.",[34,2149],{},[37,2151,2153],{"id":2152},"builder-verdict","Builder verdict",[14,2155,2156,2157,2159,2160,2162,2163,2165,2166,2170],{},"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 ",[24,2158,26],{}," and ",[24,2161,495],{},", derive the deadline from Stripe's own ",[24,2164,1207],{},", 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 ",[18,2167,2169],{"href":2168},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fhow-to-charge-for-api-access-using-stripe\u002F","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.",[34,2172],{},[37,2174,2176],{"id":2175},"faq","FAQ",[14,2178,2179,2182],{},[45,2180,2181],{},"How much revenue does a dunning flow actually recover?","\nOn a $49\u002Fmonth 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.",[14,2184,2185,2188,2189,2191],{},[45,2186,2187],{},"How long should my grace period be?","\nEight 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 ",[24,2190,1207],{}," off the failed invoice and add a day, so the deadline tracks Stripe's real schedule instead of a constant you have to maintain.",[14,2193,2194,2197,2198,2200,2201,2203],{},[45,2195,2196],{},"Should I suspend access on the first failed payment?","\nNo. The first failure is usually an expired card or a temporary decline that Stripe recovers on retry. Move the account to ",[24,2199,160],{},", halve its rate limit so the problem is visible, email a link to the hosted invoice, and suspend only on ",[24,2202,356],{},". Suspending immediately trades a few dollars of served compute for a whole customer's lifetime value.",[14,2205,2206,2209,640,2211,2213,2214,2216,2217,2219,2220,2224],{},[45,2207,2208],{},"Which webhooks do I need, and what does handling them cost?",[24,2210,26],{},[24,2212,495],{}," or ",[24,2215,175],{},", and ",[24,2218,356],{},". 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 ",[18,2221,2223],{"href":2222},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fbest-platforms-to-host-python-apis-for-free\u002F","best platforms to host Python APIs for free",". The cost is engineering time, not hosting.",[14,2226,2227,2230,2231,640,2233,2235],{},[45,2228,2229],{},"What is the migration risk if I change billing providers later?","\nThe state machine is the asset, not the Stripe calls. Keep ",[24,2232,968],{},[24,2234,978],{}," 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.",[37,2237,2239],{"id":2238},"related","Related",[14,2241,2242],{},[45,2243,2244],{},"Same track:",[2246,2247,2248,2254,2260],"ul",{},[2249,2250,2251,2253],"li",{},[18,2252,21],{"href":20}," — the full checkout, webhook and subscription setup this page extends.",[2249,2255,2256,2259],{},[18,2257,2258],{"href":1957},"Testing Stripe Integrations with Test Clocks"," — fast-forward a subscription through the retry schedule before shipping.",[2249,2261,2262,2266],{},[18,2263,2265],{"href":2264},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fstripe-metered-billing-configuration\u002F","Stripe Metered Billing Configuration"," — how usage records turn into the invoice that can fail.",[14,2268,2269],{},[45,2270,2271],{},"Other tracks:",[2246,2273,2274,2280],{},[2249,2275,2276,2279],{},[18,2277,2278],{"href":30},"Verifying Stripe Webhook Signatures"," — secure the endpoint before it can change account state.",[2249,2281,2282,2285],{},[18,2283,2284],{"href":509},"Building an Idempotent Webhook Receiver"," — make replayed dunning events converge instead of corrupting state.",[2287,2288,2289],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":522,"searchDepth":539,"depth":539,"links":2291},[2292,2293,2294,2295,2296,2297,2298,2299,2300,2301],{"id":39,"depth":539,"text":40},{"id":220,"depth":539,"text":221},{"id":338,"depth":539,"text":339},{"id":485,"depth":539,"text":486},{"id":1329,"depth":539,"text":1330},{"id":1855,"depth":539,"text":1856},{"id":1950,"depth":539,"text":1951},{"id":2152,"depth":539,"text":2153},{"id":2175,"depth":539,"text":2176},{"id":2238,"depth":539,"text":2239},"Recover failed API subscription payments with Stripe smart retries, invoice.payment_failed webhooks, a grace-period state machine, and 402 enforcement in Python.","md",{"pageTitle":2305,"type":2306,"datePublished":2307,"dateModified":2308},"Handling Failed Payments and Dunning in Python APIs","article","2026-06-18","2026-07-23","\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fhandling-failed-payments-and-dunning",{"title":5,"description":2302},"building-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fhandling-failed-payments-and-dunning\u002Findex","Cx-OIvHCw1bqF-8TP3yjGY-rUVMUHHbIiPMw2GNSiCo",{"@context":2314,"@type":2315,"mainEntity":2316},"https:\u002F\u002Fschema.org","FAQPage",[2317,2322,2325,2328,2331],{"@type":2318,"name":2181,"acceptedAnswer":2319},"Question",{"@type":2320,"text":2321},"Answer","On a $49\u002Fmonth 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.",{"@type":2318,"name":2187,"acceptedAnswer":2323},{"@type":2320,"text":2324},"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.",{"@type":2318,"name":2196,"acceptedAnswer":2326},{"@type":2320,"text":2327},"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.",{"@type":2318,"name":2208,"acceptedAnswer":2329},{"@type":2320,"text":2330},"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.",{"@type":2318,"name":2229,"acceptedAnswer":2332},{"@type":2320,"text":2333},"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.",1784887028503]