[{"data":1,"prerenderedAt":2252},["ShallowReactive",2],{"page-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002F":3,"faq-schema-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002F":2231},{"id":4,"title":5,"body":6,"description":2221,"extension":2222,"meta":2223,"navigation":351,"path":2227,"seo":2228,"stem":2229,"__hash__":2230},"content\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002Findex.md","Testing Stripe Integrations with Test Clocks",{"type":7,"value":8,"toc":2208},"minimark",[9,13,23,31,34,39,46,64,216,218,222,230,286,289,300,302,306,309,947,950,952,956,968,1097,1397,1409,1411,1415,1418,1433,1565,1568,1944,1946,1950,1953,2057,2072,2079,2081,2085,2088,2096,2098,2102,2117,2119,2123,2126,2128,2132,2139,2145,2154,2160,2166,2170,2204],[10,11,5],"h1",{"id":12},"testing-stripe-integrations-with-test-clocks",[14,15,16,17,22],"p",{},"Billing bugs surface on a schedule you cannot wait for. A trial that ends thirty days from now, a renewal that fires on day 31, a card that declines on the third invoice and drags the account through dunning — none of that shows up in a test suite that finishes in two seconds. 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 resolves one specific choice: whether to keep faking Stripe's timeline with mocked fixtures or to drive it with real Stripe test clocks. Test clocks let you compress a year of subscription lifecycle into under two minutes of wall-clock time, against Stripe's real billing engine, and assert that your own webhook handlers moved the account to the right state.",[14,24,25,26,30],{},"The decision matters commercially because billing is the one subsystem where a silent failure costs you money directly. A renewal that never provisions, a ",[27,28,29],"code",{},"past_due"," account that keeps serving requests for free, a suspended customer who paid and stayed locked out — each is revenue leaking or a refund request. Unit tests over hand-written JSON fixtures prove that your handler parses a payload. They prove nothing about whether Stripe actually emits that payload, in that order, at that moment.",[32,33],"hr",{},[35,36,38],"h2",{"id":37},"what-a-test-clock-actually-is","What a test clock actually is",[14,40,41,42,45],{},"A test clock is a Stripe test-mode object holding a simulated timestamp. Any customer created with ",[27,43,44],{},"test_clock"," set is bound to that clock, and every object descended from that customer — subscriptions, invoices, payment intents, schedules — reads time from the clock instead of the real world. When you advance the clock to a future timestamp, Stripe runs the billing engine forward through every intervening event: it finalizes invoices, attempts charges, retries failures, transitions subscription statuses, and fires the corresponding webhooks to your endpoints. You get the genuine event sequence, with genuine payload shapes and genuine ordering quirks, without waiting a month.",[14,47,48,49,52,53,56,57,60,61,63],{},"Advancing is asynchronous. ",[27,50,51],{},"TestClock.advance"," returns immediately with ",[27,54,55],{},"status=\"advancing\"","; the clock flips to ",[27,58,59],{},"ready"," once Stripe has generated and dispatched everything for that jump. The single most common mistake is asserting right after the advance call and reading stale state. Every helper below polls to ",[27,62,59],{}," first, then waits for your own side effects to land.",[65,66,74,75,74,79,74,83,74,90,74,98,74,105,74,109,74,112,74,115,74,118,74,121,74,124,74,126,74,131,74,134,74,137,74,140,74,143,74,146,74,149,74,155,74,157,74,159,74,161,74,165,74,169,74,173,74,176,74,179,74,182,74,185,74,188,74,191,74,195,74,197,74,200,74,202,74,206,74,209,74,213],"svg",{"viewBox":67,"role":68,"ariaLabelledBy":69,"xmlns":72,"style":73},"0 0 720 262","img",[70,71],"tclk-tl-t","tclk-tl-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[76,77,78],"title",{"id":70},"Wall-clock seconds mapped to simulated billing months",[80,81,82],"desc",{"id":71},"A 41-second pytest run advances a Stripe test clock across twelve simulated months, passing trial end, renewals, a card decline and suspension.",[84,85],"rect",{"x":86,"y":86,"width":87,"height":88,"fill":89},"0","720","262","var(--c-surface)",[91,92,97],"text",{"x":93,"y":94,"fill":95,"style":96},"60","42","var(--c-text)","text-anchor:start;font-size:13;font-family:var(--font-sans);","Wall clock — one pytest run",[99,100],"line",{"x1":93,"y1":101,"x2":102,"y2":101,"stroke":103,"style":104},"90","660","var(--c-blue)","stroke-width:2;",[99,106],{"x1":93,"y1":107,"x2":93,"y2":108,"stroke":103,"style":104},"84","96",[99,110],{"x1":111,"y1":107,"x2":111,"y2":108,"stroke":103,"style":104},"160",[99,113],{"x1":114,"y1":107,"x2":114,"y2":108,"stroke":103,"style":104},"260",[99,116],{"x1":117,"y1":107,"x2":117,"y2":108,"stroke":103,"style":104},"360",[99,119],{"x1":120,"y1":107,"x2":120,"y2":108,"stroke":103,"style":104},"460",[99,122],{"x1":123,"y1":107,"x2":123,"y2":108,"stroke":103,"style":104},"560",[99,125],{"x1":102,"y1":107,"x2":102,"y2":108,"stroke":103,"style":104},[91,127,130],{"x":93,"y":128,"fill":95,"style":129},"76","text-anchor:middle;font-size:11;font-family:var(--font-sans);","0s",[91,132,133],{"x":111,"y":128,"fill":95,"style":129},"7s",[91,135,136],{"x":114,"y":128,"fill":95,"style":129},"14s",[91,138,139],{"x":117,"y":128,"fill":95,"style":129},"21s",[91,141,142],{"x":120,"y":128,"fill":95,"style":129},"28s",[91,144,145],{"x":123,"y":128,"fill":95,"style":129},"35s",[91,147,148],{"x":102,"y":128,"fill":95,"style":129},"41s",[99,150],{"x1":111,"y1":151,"x2":111,"y2":152,"stroke":153,"style":154},"98","162","var(--c-border)","stroke-width:1;stroke-dasharray:4 4;",[99,156],{"x1":117,"y1":151,"x2":117,"y2":152,"stroke":153,"style":154},[99,158],{"x1":120,"y1":151,"x2":120,"y2":152,"stroke":153,"style":154},[99,160],{"x1":123,"y1":151,"x2":123,"y2":152,"stroke":153,"style":154},[91,162,164],{"x":93,"y":163,"fill":95,"style":96},"146","Simulated clock — 12 billing months",[99,166],{"x1":93,"y1":167,"x2":102,"y2":167,"stroke":168,"style":104},"176","var(--c-teal)",[91,170,172],{"x":93,"y":171,"fill":95,"style":129},"200","Jan",[91,174,175],{"x":111,"y":171,"fill":95,"style":129},"Feb",[91,177,178],{"x":114,"y":171,"fill":95,"style":129},"Apr",[91,180,181],{"x":117,"y":171,"fill":95,"style":129},"Jun",[91,183,184],{"x":120,"y":171,"fill":95,"style":129},"Aug",[91,186,187],{"x":123,"y":171,"fill":95,"style":129},"Oct",[91,189,190],{"x":102,"y":171,"fill":95,"style":129},"Dec",[192,193],"circle",{"cx":111,"cy":167,"r":194,"fill":168},"5",[192,196],{"cx":117,"cy":167,"r":194,"fill":168},[192,198],{"cx":120,"cy":167,"r":194,"fill":199},"var(--c-coral)",[192,201],{"cx":123,"cy":167,"r":194,"fill":199},[91,203,205],{"x":111,"y":204,"fill":95,"style":129},"224","trial ends",[91,207,208],{"x":117,"y":204,"fill":95,"style":129},"renewal #5",[91,210,212],{"x":120,"y":211,"fill":95,"style":129},"244","card declines",[91,214,215],{"x":123,"y":204,"fill":95,"style":129},"suspended",[32,217],{},[35,219,221],{"id":220},"test-clocks-versus-the-alternatives","Test clocks versus the alternatives",[14,223,224,225,229],{},"Three approaches compete for the same job. Hand-written fixtures replay a saved payload into your handler. Recorded transports — the pattern covered in ",[18,226,228],{"href":227},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002Fmocking-external-apis-with-respx\u002F","mocking external APIs with respx"," — intercept outbound HTTP so your code talks to a stub Stripe. Test clocks use the real Stripe test API and let it generate everything.",[231,232,233,249],"table",{},[234,235,236],"thead",{},[237,238,239,243,246],"tr",{},[240,241,242],"th",{},"Approach",[240,244,245],{},"Proves",[240,247,248],{},"Cost per run",[250,251,252,264,275],"tbody",{},[237,253,254,258,261],{},[255,256,257],"td",{},"JSON fixtures",[255,259,260],{},"Handler parses a payload",[255,262,263],{},"~0.1 s",[237,265,266,269,272],{},[255,267,268],{},"respx stubs",[255,270,271],{},"Client code calls Stripe right",[255,273,274],{},"~1 s",[237,276,277,280,283],{},[255,278,279],{},"Test clocks",[255,281,282],{},"The whole lifecycle behaves",[255,284,285],{},"60–120 s",[14,287,288],{},"They are not rivals so much as layers. Keep fixtures for branch coverage of your handler — every event type, every malformed edge case, every idempotency retry. Keep respx for the outbound direction, where you assert that your checkout code sends the right price and metadata. Then add one test-clock suite that walks a subscription from creation to suspension and back, because that is the only layer that catches ordering bugs, missing events, and the drift that appears when Stripe changes an invoice's behaviour and your six-month-old fixture does not.",[14,290,291,292,295,296,299],{},"The fixture trap is worth naming plainly. A fixture is a photograph of Stripe's behaviour on the day you copied it: it never fails when Stripe changes when ",[27,293,294],{},"invoice.paid"," fires relative to ",[27,297,298],{},"customer.subscription.updated",", so your handler keeps passing against a version of reality that no longer exists.",[32,301],{},[35,303,305],{"id":304},"creating-a-clock-and-putting-a-subscription-on-it","Creating a clock and putting a subscription on it",[14,307,308],{},"The setup is short. Create the clock at a fixed timestamp, create a customer attached to it, attach a test payment method, and start a subscription. Freezing the start time at a known epoch rather than \"now\" keeps runs deterministic, which matters when a test crosses a month boundary or a leap day.",[310,311,316],"pre",{"className":312,"code":313,"language":314,"meta":315,"style":315},"language-python shiki shiki-themes github-light github-dark","import os\nimport time\nimport stripe\n\nstripe.api_key = os.getenv(\"STRIPE_TEST_SECRET_KEY\")\nPRICE_ID = os.getenv(\"STRIPE_PRICE_ID\")\nPOLL_TIMEOUT = float(os.getenv(\"TEST_CLOCK_POLL_TIMEOUT\", \"90\"))\nFROZEN_START = int(os.getenv(\"TEST_CLOCK_FROZEN_START\", \"1767225600\"))  # 2026-01-01Z\n\n\ndef new_clock(frozen_time: int = FROZEN_START) -> stripe.test_helpers.TestClock:\n    return stripe.test_helpers.TestClock.create(\n        frozen_time=frozen_time,\n        name=os.getenv(\"TEST_CLOCK_NAME\", \"billing-suite\"),\n    )\n\n\ndef subscribe(clock_id: str, payment_method: str = \"pm_card_visa\") -> stripe.Subscription:\n    customer = stripe.Customer.create(\n        email=os.getenv(\"TEST_CUSTOMER_EMAIL\", \"clock@example.test\"),\n        test_clock=clock_id,\n    )\n    stripe.PaymentMethod.attach(payment_method, customer=customer.id)\n    stripe.Customer.modify(\n        customer.id,\n        invoice_settings={\"default_payment_method\": payment_method},\n    )\n    return stripe.Subscription.create(\n        customer=customer.id,\n        items=[{\"price\": PRICE_ID}],\n        trial_period_days=int(os.getenv(\"TRIAL_DAYS\", \"14\")),\n    )\n\n\ndef advance(clock_id: str, to_ts: int) -> None:\n    \"\"\"Advance and block until Stripe has emitted everything for the jump.\"\"\"\n    stripe.test_helpers.TestClock.advance(clock_id, frozen_time=to_ts)\n    deadline = time.monotonic() + POLL_TIMEOUT\n    while time.monotonic() \u003C deadline:\n        clock = stripe.test_helpers.TestClock.retrieve(clock_id)\n        match clock.status:\n            case \"ready\":\n                return\n            case \"internal_failure\":\n                raise RuntimeError(f\"test clock {clock_id} failed to advance\")\n            case _:\n                time.sleep(2)\n    raise TimeoutError(f\"test clock {clock_id} stuck advancing past {to_ts}\")\n","python","",[27,317,318,330,338,346,353,372,389,415,443,448,453,477,486,498,520,526,531,536,563,574,594,605,610,624,630,636,653,658,666,677,699,722,727,732,737,763,769,783,800,814,825,834,845,851,861,892,900,911],{"__ignoreMap":315},[319,320,322,326],"span",{"class":99,"line":321},1,[319,323,325],{"class":324},"szBVR","import",[319,327,329],{"class":328},"sVt8B"," os\n",[319,331,333,335],{"class":99,"line":332},2,[319,334,325],{"class":324},[319,336,337],{"class":328}," time\n",[319,339,341,343],{"class":99,"line":340},3,[319,342,325],{"class":324},[319,344,345],{"class":328}," stripe\n",[319,347,349],{"class":99,"line":348},4,[319,350,352],{"emptyLinePlaceholder":351},true,"\n",[319,354,356,359,362,365,369],{"class":99,"line":355},5,[319,357,358],{"class":328},"stripe.api_key ",[319,360,361],{"class":324},"=",[319,363,364],{"class":328}," os.getenv(",[319,366,368],{"class":367},"sZZnC","\"STRIPE_TEST_SECRET_KEY\"",[319,370,371],{"class":328},")\n",[319,373,375,379,382,384,387],{"class":99,"line":374},6,[319,376,378],{"class":377},"sj4cs","PRICE_ID",[319,380,381],{"class":324}," =",[319,383,364],{"class":328},[319,385,386],{"class":367},"\"STRIPE_PRICE_ID\"",[319,388,371],{"class":328},[319,390,392,395,397,400,403,406,409,412],{"class":99,"line":391},7,[319,393,394],{"class":377},"POLL_TIMEOUT",[319,396,381],{"class":324},[319,398,399],{"class":377}," float",[319,401,402],{"class":328},"(os.getenv(",[319,404,405],{"class":367},"\"TEST_CLOCK_POLL_TIMEOUT\"",[319,407,408],{"class":328},", ",[319,410,411],{"class":367},"\"90\"",[319,413,414],{"class":328},"))\n",[319,416,418,421,423,426,428,431,433,436,439],{"class":99,"line":417},8,[319,419,420],{"class":377},"FROZEN_START",[319,422,381],{"class":324},[319,424,425],{"class":377}," int",[319,427,402],{"class":328},[319,429,430],{"class":367},"\"TEST_CLOCK_FROZEN_START\"",[319,432,408],{"class":328},[319,434,435],{"class":367},"\"1767225600\"",[319,437,438],{"class":328},"))  ",[319,440,442],{"class":441},"sJ8bj","# 2026-01-01Z\n",[319,444,446],{"class":99,"line":445},9,[319,447,352],{"emptyLinePlaceholder":351},[319,449,451],{"class":99,"line":450},10,[319,452,352],{"emptyLinePlaceholder":351},[319,454,456,459,463,466,469,471,474],{"class":99,"line":455},11,[319,457,458],{"class":324},"def",[319,460,462],{"class":461},"sScJk"," new_clock",[319,464,465],{"class":328},"(frozen_time: ",[319,467,468],{"class":377},"int",[319,470,381],{"class":324},[319,472,473],{"class":377}," FROZEN_START",[319,475,476],{"class":328},") -> stripe.test_helpers.TestClock:\n",[319,478,480,483],{"class":99,"line":479},12,[319,481,482],{"class":324},"    return",[319,484,485],{"class":328}," stripe.test_helpers.TestClock.create(\n",[319,487,489,493,495],{"class":99,"line":488},13,[319,490,492],{"class":491},"s4XuR","        frozen_time",[319,494,361],{"class":324},[319,496,497],{"class":328},"frozen_time,\n",[319,499,501,504,506,509,512,514,517],{"class":99,"line":500},14,[319,502,503],{"class":491},"        name",[319,505,361],{"class":324},[319,507,508],{"class":328},"os.getenv(",[319,510,511],{"class":367},"\"TEST_CLOCK_NAME\"",[319,513,408],{"class":328},[319,515,516],{"class":367},"\"billing-suite\"",[319,518,519],{"class":328},"),\n",[319,521,523],{"class":99,"line":522},15,[319,524,525],{"class":328},"    )\n",[319,527,529],{"class":99,"line":528},16,[319,530,352],{"emptyLinePlaceholder":351},[319,532,534],{"class":99,"line":533},17,[319,535,352],{"emptyLinePlaceholder":351},[319,537,539,541,544,547,550,553,555,557,560],{"class":99,"line":538},18,[319,540,458],{"class":324},[319,542,543],{"class":461}," subscribe",[319,545,546],{"class":328},"(clock_id: ",[319,548,549],{"class":377},"str",[319,551,552],{"class":328},", payment_method: ",[319,554,549],{"class":377},[319,556,381],{"class":324},[319,558,559],{"class":367}," \"pm_card_visa\"",[319,561,562],{"class":328},") -> stripe.Subscription:\n",[319,564,566,569,571],{"class":99,"line":565},19,[319,567,568],{"class":328},"    customer ",[319,570,361],{"class":324},[319,572,573],{"class":328}," stripe.Customer.create(\n",[319,575,577,580,582,584,587,589,592],{"class":99,"line":576},20,[319,578,579],{"class":491},"        email",[319,581,361],{"class":324},[319,583,508],{"class":328},[319,585,586],{"class":367},"\"TEST_CUSTOMER_EMAIL\"",[319,588,408],{"class":328},[319,590,591],{"class":367},"\"clock@example.test\"",[319,593,519],{"class":328},[319,595,597,600,602],{"class":99,"line":596},21,[319,598,599],{"class":491},"        test_clock",[319,601,361],{"class":324},[319,603,604],{"class":328},"clock_id,\n",[319,606,608],{"class":99,"line":607},22,[319,609,525],{"class":328},[319,611,613,616,619,621],{"class":99,"line":612},23,[319,614,615],{"class":328},"    stripe.PaymentMethod.attach(payment_method, ",[319,617,618],{"class":491},"customer",[319,620,361],{"class":324},[319,622,623],{"class":328},"customer.id)\n",[319,625,627],{"class":99,"line":626},24,[319,628,629],{"class":328},"    stripe.Customer.modify(\n",[319,631,633],{"class":99,"line":632},25,[319,634,635],{"class":328},"        customer.id,\n",[319,637,639,642,644,647,650],{"class":99,"line":638},26,[319,640,641],{"class":491},"        invoice_settings",[319,643,361],{"class":324},[319,645,646],{"class":328},"{",[319,648,649],{"class":367},"\"default_payment_method\"",[319,651,652],{"class":328},": payment_method},\n",[319,654,656],{"class":99,"line":655},27,[319,657,525],{"class":328},[319,659,661,663],{"class":99,"line":660},28,[319,662,482],{"class":324},[319,664,665],{"class":328}," stripe.Subscription.create(\n",[319,667,669,672,674],{"class":99,"line":668},29,[319,670,671],{"class":491},"        customer",[319,673,361],{"class":324},[319,675,676],{"class":328},"customer.id,\n",[319,678,680,683,685,688,691,694,696],{"class":99,"line":679},30,[319,681,682],{"class":491},"        items",[319,684,361],{"class":324},[319,686,687],{"class":328},"[{",[319,689,690],{"class":367},"\"price\"",[319,692,693],{"class":328},": ",[319,695,378],{"class":377},[319,697,698],{"class":328},"}],\n",[319,700,702,705,707,709,711,714,716,719],{"class":99,"line":701},31,[319,703,704],{"class":491},"        trial_period_days",[319,706,361],{"class":324},[319,708,468],{"class":377},[319,710,402],{"class":328},[319,712,713],{"class":367},"\"TRIAL_DAYS\"",[319,715,408],{"class":328},[319,717,718],{"class":367},"\"14\"",[319,720,721],{"class":328},")),\n",[319,723,725],{"class":99,"line":724},32,[319,726,525],{"class":328},[319,728,730],{"class":99,"line":729},33,[319,731,352],{"emptyLinePlaceholder":351},[319,733,735],{"class":99,"line":734},34,[319,736,352],{"emptyLinePlaceholder":351},[319,738,740,742,745,747,749,752,754,757,760],{"class":99,"line":739},35,[319,741,458],{"class":324},[319,743,744],{"class":461}," advance",[319,746,546],{"class":328},[319,748,549],{"class":377},[319,750,751],{"class":328},", to_ts: ",[319,753,468],{"class":377},[319,755,756],{"class":328},") -> ",[319,758,759],{"class":377},"None",[319,761,762],{"class":328},":\n",[319,764,766],{"class":99,"line":765},36,[319,767,768],{"class":367},"    \"\"\"Advance and block until Stripe has emitted everything for the jump.\"\"\"\n",[319,770,772,775,778,780],{"class":99,"line":771},37,[319,773,774],{"class":328},"    stripe.test_helpers.TestClock.advance(clock_id, ",[319,776,777],{"class":491},"frozen_time",[319,779,361],{"class":324},[319,781,782],{"class":328},"to_ts)\n",[319,784,786,789,791,794,797],{"class":99,"line":785},38,[319,787,788],{"class":328},"    deadline ",[319,790,361],{"class":324},[319,792,793],{"class":328}," time.monotonic() ",[319,795,796],{"class":324},"+",[319,798,799],{"class":377}," POLL_TIMEOUT\n",[319,801,803,806,808,811],{"class":99,"line":802},39,[319,804,805],{"class":324},"    while",[319,807,793],{"class":328},[319,809,810],{"class":324},"\u003C",[319,812,813],{"class":328}," deadline:\n",[319,815,817,820,822],{"class":99,"line":816},40,[319,818,819],{"class":328},"        clock ",[319,821,361],{"class":324},[319,823,824],{"class":328}," stripe.test_helpers.TestClock.retrieve(clock_id)\n",[319,826,828,831],{"class":99,"line":827},41,[319,829,830],{"class":324},"        match",[319,832,833],{"class":328}," clock.status:\n",[319,835,837,840,843],{"class":99,"line":836},42,[319,838,839],{"class":324},"            case",[319,841,842],{"class":367}," \"ready\"",[319,844,762],{"class":328},[319,846,848],{"class":99,"line":847},43,[319,849,850],{"class":324},"                return\n",[319,852,854,856,859],{"class":99,"line":853},44,[319,855,839],{"class":324},[319,857,858],{"class":367}," \"internal_failure\"",[319,860,762],{"class":328},[319,862,864,867,870,873,876,879,881,884,887,890],{"class":99,"line":863},45,[319,865,866],{"class":324},"                raise",[319,868,869],{"class":377}," RuntimeError",[319,871,872],{"class":328},"(",[319,874,875],{"class":324},"f",[319,877,878],{"class":367},"\"test clock ",[319,880,646],{"class":377},[319,882,883],{"class":328},"clock_id",[319,885,886],{"class":377},"}",[319,888,889],{"class":367}," failed to advance\"",[319,891,371],{"class":328},[319,893,895,897],{"class":99,"line":894},46,[319,896,839],{"class":324},[319,898,899],{"class":328}," _:\n",[319,901,903,906,909],{"class":99,"line":902},47,[319,904,905],{"class":328},"                time.sleep(",[319,907,908],{"class":377},"2",[319,910,371],{"class":328},[319,912,914,917,920,922,924,926,928,930,932,935,937,940,942,945],{"class":99,"line":913},48,[319,915,916],{"class":324},"    raise",[319,918,919],{"class":377}," TimeoutError",[319,921,872],{"class":328},[319,923,875],{"class":324},[319,925,878],{"class":367},[319,927,646],{"class":377},[319,929,883],{"class":328},[319,931,886],{"class":377},[319,933,934],{"class":367}," stuck advancing past ",[319,936,646],{"class":377},[319,938,939],{"class":328},"to_ts",[319,941,886],{"class":377},[319,943,944],{"class":367},"\"",[319,946,371],{"class":328},[14,948,949],{},"Two rules govern advancing. Time only moves forward — you cannot rewind a clock, so a test that needs an earlier state needs a fresh clock. And a single jump should land on or just past a billing boundary rather than skipping several at once; stepping period by period keeps the emitted event sequence legible and keeps each advance inside a sane timeout.",[32,951],{},[35,953,955],{"id":954},"walking-a-renewal-and-a-dunning-cycle","Walking a renewal and a dunning cycle",[14,957,958,959,962,963,967],{},"The valuable test is the unhappy path. Start the subscription on a card that attaches fine but declines on charge — Stripe's ",[27,960,961],{},"pm_card_chargeCustomerFail"," token does exactly that — then step forward through trial end and the retry schedule while asserting your own account state at each stop. This is the ",[18,964,966],{"href":965},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Fhandling-failed-payments-and-dunning\u002F","failed payments and dunning"," flow, executed end to end instead of imagined.",[65,969,74,974,74,977,74,980,74,983,74,1001,74,1010,74,1015,74,1019,74,1023,74,1027,74,1031,74,1035,74,1039,74,1044,74,1046,74,1048,74,1050,74,1053,74,1058,74,1061,74,1066,74,1069,74,1073,74,1078,74,1083,74,1086,74,1090,74,1093],{"viewBox":970,"role":68,"ariaLabelledBy":971,"xmlns":72,"style":73},"0 0 720 330",[972,973],"tclk-seq-t","tclk-seq-d",[76,975,976],{"id":972},"Sequence of one clock advance through a failed renewal",[80,978,979],{"id":973},"The test advances the clock, Stripe emits a payment failure event, the relay forwards it to the local API which records past due state, and the test then asserts the account returns 402.",[84,981],{"x":86,"y":86,"width":87,"height":982,"fill":89},"330",[984,985,986,987,74],"defs",{},"\n    ",[988,989,995,996,986],"marker",{"id":990,"viewBox":991,"refX":992,"refY":194,"markerWidth":993,"markerHeight":993,"orient":994},"tclk-seq-arrow","0 0 10 10","9","7","auto-start-reverse","\n      ",[997,998],"path",{"d":999,"fill":1000},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[84,1002],{"x":1003,"y":1004,"width":1005,"height":1006,"rx":1007,"fill":1008,"stroke":153,"style":1009},"30","18","120","34","8","var(--c-surface-alt)","stroke-width:1;",[91,1011,1014],{"x":101,"y":1012,"fill":95,"style":1013},"40","text-anchor:middle;font-size:12;font-family:var(--font-sans);","pytest",[84,1016],{"x":1017,"y":1004,"width":1018,"height":1006,"rx":1007,"fill":1008,"stroke":153,"style":1009},"220","150",[91,1020,1022],{"x":1021,"y":1012,"fill":95,"style":1013},"295","Stripe clock",[84,1024],{"x":1025,"y":1004,"width":1026,"height":1006,"rx":1007,"fill":1008,"stroke":153,"style":1009},"410","140",[91,1028,1030],{"x":1029,"y":1012,"fill":95,"style":1013},"480","event relay",[84,1032],{"x":1033,"y":1004,"width":1034,"height":1006,"rx":1007,"fill":1008,"stroke":153,"style":1009},"590","110",[91,1036,1038],{"x":1037,"y":1012,"fill":95,"style":1013},"645","your API",[99,1040],{"x1":101,"y1":1041,"x2":101,"y2":1042,"stroke":153,"style":1043},"56","300","stroke-width:1;stroke-dasharray:5 5;",[99,1045],{"x1":1021,"y1":1041,"x2":1021,"y2":1042,"stroke":153,"style":1043},[99,1047],{"x1":1029,"y1":1041,"x2":1029,"y2":1042,"stroke":153,"style":1043},[99,1049],{"x1":1037,"y1":1041,"x2":1037,"y2":1042,"stroke":153,"style":1043},[99,1051],{"x1":101,"y1":101,"x2":1021,"y2":101,"stroke":103,"style":1052},"stroke-width:2;marker-end:url(#tclk-seq-arrow);",[91,1054,1057],{"x":1055,"y":1056,"fill":95,"style":129},"192","82","advance(+31d)",[99,1059],{"x1":1021,"y1":1060,"x2":1029,"y2":1060,"stroke":199,"style":1052},"130",[91,1062,1065],{"x":1063,"y":1064,"fill":95,"style":129},"387","122","payment_failed",[99,1067],{"x1":1029,"y1":1068,"x2":1037,"y2":1068,"stroke":199,"style":1052},"170",[91,1070,1072],{"x":1071,"y":152,"fill":95,"style":129},"562","POST \u002Fwebhooks",[84,1074],{"x":1075,"y":1055,"width":1005,"height":1003,"rx":1076,"fill":89,"stroke":1077,"style":104},"580","6","var(--c-yellow)",[91,1079,1082],{"x":1080,"y":1081,"fill":95,"style":129},"640","212","state = past_due",[99,1084],{"x1":101,"y1":1085,"x2":1037,"y2":1085,"stroke":1000,"style":1052},"252",[91,1087,1089],{"x":1088,"y":211,"fill":95,"style":129},"367","GET \u002Fv1\u002Fusage (billed key)",[99,1091],{"x1":1037,"y1":1092,"x2":101,"y2":1092,"stroke":168,"style":1052},"288",[91,1094,1096],{"x":1088,"y":1095,"fill":95,"style":129},"280","402 + grace_until",[310,1098,1100],{"className":312,"code":1099,"language":314,"meta":315,"style":315},"import os\nimport pytest\nimport stripe\nfrom clockkit import new_clock, subscribe, advance, FROZEN_START\n\nDAY = 86400\n\n\n@pytest.fixture\ndef clock():\n    c = new_clock()\n    yield c\n    stripe.test_helpers.TestClock.delete(c.id)\n\n\ndef test_declined_renewal_moves_account_to_past_due(clock, account_state):\n    sub = subscribe(clock.id, payment_method=\"pm_card_chargeCustomerFail\")\n    assert sub.status == \"trialing\"\n\n    # Step past the 14-day trial: Stripe finalizes the first invoice and the charge fails.\n    advance(clock.id, FROZEN_START + 15 * DAY)\n    state = account_state.wait_for(sub.customer, status=\"past_due\")\n    assert state[\"grace_until\"] > FROZEN_START\n\n    # Step through the smart-retry window until Stripe gives up.\n    advance(clock.id, FROZEN_START + 23 * DAY)\n    advance(clock.id, FROZEN_START + 32 * DAY)\n    final = account_state.wait_for(sub.customer, status=\"suspended\")\n    assert final[\"api_access\"] is False\n",[27,1101,1102,1108,1115,1121,1137,1141,1151,1155,1159,1164,1174,1184,1192,1197,1201,1205,1215,1235,1249,1253,1258,1279,1299,1318,1322,1327,1344,1361,1379],{"__ignoreMap":315},[319,1103,1104,1106],{"class":99,"line":321},[319,1105,325],{"class":324},[319,1107,329],{"class":328},[319,1109,1110,1112],{"class":99,"line":332},[319,1111,325],{"class":324},[319,1113,1114],{"class":328}," pytest\n",[319,1116,1117,1119],{"class":99,"line":340},[319,1118,325],{"class":324},[319,1120,345],{"class":328},[319,1122,1123,1126,1129,1131,1134],{"class":99,"line":348},[319,1124,1125],{"class":324},"from",[319,1127,1128],{"class":328}," clockkit ",[319,1130,325],{"class":324},[319,1132,1133],{"class":328}," new_clock, subscribe, advance, ",[319,1135,1136],{"class":377},"FROZEN_START\n",[319,1138,1139],{"class":99,"line":355},[319,1140,352],{"emptyLinePlaceholder":351},[319,1142,1143,1146,1148],{"class":99,"line":374},[319,1144,1145],{"class":377},"DAY",[319,1147,381],{"class":324},[319,1149,1150],{"class":377}," 86400\n",[319,1152,1153],{"class":99,"line":391},[319,1154,352],{"emptyLinePlaceholder":351},[319,1156,1157],{"class":99,"line":417},[319,1158,352],{"emptyLinePlaceholder":351},[319,1160,1161],{"class":99,"line":445},[319,1162,1163],{"class":461},"@pytest.fixture\n",[319,1165,1166,1168,1171],{"class":99,"line":450},[319,1167,458],{"class":324},[319,1169,1170],{"class":461}," clock",[319,1172,1173],{"class":328},"():\n",[319,1175,1176,1179,1181],{"class":99,"line":455},[319,1177,1178],{"class":328},"    c ",[319,1180,361],{"class":324},[319,1182,1183],{"class":328}," new_clock()\n",[319,1185,1186,1189],{"class":99,"line":479},[319,1187,1188],{"class":324},"    yield",[319,1190,1191],{"class":328}," c\n",[319,1193,1194],{"class":99,"line":488},[319,1195,1196],{"class":328},"    stripe.test_helpers.TestClock.delete(c.id)\n",[319,1198,1199],{"class":99,"line":500},[319,1200,352],{"emptyLinePlaceholder":351},[319,1202,1203],{"class":99,"line":522},[319,1204,352],{"emptyLinePlaceholder":351},[319,1206,1207,1209,1212],{"class":99,"line":528},[319,1208,458],{"class":324},[319,1210,1211],{"class":461}," test_declined_renewal_moves_account_to_past_due",[319,1213,1214],{"class":328},"(clock, account_state):\n",[319,1216,1217,1220,1222,1225,1228,1230,1233],{"class":99,"line":533},[319,1218,1219],{"class":328},"    sub ",[319,1221,361],{"class":324},[319,1223,1224],{"class":328}," subscribe(clock.id, ",[319,1226,1227],{"class":491},"payment_method",[319,1229,361],{"class":324},[319,1231,1232],{"class":367},"\"pm_card_chargeCustomerFail\"",[319,1234,371],{"class":328},[319,1236,1237,1240,1243,1246],{"class":99,"line":538},[319,1238,1239],{"class":324},"    assert",[319,1241,1242],{"class":328}," sub.status ",[319,1244,1245],{"class":324},"==",[319,1247,1248],{"class":367}," \"trialing\"\n",[319,1250,1251],{"class":99,"line":565},[319,1252,352],{"emptyLinePlaceholder":351},[319,1254,1255],{"class":99,"line":576},[319,1256,1257],{"class":441},"    # Step past the 14-day trial: Stripe finalizes the first invoice and the charge fails.\n",[319,1259,1260,1263,1265,1268,1271,1274,1277],{"class":99,"line":596},[319,1261,1262],{"class":328},"    advance(clock.id, ",[319,1264,420],{"class":377},[319,1266,1267],{"class":324}," +",[319,1269,1270],{"class":377}," 15",[319,1272,1273],{"class":324}," *",[319,1275,1276],{"class":377}," DAY",[319,1278,371],{"class":328},[319,1280,1281,1284,1286,1289,1292,1294,1297],{"class":99,"line":607},[319,1282,1283],{"class":328},"    state ",[319,1285,361],{"class":324},[319,1287,1288],{"class":328}," account_state.wait_for(sub.customer, ",[319,1290,1291],{"class":491},"status",[319,1293,361],{"class":324},[319,1295,1296],{"class":367},"\"past_due\"",[319,1298,371],{"class":328},[319,1300,1301,1303,1306,1309,1312,1315],{"class":99,"line":612},[319,1302,1239],{"class":324},[319,1304,1305],{"class":328}," state[",[319,1307,1308],{"class":367},"\"grace_until\"",[319,1310,1311],{"class":328},"] ",[319,1313,1314],{"class":324},">",[319,1316,1317],{"class":377}," FROZEN_START\n",[319,1319,1320],{"class":99,"line":626},[319,1321,352],{"emptyLinePlaceholder":351},[319,1323,1324],{"class":99,"line":632},[319,1325,1326],{"class":441},"    # Step through the smart-retry window until Stripe gives up.\n",[319,1328,1329,1331,1333,1335,1338,1340,1342],{"class":99,"line":638},[319,1330,1262],{"class":328},[319,1332,420],{"class":377},[319,1334,1267],{"class":324},[319,1336,1337],{"class":377}," 23",[319,1339,1273],{"class":324},[319,1341,1276],{"class":377},[319,1343,371],{"class":328},[319,1345,1346,1348,1350,1352,1355,1357,1359],{"class":99,"line":655},[319,1347,1262],{"class":328},[319,1349,420],{"class":377},[319,1351,1267],{"class":324},[319,1353,1354],{"class":377}," 32",[319,1356,1273],{"class":324},[319,1358,1276],{"class":377},[319,1360,371],{"class":328},[319,1362,1363,1366,1368,1370,1372,1374,1377],{"class":99,"line":660},[319,1364,1365],{"class":328},"    final ",[319,1367,361],{"class":324},[319,1369,1288],{"class":328},[319,1371,1291],{"class":491},[319,1373,361],{"class":324},[319,1375,1376],{"class":367},"\"suspended\"",[319,1378,371],{"class":328},[319,1380,1381,1383,1386,1389,1391,1394],{"class":99,"line":668},[319,1382,1239],{"class":324},[319,1384,1385],{"class":328}," final[",[319,1387,1388],{"class":367},"\"api_access\"",[319,1390,1311],{"class":328},[319,1392,1393],{"class":324},"is",[319,1395,1396],{"class":377}," False\n",[14,1398,1399,1400,1403,1404,1408],{},"Note what the assertions target: your database, not Stripe's. Reading ",[27,1401,1402],{},"subscription.status"," back from Stripe only proves Stripe works. The bug you are hunting lives in the gap between Stripe emitting an event and your account row changing — a handler that returned 500 and got retried into a double downgrade, a signature check that rejected the payload, a state transition that fired in the wrong order. Verify the signature path in the same run rather than bypassing it; the details are in ",[18,1405,1407],{"href":1406},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fverifying-stripe-webhook-signatures\u002F","verifying Stripe webhook signatures",".",[32,1410],{},[35,1412,1414],{"id":1413},"getting-events-back-into-your-app","Getting events back into your app",[14,1416,1417],{},"Test-clock events go to whatever endpoints your test-mode account has registered, which means the delivery path is the piece you must design. Three options, in order of preference for a small team.",[14,1419,1420,1421,1424,1425,1428,1429,1432],{},"Run the Stripe CLI as a sidecar with ",[27,1422,1423],{},"stripe listen --forward-to",", pointed at your locally running app. It prints a signing secret on startup — feed that into the app's ",[27,1426,1427],{},"STRIPE_WEBHOOK_SECRET"," so real signature verification runs. Second, expose a public URL from CI and register a temporary test-mode endpoint per run, deleting it in teardown. Third, poll ",[27,1430,1431],{},"stripe.Event.list"," in the test and post each event into your own handler yourself; this skips real delivery, so use it only when the CLI is unavailable.",[65,1434,74,1439,74,1442,74,1445,74,1448,74,1455,74,1462,74,1466,74,1469,74,1473,74,1478,74,1483,74,1486,74,1489,74,1492,74,1496,74,1500,74,1505,74,1509,74,1512,74,1516,74,1518,74,1521,74,1523,74,1526,74,1530,74,1535,74,1538,74,1541,74,1545,74,1550,74,1554,74,1556,74,1560,74,1563],{"viewBox":1435,"role":68,"ariaLabelledBy":1436,"xmlns":72,"style":73},"0 0 720 320",[1437,1438],"tclk-arch-t","tclk-arch-d",[76,1440,1441],{"id":1437},"Test-clock harness topology on a CI runner",[80,1443,1444],{"id":1438},"The pytest suite drives a Stripe test clock which advances a subscription and emits events, forwarded by the Stripe CLI into the local API, which writes state the suite then asserts.",[84,1446],{"x":86,"y":86,"width":87,"height":1447,"fill":89},"320",[984,1449,986,1450,74],{},[988,1451,995,1453,986],{"id":1452,"viewBox":991,"refX":992,"refY":194,"markerWidth":993,"markerHeight":993,"orient":994},"tclk-arch-arrow",[997,1454],{"d":999,"fill":1000},[84,1456],{"x":1457,"y":1458,"width":1459,"height":1085,"rx":1460,"fill":1008,"stroke":153,"style":1461},"16","44","316","12","stroke-width:1;stroke-dasharray:6 5;",[91,1463,1465],{"x":1003,"y":1006,"fill":95,"style":1464},"text-anchor:start;font-size:12;font-family:var(--font-sans);","CI runner",[84,1467],{"x":117,"y":1458,"width":1468,"height":1085,"rx":1460,"fill":1008,"stroke":153,"style":1461},"344",[91,1470,1472],{"x":1471,"y":1006,"fill":95,"style":1464},"374","Stripe test mode",[84,1474],{"x":1475,"y":1476,"width":1018,"height":1477,"rx":1007,"fill":89,"stroke":103,"style":104},"36","72","46",[91,1479,1482],{"x":1480,"y":1481,"fill":95,"style":1013},"111","100","pytest suite",[84,1484],{"x":1475,"y":1485,"width":1018,"height":1477,"rx":1007,"fill":89,"stroke":103,"style":104},"164",[91,1487,1488],{"x":1480,"y":1055,"fill":95,"style":1013},"FastAPI app",[84,1490],{"x":1475,"y":1491,"width":1018,"height":1477,"rx":1007,"fill":89,"stroke":168,"style":104},"238",[91,1493,1495],{"x":1480,"y":1494,"fill":95,"style":1013},"266","account state DB",[84,1497],{"x":1498,"y":1485,"width":1499,"height":1477,"rx":1007,"fill":89,"stroke":1077,"style":104},"210","106",[91,1501,1504],{"x":1502,"y":1503,"fill":95,"style":129},"263","186","stripe CLI",[91,1506,1508],{"x":1502,"y":1507,"fill":95,"style":129},"202","listen",[84,1510],{"x":1511,"y":1476,"width":1018,"height":1477,"rx":1007,"fill":89,"stroke":103,"style":104},"392",[91,1513,1515],{"x":1514,"y":1481,"fill":95,"style":1013},"467","test clock",[84,1517],{"x":1511,"y":1485,"width":1018,"height":1477,"rx":1007,"fill":89,"stroke":1000,"style":104},[91,1519,1520],{"x":1514,"y":1055,"fill":95,"style":1013},"subscription",[84,1522],{"x":1511,"y":1491,"width":1018,"height":1477,"rx":1007,"fill":89,"stroke":199,"style":104},[91,1524,1525],{"x":1514,"y":1494,"fill":95,"style":1013},"event stream",[99,1527],{"x1":1503,"y1":1528,"x2":1511,"y2":1528,"stroke":103,"style":1529},"88","stroke-width:2;marker-end:url(#tclk-arch-arrow);",[91,1531,1534],{"x":1532,"y":1533,"fill":95,"style":129},"289","80","advance()",[99,1536],{"x1":1514,"y1":1537,"x2":1514,"y2":111,"stroke":1000,"style":1529},"118",[99,1539],{"x1":1514,"y1":1498,"x2":1514,"y2":1540,"stroke":1000,"style":1529},"234",[99,1542],{"x1":1511,"y1":1543,"x2":1502,"y2":1544,"stroke":199,"style":1529},"261","214",[91,1546,1549],{"x":1547,"y":1548,"fill":95,"style":129},"348","248","events",[99,1551],{"x1":1498,"y1":1552,"x2":1553,"y2":1552,"stroke":1077,"style":1529},"187","190",[99,1555],{"x1":1480,"y1":1498,"x2":1480,"y2":1540,"stroke":168,"style":1529},[99,1557],{"x1":1475,"y1":1558,"x2":1559,"y2":1558,"stroke":168,"style":104},"255","24",[99,1561],{"x1":1559,"y1":1558,"x2":1559,"y2":1562,"stroke":168,"style":104},"95",[99,1564],{"x1":1559,"y1":1562,"x2":1006,"y2":1562,"stroke":168,"style":1529},[14,1566,1567],{},"Delivery is eventually consistent, so never assert immediately. Give the suite one polling helper that retries a read against your own store until it matches or a deadline passes, and reuse it everywhere. The version below hits the running app over HTTP with httpx, which also exercises your real auth and serialization path.",[310,1569,1571],{"className":312,"code":1570,"language":314,"meta":315,"style":315},"import asyncio\nimport os\nimport httpx\n\nBASE_URL = os.getenv(\"APP_BASE_URL\", \"http:\u002F\u002F127.0.0.1:8000\")\nADMIN_TOKEN = os.getenv(\"APP_ADMIN_TOKEN\")\nSETTLE_TIMEOUT = float(os.getenv(\"WEBHOOK_SETTLE_TIMEOUT\", \"45\"))\n\n\nasync def wait_for_state(customer_id: str, status: str) -> dict:\n    \"\"\"Poll the app until the webhook side effect lands, or fail loudly.\"\"\"\n    url = f\"{BASE_URL}\u002Finternal\u002Faccounts\u002F{customer_id}\"\n    headers = {\"Authorization\": f\"Bearer {ADMIN_TOKEN}\"}\n    deadline = asyncio.get_running_loop().time() + SETTLE_TIMEOUT\n    last = None\n    async with httpx.AsyncClient(timeout=10.0) as client:\n        while asyncio.get_running_loop().time() \u003C deadline:\n            resp = await client.get(url, headers=headers)\n            if resp.status_code == 200:\n                last = resp.json()\n                if last[\"status\"] == status:\n                    return last\n            await asyncio.sleep(1.0)\n    raise AssertionError(f\"expected {status!r} for {customer_id}, last saw {last!r}\")\n",[27,1572,1573,1580,1586,1593,1597,1616,1630,1651,1655,1659,1687,1692,1720,1748,1762,1772,1800,1811,1832,1847,1857,1875,1883,1896],{"__ignoreMap":315},[319,1574,1575,1577],{"class":99,"line":321},[319,1576,325],{"class":324},[319,1578,1579],{"class":328}," asyncio\n",[319,1581,1582,1584],{"class":99,"line":332},[319,1583,325],{"class":324},[319,1585,329],{"class":328},[319,1587,1588,1590],{"class":99,"line":340},[319,1589,325],{"class":324},[319,1591,1592],{"class":328}," httpx\n",[319,1594,1595],{"class":99,"line":348},[319,1596,352],{"emptyLinePlaceholder":351},[319,1598,1599,1602,1604,1606,1609,1611,1614],{"class":99,"line":355},[319,1600,1601],{"class":377},"BASE_URL",[319,1603,381],{"class":324},[319,1605,364],{"class":328},[319,1607,1608],{"class":367},"\"APP_BASE_URL\"",[319,1610,408],{"class":328},[319,1612,1613],{"class":367},"\"http:\u002F\u002F127.0.0.1:8000\"",[319,1615,371],{"class":328},[319,1617,1618,1621,1623,1625,1628],{"class":99,"line":374},[319,1619,1620],{"class":377},"ADMIN_TOKEN",[319,1622,381],{"class":324},[319,1624,364],{"class":328},[319,1626,1627],{"class":367},"\"APP_ADMIN_TOKEN\"",[319,1629,371],{"class":328},[319,1631,1632,1635,1637,1639,1641,1644,1646,1649],{"class":99,"line":391},[319,1633,1634],{"class":377},"SETTLE_TIMEOUT",[319,1636,381],{"class":324},[319,1638,399],{"class":377},[319,1640,402],{"class":328},[319,1642,1643],{"class":367},"\"WEBHOOK_SETTLE_TIMEOUT\"",[319,1645,408],{"class":328},[319,1647,1648],{"class":367},"\"45\"",[319,1650,414],{"class":328},[319,1652,1653],{"class":99,"line":417},[319,1654,352],{"emptyLinePlaceholder":351},[319,1656,1657],{"class":99,"line":445},[319,1658,352],{"emptyLinePlaceholder":351},[319,1660,1661,1664,1667,1670,1673,1675,1678,1680,1682,1685],{"class":99,"line":450},[319,1662,1663],{"class":324},"async",[319,1665,1666],{"class":324}," def",[319,1668,1669],{"class":461}," wait_for_state",[319,1671,1672],{"class":328},"(customer_id: ",[319,1674,549],{"class":377},[319,1676,1677],{"class":328},", status: ",[319,1679,549],{"class":377},[319,1681,756],{"class":328},[319,1683,1684],{"class":377},"dict",[319,1686,762],{"class":328},[319,1688,1689],{"class":99,"line":455},[319,1690,1691],{"class":367},"    \"\"\"Poll the app until the webhook side effect lands, or fail loudly.\"\"\"\n",[319,1693,1694,1697,1699,1702,1704,1707,1710,1712,1715,1717],{"class":99,"line":479},[319,1695,1696],{"class":328},"    url ",[319,1698,361],{"class":324},[319,1700,1701],{"class":324}," f",[319,1703,944],{"class":367},[319,1705,1706],{"class":377},"{BASE_URL}",[319,1708,1709],{"class":367},"\u002Finternal\u002Faccounts\u002F",[319,1711,646],{"class":377},[319,1713,1714],{"class":328},"customer_id",[319,1716,886],{"class":377},[319,1718,1719],{"class":367},"\"\n",[319,1721,1722,1725,1727,1730,1733,1735,1737,1740,1743,1745],{"class":99,"line":488},[319,1723,1724],{"class":328},"    headers ",[319,1726,361],{"class":324},[319,1728,1729],{"class":328}," {",[319,1731,1732],{"class":367},"\"Authorization\"",[319,1734,693],{"class":328},[319,1736,875],{"class":324},[319,1738,1739],{"class":367},"\"Bearer ",[319,1741,1742],{"class":377},"{ADMIN_TOKEN}",[319,1744,944],{"class":367},[319,1746,1747],{"class":328},"}\n",[319,1749,1750,1752,1754,1757,1759],{"class":99,"line":500},[319,1751,788],{"class":328},[319,1753,361],{"class":324},[319,1755,1756],{"class":328}," asyncio.get_running_loop().time() ",[319,1758,796],{"class":324},[319,1760,1761],{"class":377}," SETTLE_TIMEOUT\n",[319,1763,1764,1767,1769],{"class":99,"line":522},[319,1765,1766],{"class":328},"    last ",[319,1768,361],{"class":324},[319,1770,1771],{"class":377}," None\n",[319,1773,1774,1777,1780,1783,1786,1788,1791,1794,1797],{"class":99,"line":528},[319,1775,1776],{"class":324},"    async",[319,1778,1779],{"class":324}," with",[319,1781,1782],{"class":328}," httpx.AsyncClient(",[319,1784,1785],{"class":491},"timeout",[319,1787,361],{"class":324},[319,1789,1790],{"class":377},"10.0",[319,1792,1793],{"class":328},") ",[319,1795,1796],{"class":324},"as",[319,1798,1799],{"class":328}," client:\n",[319,1801,1802,1805,1807,1809],{"class":99,"line":533},[319,1803,1804],{"class":324},"        while",[319,1806,1756],{"class":328},[319,1808,810],{"class":324},[319,1810,813],{"class":328},[319,1812,1813,1816,1818,1821,1824,1827,1829],{"class":99,"line":538},[319,1814,1815],{"class":328},"            resp ",[319,1817,361],{"class":324},[319,1819,1820],{"class":324}," await",[319,1822,1823],{"class":328}," client.get(url, ",[319,1825,1826],{"class":491},"headers",[319,1828,361],{"class":324},[319,1830,1831],{"class":328},"headers)\n",[319,1833,1834,1837,1840,1842,1845],{"class":99,"line":565},[319,1835,1836],{"class":324},"            if",[319,1838,1839],{"class":328}," resp.status_code ",[319,1841,1245],{"class":324},[319,1843,1844],{"class":377}," 200",[319,1846,762],{"class":328},[319,1848,1849,1852,1854],{"class":99,"line":576},[319,1850,1851],{"class":328},"                last ",[319,1853,361],{"class":324},[319,1855,1856],{"class":328}," resp.json()\n",[319,1858,1859,1862,1865,1868,1870,1872],{"class":99,"line":596},[319,1860,1861],{"class":324},"                if",[319,1863,1864],{"class":328}," last[",[319,1866,1867],{"class":367},"\"status\"",[319,1869,1311],{"class":328},[319,1871,1245],{"class":324},[319,1873,1874],{"class":328}," status:\n",[319,1876,1877,1880],{"class":99,"line":607},[319,1878,1879],{"class":324},"                    return",[319,1881,1882],{"class":328}," last\n",[319,1884,1885,1888,1891,1894],{"class":99,"line":612},[319,1886,1887],{"class":324},"            await",[319,1889,1890],{"class":328}," asyncio.sleep(",[319,1892,1893],{"class":377},"1.0",[319,1895,371],{"class":328},[319,1897,1898,1900,1903,1905,1907,1910,1912,1914,1917,1919,1922,1924,1926,1928,1931,1933,1936,1938,1940,1942],{"class":99,"line":626},[319,1899,916],{"class":324},[319,1901,1902],{"class":377}," AssertionError",[319,1904,872],{"class":328},[319,1906,875],{"class":324},[319,1908,1909],{"class":367},"\"expected ",[319,1911,646],{"class":377},[319,1913,1291],{"class":328},[319,1915,1916],{"class":324},"!r",[319,1918,886],{"class":377},[319,1920,1921],{"class":367}," for ",[319,1923,646],{"class":377},[319,1925,1714],{"class":328},[319,1927,886],{"class":377},[319,1929,1930],{"class":367},", last saw ",[319,1932,646],{"class":377},[319,1934,1935],{"class":328},"last",[319,1937,1916],{"class":324},[319,1939,886],{"class":377},[319,1941,944],{"class":367},[319,1943,371],{"class":328},[32,1945],{},[35,1947,1949],{"id":1948},"cost-runtime-and-wiring-it-into-ci","Cost, runtime, and wiring it into CI",[14,1951,1952],{},"Test clocks are free — they run in test mode and consume no live API quota — so the only cost is wall-clock minutes on your CI runner and the engineering time to keep the suite honest. A realistic single-subscription lifecycle test lands around 87 seconds, and almost all of it is waiting for advances to settle.",[65,1954,74,1959,74,1962,74,1965,74,1967,74,1972,74,1977,74,1982,74,1987,74,1991,74,1994,74,1998,74,2002,74,2005,74,2009,74,2013,74,2017,74,2020,74,2023,74,2027,74,2031,74,2035,74,2039,74,2043,74,2047,74,2049,74,2053],{"viewBox":1955,"role":68,"ariaLabelledBy":1956,"xmlns":72,"style":73},"0 0 720 300",[1957,1958],"tclk-bar-t","tclk-bar-d",[76,1960,1961],{"id":1957},"Where the 87 seconds of a test-clock run goes",[80,1963,1964],{"id":1958},"Bar chart: clock creation 2 seconds, customer and subscription 3, three advances at 22, 21 and 23 seconds, webhook settle 12, teardown 4.",[84,1966],{"x":86,"y":86,"width":87,"height":1042,"fill":89},[91,1968,1971],{"x":1969,"y":1970,"fill":95,"style":96},"20","32","Where 87 seconds of one lifecycle test goes",[91,1973,1976],{"x":1974,"y":1476,"fill":95,"style":1975},"196","text-anchor:end;font-size:11;font-family:var(--font-sans);","create test clock",[84,1978],{"x":1498,"y":1041,"width":1979,"height":1980,"rx":1981,"fill":103},"38","22","3",[91,1983,1986],{"x":1984,"y":1476,"fill":95,"style":1985},"258","text-anchor:start;font-size:11;font-family:var(--font-sans);","2s",[91,1988,1990],{"x":1974,"y":1989,"fill":95,"style":1975},"104","customer + subscription",[84,1992],{"x":1498,"y":1528,"width":1993,"height":1980,"rx":1981,"fill":103},"57",[91,1995,1997],{"x":1996,"y":1989,"fill":95,"style":1985},"277","3s",[91,1999,2001],{"x":1974,"y":2000,"fill":95,"style":1975},"136","advance to trial end",[84,2003],{"x":1498,"y":1005,"width":2004,"height":1980,"rx":1981,"fill":199},"418",[91,2006,2008],{"x":2007,"y":2000,"fill":95,"style":1985},"638","22s",[91,2010,2012],{"x":1974,"y":2011,"fill":95,"style":1975},"168","advance to retry 1",[84,2014],{"x":1498,"y":2015,"width":2016,"height":1980,"rx":1981,"fill":199},"152","399",[91,2018,139],{"x":2019,"y":2011,"fill":95,"style":1985},"619",[91,2021,2022],{"x":1974,"y":171,"fill":95,"style":1975},"advance to retry 2",[84,2024],{"x":1498,"y":2025,"width":2026,"height":1980,"rx":1981,"fill":199},"184","437",[91,2028,2030],{"x":2029,"y":171,"fill":95,"style":1985},"657","23s",[91,2032,2034],{"x":1974,"y":2033,"fill":95,"style":1975},"232","webhook settle",[84,2036],{"x":1498,"y":2037,"width":2038,"height":1980,"rx":1981,"fill":1077},"216","228",[91,2040,2042],{"x":2041,"y":2033,"fill":95,"style":1985},"448","12s",[91,2044,2046],{"x":1974,"y":2045,"fill":95,"style":1975},"264","teardown",[84,2048],{"x":1498,"y":1548,"width":128,"height":1980,"rx":1981,"fill":168},[91,2050,2052],{"x":2051,"y":2045,"fill":95,"style":1985},"296","4s",[99,2054],{"x1":1498,"y1":2055,"x2":1498,"y2":2056,"stroke":153,"style":1009},"52","276",[14,2058,2059,2060,2063,2064,2067,2068,2071],{},"That shape dictates the wiring. Do not put test clocks on every pull request; put them behind a marker and run them on merges to the main branch and on a nightly schedule. Mark them ",[27,2061,2062],{},"@pytest.mark.billing",", deselect that marker in the fast job, and run ",[27,2065,2066],{},"pytest -m billing"," in a separate job with a longer timeout. Because the runtime is dominated by waiting, run the suite with ",[27,2069,2070],{},"pytest-xdist"," — six clock tests in parallel finish in roughly the time of the slowest one, since each test owns its own clock and its own customer.",[14,2073,2074,2075,1408],{},"Two operational details save real pain. Name every clock after the commit SHA and the test so an abandoned clock is traceable, and always delete clocks in a fixture teardown — Stripe caps how many test clocks an account holds at once, and a leaked clock from a cancelled job blocks the next run. Use a restricted test-mode key stored in your CI secret store, never in the repo. If your suite writes usage records too, the same harness verifies the metering path described in ",[18,2076,2078],{"href":2077},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fstripe-metered-billing-configuration\u002F","Stripe metered billing configuration",[32,2080],{},[35,2082,2084],{"id":2083},"when-to-use-test-clocks-and-when-not-to","When to use test clocks, and when not to",[14,2086,2087],{},"Use them the moment recurring revenue depends on your code: any subscription, trial, metered plan, or seat-based tier where an automated transition changes what a customer can call. Use them before you change billing logic — a pricing migration, a new tier, a switch from monthly to annual — because that is exactly when a fixture-only suite gives false confidence. Use them if a billing bug would cost you more than a few hours of engineering time to detect from support tickets.",[14,2089,2090,2091,2095],{},"Skip them if you charge one-off payments with no schedule; a checkout session either succeeds or it does not, and no simulated time helps. Skip them for pure handler branch coverage, where fixtures are 500 times faster. Skip them in the pre-commit hook and on every push, because a suite that adds ninety seconds to every iteration gets disabled within a week. And do not reach for them before you have a working webhook receiver with idempotency in place — test clocks generate duplicate-looking events readily, so start with a ",[18,2092,2094],{"href":2093},"\u002Fautomating-side-hustle-operations-with-apis\u002Fprocessing-webhooks-with-python\u002Fbuilding-an-idempotent-webhook-receiver\u002F","reliable idempotent receiver"," and then prove it under simulated time.",[32,2097],{},[35,2099,2101],{"id":2100},"moving-from-fixture-based-billing-tests","Moving from fixture-based billing tests",[14,2103,2104,2105,2108,2109,2112,2113,2116],{},"The transition is incremental and takes an afternoon. First, add the clock helpers above as a small module and one ",[27,2106,2107],{},"clock"," fixture with guaranteed teardown. Second, pick your single highest-value scenario — usually trial to first successful renewal — and write it end to end, keeping every existing fixture test untouched. Third, run the Stripe CLI as a service in the CI job and wire its printed signing secret into the app's environment so signature verification stays live. Fourth, add the polling helper and replace every bare ",[27,2110,2111],{},"assert"," on post-webhook state with a ",[27,2114,2115],{},"wait_for"," call. Fifth, add the dunning path, then a plan upgrade with proration. Only then consider deleting fixtures — and delete only the ones whose scenario the clock suite now covers end to end, since fixtures still earn their keep for malformed payloads and error branches that Stripe will never send you on demand.",[32,2118],{},[35,2120,2122],{"id":2121},"builder-verdict","Builder verdict",[14,2124,2125],{},"Test clocks win, and it is not close, for anything with a recurring charge. One suite of five or six lifecycle tests — trial conversion, successful renewal, declined renewal into dunning, recovery after a card update, cancellation at period end — costs about ninety seconds per CI run and catches the class of bug that costs actual revenue. Fixtures cannot catch it, because fixtures encode your assumption about Stripe rather than Stripe's behaviour. The right shape is layered: keep fast fixture tests for handler branches, keep respx for the outbound calls, and add one marked, nightly-plus-merge test-clock job that walks a real subscription through a simulated year. Ninety seconds of runner time against a single missed renewal that silently kept serving a suspended account is the easiest trade in your billing stack.",[32,2127],{},[35,2129,2131],{"id":2130},"faq","FAQ",[14,2133,2134,2138],{},[2135,2136,2137],"strong",{},"Do Stripe test clocks cost anything to run?","\nNo. Test clocks exist only in test mode and consume no live API quota or transaction fees, so the entire cost is CI runner minutes. A five-test lifecycle suite at roughly ninety seconds per run on a nightly plus per-merge schedule is a few dollars a month on any hosted CI plan — trivially cheaper than one missed renewal.",[14,2140,2141,2144],{},[2135,2142,2143],{},"Can I use test clocks against my live Stripe account?","\nNo, and that is deliberate. Clocks are test-mode only, so your suite needs a separate test-mode secret key. Use a restricted key scoped to customers, subscriptions, invoices and test helpers, keep it in the CI secret store, and rotate it on the same schedule as the rest of your credentials so a leaked test key never becomes a standing liability.",[14,2146,2147,2150,2151,2153],{},[2135,2148,2149],{},"How do I stop test clocks slowing down every pull request?","\nMark them with a pytest marker, deselect that marker in the fast per-push job, and run ",[27,2152,2066],{}," on merges to the main branch and nightly. Parallelize with pytest-xdist since each test owns its own clock and spends its time waiting, so six tests finish in roughly the wall time of the slowest one.",[14,2155,2156,2159],{},[2135,2157,2158],{},"What is the migration risk if I switch a plan or price?","\nThat is precisely when clocks pay for themselves. Before changing a price, run the existing lifecycle suite against the new price ID to see how proration, invoice totals and status transitions actually behave, then keep both scenarios in the suite until every customer has moved. Doing this catches double-charging and lost-access bugs before a customer finds them.",[14,2161,2162,2165],{},[2135,2163,2164],{},"Do I still need mocked Stripe fixtures once I have test clocks?","\nYes, for a narrower job. Fixtures stay fast and let you exercise malformed payloads, duplicate deliveries and error branches Stripe will not produce on demand. Clocks cover the timeline and event ordering. Delete a fixture only when a clock test covers the same scenario end to end.",[35,2167,2169],{"id":2168},"related","Related",[2171,2172,2173,2179,2185,2192,2198],"ul",{},[2174,2175,2176,2178],"li",{},[18,2177,21],{"href":20}," — the parent guide covering the full billing integration this suite tests.",[2174,2180,2181,2184],{},[18,2182,2183],{"href":965},"Handling Failed Payments and Dunning"," — the state machine the dunning clock test asserts against.",[2174,2186,2187,2191],{},[18,2188,2190],{"href":2189},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","Testing Python APIs with pytest"," — fixture design, markers and CI layering for the rest of your suite.",[2174,2193,2194,2197],{},[18,2195,2196],{"href":227},"Mocking External APIs with respx"," — the fast layer that complements clocks for outbound Stripe calls.",[2174,2199,2200,2203],{},[18,2201,2202],{"href":2093},"Building an Idempotent Webhook Receiver"," — prerequisite reliability work before simulating a year of events.",[2205,2206,2207],"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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .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":315,"searchDepth":332,"depth":332,"links":2209},[2210,2211,2212,2213,2214,2215,2216,2217,2218,2219,2220],{"id":37,"depth":332,"text":38},{"id":220,"depth":332,"text":221},{"id":304,"depth":332,"text":305},{"id":954,"depth":332,"text":955},{"id":1413,"depth":332,"text":1414},{"id":1948,"depth":332,"text":1949},{"id":2083,"depth":332,"text":2084},{"id":2100,"depth":332,"text":2101},{"id":2121,"depth":332,"text":2122},{"id":2130,"depth":332,"text":2131},{"id":2168,"depth":332,"text":2169},"Simulate a year of Stripe billing in seconds: create test clocks, advance through renewals and dunning, assert webhook side effects, and run the suite in CI.","md",{"pageTitle":2224,"type":2225,"datePublished":2226,"dateModified":2226},"Testing Stripe Integrations with Test Clocks in Python","article","2026-07-23","\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks",{"title":5,"description":2221},"building-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002Ftesting-stripe-integrations-with-test-clocks\u002Findex","S06B0hucLOCYQIW0Co-rg388wRvU3RJ-r_YOy3XdUvA",{"@context":2232,"@type":2233,"mainEntity":2234},"https:\u002F\u002Fschema.org","FAQPage",[2235,2240,2243,2246,2249],{"@type":2236,"name":2137,"acceptedAnswer":2237},"Question",{"@type":2238,"text":2239},"Answer","No. Test clocks exist only in test mode and consume no live API quota or transaction fees, so the entire cost is CI runner minutes. A five-test lifecycle suite at roughly ninety seconds per run on a nightly plus per-merge schedule is a few dollars a month on any hosted CI plan — trivially cheaper than one missed renewal.",{"@type":2236,"name":2143,"acceptedAnswer":2241},{"@type":2238,"text":2242},"No, and that is deliberate. Clocks are test-mode only, so your suite needs a separate test-mode secret key. Use a restricted key scoped to customers, subscriptions, invoices and test helpers, keep it in the CI secret store, and rotate it on the same schedule as the rest of your credentials so a leaked test key never becomes a standing liability.",{"@type":2236,"name":2149,"acceptedAnswer":2244},{"@type":2238,"text":2245},"Mark them with a pytest marker, deselect that marker in the fast per-push job, and run pytest -m billing on merges to the main branch and nightly. Parallelize with pytest-xdist since each test owns its own clock and spends its time waiting, so six tests finish in roughly the wall time of the slowest one.",{"@type":2236,"name":2158,"acceptedAnswer":2247},{"@type":2238,"text":2248},"That is precisely when clocks pay for themselves. Before changing a price, run the existing lifecycle suite against the new price ID to see how proration, invoice totals and status transitions actually behave, then keep both scenarios in the suite until every customer has moved. Doing this catches double-charging and lost-access bugs before a customer finds them.",{"@type":2236,"name":2164,"acceptedAnswer":2250},{"@type":2238,"text":2251},"Yes, for a narrower job. Fixtures stay fast and let you exercise malformed payloads, duplicate deliveries and error branches Stripe will not produce on demand. Clocks cover the timeline and event ordering. Delete a fixture only when a clock test covers the same scenario end to end.",1784887028504]