[{"data":1,"prerenderedAt":1781},["ShallowReactive",2],{"page-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fbuilding-api-marketplaces\u002Fpython-api-subscription-billing-tutorial\u002F":3,"faq-schema-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fbuilding-api-marketplaces\u002Fpython-api-subscription-billing-tutorial\u002F":1763},{"id":4,"title":5,"body":6,"description":16,"extension":1757,"meta":1758,"navigation":221,"path":1759,"seo":1760,"stem":1761,"__hash__":1762},"content\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fbuilding-api-marketplaces\u002Fpython-api-subscription-billing-tutorial\u002Findex.md","Python API Subscription Billing Tutorial: Stripe Integration & Usage Tracking",{"type":7,"value":8,"toc":1748},"minimark",[9,13,17,23,39,44,66,106,109,145,156,160,168,664,674,678,681,1060,1066,1070,1081,1563,1577,1581,1587,1592,1613,1618,1649,1653,1697,1701,1715,1725,1731,1744],[10,11,5],"h1",{"id":12},"python-api-subscription-billing-tutorial-stripe-integration-usage-tracking",[14,15,16],"p",{},"Implementing subscription billing for a Python API requires more than just a payment form. You need cryptographic security, idempotent event processing, and real-time quota enforcement to prevent revenue leakage and unauthorized access. This guide delivers a production-ready architecture for tiered subscriptions and metered usage tracking using FastAPI, Stripe, and Redis.",[14,18,19],{},[20,21,22],"strong",{},"Key Implementation Pillars:",[24,25,26,30,33,36],"ul",{},[27,28,29],"li",{},"Zero-config checkout sessions mapped to internal pricing tiers",[27,31,32],{},"Cryptographically secure webhook signature validation",[27,34,35],{},"Idempotent event handling to eliminate duplicate provisioning",[27,37,38],{},"Redis-backed atomic usage counters with hard quota enforcement",[40,41,43],"h2",{"id":42},"_1-architecture-prerequisites","1. Architecture & Prerequisites",[14,45,46,47,51,52,51,55,51,58,61,62,65],{},"Before writing billing logic, establish a resilient stack. You will need ",[48,49,50],"code",{},"fastapi",", ",[48,53,54],{},"uvicorn",[48,56,57],{},"stripe",[48,59,60],{},"redis",", and ",[48,63,64],{},"pydantic",".",[67,68,73],"pre",{"className":69,"code":70,"language":71,"meta":72,"style":72},"language-bash shiki shiki-themes github-light github-dark","pip install fastapi uvicorn stripe redis pydantic python-dotenv\n","bash","",[48,74,75],{"__ignoreMap":72},[76,77,80,84,88,91,94,97,100,103],"span",{"class":78,"line":79},"line",1,[76,81,83],{"class":82},"sScJk","pip",[76,85,87],{"class":86},"sZZnC"," install",[76,89,90],{"class":86}," fastapi",[76,92,93],{"class":86}," uvicorn",[76,95,96],{"class":86}," stripe",[76,98,99],{"class":86}," redis",[76,101,102],{"class":86}," pydantic",[76,104,105],{"class":86}," python-dotenv\n",[14,107,108],{},"Configure your environment variables securely. Never hardcode secrets or price IDs.",[67,110,114],{"className":111,"code":112,"language":113,"meta":72,"style":72},"language-env shiki shiki-themes github-light github-dark","STRIPE_SECRET_KEY=sk_test_...\nSTRIPE_WEBHOOK_SECRET=whsec_...\nREDIS_URL=redis:\u002F\u002Flocalhost:6379\u002F0\nPRICE_STARTER=price_1Nq...\nPRICE_PRO=price_1Nr...\n","env",[48,115,116,121,127,133,139],{"__ignoreMap":72},[76,117,118],{"class":78,"line":79},[76,119,120],{},"STRIPE_SECRET_KEY=sk_test_...\n",[76,122,124],{"class":78,"line":123},2,[76,125,126],{},"STRIPE_WEBHOOK_SECRET=whsec_...\n",[76,128,130],{"class":78,"line":129},3,[76,131,132],{},"REDIS_URL=redis:\u002F\u002Flocalhost:6379\u002F0\n",[76,134,136],{"class":78,"line":135},4,[76,137,138],{},"PRICE_STARTER=price_1Nq...\n",[76,140,142],{"class":78,"line":141},5,[76,143,144],{},"PRICE_PRO=price_1Nr...\n",[14,146,147,148,151,152,155],{},"Redis serves two critical roles here: atomic request counting via ",[48,149,150],{},"INCR"," and distributed idempotency tracking for webhook events. Ensure your Redis instance is configured with appropriate eviction policies (",[48,153,154],{},"maxmemory-policy allkeys-lru",") to prevent memory bloat from stale event IDs.",[40,157,159],{"id":158},"_2-implementing-tiered-subscription-checkout","2. Implementing Tiered Subscription Checkout",[14,161,162,163,65],{},"Create a dedicated FastAPI route to generate Stripe Checkout sessions. Attach tenant metadata so you can map successful payments back to your internal user records. This checkout flow aligns with broader monetization architecture: ",[164,165,167],"a",{"href":166},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002F","Building & Monetizing API-Driven Micro-SaaS",[67,169,173],{"className":170,"code":171,"language":172,"meta":72,"style":72},"language-python shiki shiki-themes github-light github-dark","import os\nimport stripe\nfrom fastapi import APIRouter, HTTPException, Request\nfrom pydantic import BaseModel\n\nrouter = APIRouter()\nstripe.api_key = os.getenv(\"STRIPE_SECRET_KEY\")\n\nclass SubscribeRequest(BaseModel):\n user_id: str\n tier: str # 'starter' or 'pro'\n\n@router.post(\"\u002Fbilling\u002Fsubscribe\")\nasync def create_checkout(req: SubscribeRequest):\n price_map = {\n \"starter\": os.getenv(\"PRICE_STARTER\"),\n \"pro\": os.getenv(\"PRICE_PRO\")\n }\n \n if req.tier not in price_map:\n raise HTTPException(status_code=400, detail=\"Invalid pricing tier\")\n \n try:\n session = stripe.checkout.Session.create(\n payment_method_types=[\"card\"],\n line_items=[{\"price\": price_map[req.tier], \"quantity\": 1}],\n mode=\"subscription\",\n success_url=f\"https:\u002F\u002Fapi.yourdomain.com\u002Fbilling\u002Fsuccess?session_id={{CHECKOUT_SESSION_ID}}\",\n cancel_url=\"https:\u002F\u002Fapi.yourdomain.com\u002Fbilling\u002Fcancel\",\n metadata={\"user_id\": req.user_id},\n # Enforce 30s timeout for API calls\n timeout=30\n )\n return {\"checkout_url\": session.url}\n except stripe.error.StripeError as e:\n raise HTTPException(status_code=502, detail=f\"Stripe API failure: {str(e)}\")\n","python",[48,174,175,185,192,205,217,223,235,252,257,275,285,298,303,316,331,342,357,370,376,382,400,430,435,444,455,472,501,515,543,556,573,579,590,596,611,626],{"__ignoreMap":72},[76,176,177,181],{"class":78,"line":79},[76,178,180],{"class":179},"szBVR","import",[76,182,184],{"class":183},"sVt8B"," os\n",[76,186,187,189],{"class":78,"line":123},[76,188,180],{"class":179},[76,190,191],{"class":183}," stripe\n",[76,193,194,197,200,202],{"class":78,"line":129},[76,195,196],{"class":179},"from",[76,198,199],{"class":183}," fastapi ",[76,201,180],{"class":179},[76,203,204],{"class":183}," APIRouter, HTTPException, Request\n",[76,206,207,209,212,214],{"class":78,"line":135},[76,208,196],{"class":179},[76,210,211],{"class":183}," pydantic ",[76,213,180],{"class":179},[76,215,216],{"class":183}," BaseModel\n",[76,218,219],{"class":78,"line":141},[76,220,222],{"emptyLinePlaceholder":221},true,"\n",[76,224,226,229,232],{"class":78,"line":225},6,[76,227,228],{"class":183},"router ",[76,230,231],{"class":179},"=",[76,233,234],{"class":183}," APIRouter()\n",[76,236,238,241,243,246,249],{"class":78,"line":237},7,[76,239,240],{"class":183},"stripe.api_key ",[76,242,231],{"class":179},[76,244,245],{"class":183}," os.getenv(",[76,247,248],{"class":86},"\"STRIPE_SECRET_KEY\"",[76,250,251],{"class":183},")\n",[76,253,255],{"class":78,"line":254},8,[76,256,222],{"emptyLinePlaceholder":221},[76,258,260,263,266,269,272],{"class":78,"line":259},9,[76,261,262],{"class":179},"class",[76,264,265],{"class":82}," SubscribeRequest",[76,267,268],{"class":183},"(",[76,270,271],{"class":82},"BaseModel",[76,273,274],{"class":183},"):\n",[76,276,278,281],{"class":78,"line":277},10,[76,279,280],{"class":183}," user_id: ",[76,282,284],{"class":283},"sj4cs","str\n",[76,286,288,291,294],{"class":78,"line":287},11,[76,289,290],{"class":183}," tier: ",[76,292,293],{"class":283},"str",[76,295,297],{"class":296},"sJ8bj"," # 'starter' or 'pro'\n",[76,299,301],{"class":78,"line":300},12,[76,302,222],{"emptyLinePlaceholder":221},[76,304,306,309,311,314],{"class":78,"line":305},13,[76,307,308],{"class":82},"@router.post",[76,310,268],{"class":183},[76,312,313],{"class":86},"\"\u002Fbilling\u002Fsubscribe\"",[76,315,251],{"class":183},[76,317,319,322,325,328],{"class":78,"line":318},14,[76,320,321],{"class":179},"async",[76,323,324],{"class":179}," def",[76,326,327],{"class":82}," create_checkout",[76,329,330],{"class":183},"(req: SubscribeRequest):\n",[76,332,334,337,339],{"class":78,"line":333},15,[76,335,336],{"class":183}," price_map ",[76,338,231],{"class":179},[76,340,341],{"class":183}," {\n",[76,343,345,348,351,354],{"class":78,"line":344},16,[76,346,347],{"class":86}," \"starter\"",[76,349,350],{"class":183},": os.getenv(",[76,352,353],{"class":86},"\"PRICE_STARTER\"",[76,355,356],{"class":183},"),\n",[76,358,360,363,365,368],{"class":78,"line":359},17,[76,361,362],{"class":86}," \"pro\"",[76,364,350],{"class":183},[76,366,367],{"class":86},"\"PRICE_PRO\"",[76,369,251],{"class":183},[76,371,373],{"class":78,"line":372},18,[76,374,375],{"class":183}," }\n",[76,377,379],{"class":78,"line":378},19,[76,380,381],{"class":183}," \n",[76,383,385,388,391,394,397],{"class":78,"line":384},20,[76,386,387],{"class":179}," if",[76,389,390],{"class":183}," req.tier ",[76,392,393],{"class":179},"not",[76,395,396],{"class":179}," in",[76,398,399],{"class":183}," price_map:\n",[76,401,403,406,409,413,415,418,420,423,425,428],{"class":78,"line":402},21,[76,404,405],{"class":179}," raise",[76,407,408],{"class":183}," HTTPException(",[76,410,412],{"class":411},"s4XuR","status_code",[76,414,231],{"class":179},[76,416,417],{"class":283},"400",[76,419,51],{"class":183},[76,421,422],{"class":411},"detail",[76,424,231],{"class":179},[76,426,427],{"class":86},"\"Invalid pricing tier\"",[76,429,251],{"class":183},[76,431,433],{"class":78,"line":432},22,[76,434,381],{"class":183},[76,436,438,441],{"class":78,"line":437},23,[76,439,440],{"class":179}," try",[76,442,443],{"class":183},":\n",[76,445,447,450,452],{"class":78,"line":446},24,[76,448,449],{"class":183}," session ",[76,451,231],{"class":179},[76,453,454],{"class":183}," stripe.checkout.Session.create(\n",[76,456,458,461,463,466,469],{"class":78,"line":457},25,[76,459,460],{"class":411}," payment_method_types",[76,462,231],{"class":179},[76,464,465],{"class":183},"[",[76,467,468],{"class":86},"\"card\"",[76,470,471],{"class":183},"],\n",[76,473,475,478,480,483,486,489,492,495,498],{"class":78,"line":474},26,[76,476,477],{"class":411}," line_items",[76,479,231],{"class":179},[76,481,482],{"class":183},"[{",[76,484,485],{"class":86},"\"price\"",[76,487,488],{"class":183},": price_map[req.tier], ",[76,490,491],{"class":86},"\"quantity\"",[76,493,494],{"class":183},": ",[76,496,497],{"class":283},"1",[76,499,500],{"class":183},"}],\n",[76,502,504,507,509,512],{"class":78,"line":503},27,[76,505,506],{"class":411}," mode",[76,508,231],{"class":179},[76,510,511],{"class":86},"\"subscription\"",[76,513,514],{"class":183},",\n",[76,516,518,521,523,526,529,532,535,538,541],{"class":78,"line":517},28,[76,519,520],{"class":411}," success_url",[76,522,231],{"class":179},[76,524,525],{"class":179},"f",[76,527,528],{"class":86},"\"https:\u002F\u002Fapi.yourdomain.com\u002Fbilling\u002Fsuccess?session_id=",[76,530,531],{"class":283},"{{",[76,533,534],{"class":86},"CHECKOUT_SESSION_ID",[76,536,537],{"class":283},"}}",[76,539,540],{"class":86},"\"",[76,542,514],{"class":183},[76,544,546,549,551,554],{"class":78,"line":545},29,[76,547,548],{"class":411}," cancel_url",[76,550,231],{"class":179},[76,552,553],{"class":86},"\"https:\u002F\u002Fapi.yourdomain.com\u002Fbilling\u002Fcancel\"",[76,555,514],{"class":183},[76,557,559,562,564,567,570],{"class":78,"line":558},30,[76,560,561],{"class":411}," metadata",[76,563,231],{"class":179},[76,565,566],{"class":183},"{",[76,568,569],{"class":86},"\"user_id\"",[76,571,572],{"class":183},": req.user_id},\n",[76,574,576],{"class":78,"line":575},31,[76,577,578],{"class":296}," # Enforce 30s timeout for API calls\n",[76,580,582,585,587],{"class":78,"line":581},32,[76,583,584],{"class":411}," timeout",[76,586,231],{"class":179},[76,588,589],{"class":283},"30\n",[76,591,593],{"class":78,"line":592},33,[76,594,595],{"class":183}," )\n",[76,597,599,602,605,608],{"class":78,"line":598},34,[76,600,601],{"class":179}," return",[76,603,604],{"class":183}," {",[76,606,607],{"class":86},"\"checkout_url\"",[76,609,610],{"class":183},": session.url}\n",[76,612,614,617,620,623],{"class":78,"line":613},35,[76,615,616],{"class":179}," except",[76,618,619],{"class":183}," stripe.error.StripeError ",[76,621,622],{"class":179},"as",[76,624,625],{"class":183}," e:\n",[76,627,629,631,633,635,637,640,642,644,646,648,651,654,657,660,662],{"class":78,"line":628},36,[76,630,405],{"class":179},[76,632,408],{"class":183},[76,634,412],{"class":411},[76,636,231],{"class":179},[76,638,639],{"class":283},"502",[76,641,51],{"class":183},[76,643,422],{"class":411},[76,645,231],{"class":179},[76,647,525],{"class":179},[76,649,650],{"class":86},"\"Stripe API failure: ",[76,652,653],{"class":283},"{str",[76,655,656],{"class":183},"(e)",[76,658,659],{"class":283},"}",[76,661,540],{"class":86},[76,663,251],{"class":183},[14,665,666,669,670,673],{},[20,667,668],{},"Why this works:"," The route validates input via Pydantic, maps tiers securely from environment variables, and attaches ",[48,671,672],{},"user_id"," to Stripe metadata. This metadata is critical for post-payment provisioning.",[40,675,677],{"id":676},"_3-metered-usage-tracking-quota-enforcement","3. Metered Usage Tracking & Quota Enforcement",[14,679,680],{},"Hard limits prevent abuse, while soft limits require graceful degradation. Use Redis middleware to intercept requests, atomically increment usage, and enforce quotas before route execution.",[67,682,684],{"className":170,"code":683,"language":172,"meta":72,"style":72},"import redis.asyncio as redis\nfrom fastapi import Request, HTTPException\nfrom fastapi.responses import JSONResponse\n\n# Initialize Redis client with connection pooling\nredis_client = redis.Redis.from_url(os.getenv(\"REDIS_URL\"), decode_responses=True)\n\nasync def usage_middleware(request: Request, call_next):\n user_id = getattr(request.state, \"user_id\", None)\n if not user_id:\n return await call_next(request)\n \n # Fetch tier limit from DB\u002Fcache (example: 1000 req\u002Fday for starter)\n limit = await get_user_daily_limit(user_id) \n \n try:\n # Atomic increment with 24h TTL to prevent memory leaks\n current = await redis_client.incr(f\"usage:{user_id}\")\n if current == 1:\n await redis_client.expire(f\"usage:{user_id}\", 86400)\n \n if current > limit:\n # Log overage for Stripe UsageRecord sync later\n await log_overage_event(user_id, current - limit)\n return JSONResponse(\n status_code=402,\n content={\"error\": \"Quota exceeded. Upgrade your plan or wait for reset.\"}\n )\n \n except redis.ConnectionError:\n # Fail open or closed based on business logic. \n # Here we fail closed to protect infrastructure.\n return JSONResponse(status_code=503, content={\"error\": \"Usage tracking unavailable\"})\n \n return await call_next(request)\n",[48,685,686,698,709,721,725,730,756,760,772,794,804,814,818,823,835,839,845,850,877,891,917,921,933,938,951,958,970,990,994,998,1005,1010,1015,1048,1052],{"__ignoreMap":72},[76,687,688,690,693,695],{"class":78,"line":79},[76,689,180],{"class":179},[76,691,692],{"class":183}," redis.asyncio ",[76,694,622],{"class":179},[76,696,697],{"class":183}," redis\n",[76,699,700,702,704,706],{"class":78,"line":123},[76,701,196],{"class":179},[76,703,199],{"class":183},[76,705,180],{"class":179},[76,707,708],{"class":183}," Request, HTTPException\n",[76,710,711,713,716,718],{"class":78,"line":129},[76,712,196],{"class":179},[76,714,715],{"class":183}," fastapi.responses ",[76,717,180],{"class":179},[76,719,720],{"class":183}," JSONResponse\n",[76,722,723],{"class":78,"line":135},[76,724,222],{"emptyLinePlaceholder":221},[76,726,727],{"class":78,"line":141},[76,728,729],{"class":296},"# Initialize Redis client with connection pooling\n",[76,731,732,735,737,740,743,746,749,751,754],{"class":78,"line":225},[76,733,734],{"class":183},"redis_client ",[76,736,231],{"class":179},[76,738,739],{"class":183}," redis.Redis.from_url(os.getenv(",[76,741,742],{"class":86},"\"REDIS_URL\"",[76,744,745],{"class":183},"), ",[76,747,748],{"class":411},"decode_responses",[76,750,231],{"class":179},[76,752,753],{"class":283},"True",[76,755,251],{"class":183},[76,757,758],{"class":78,"line":237},[76,759,222],{"emptyLinePlaceholder":221},[76,761,762,764,766,769],{"class":78,"line":254},[76,763,321],{"class":179},[76,765,324],{"class":179},[76,767,768],{"class":82}," usage_middleware",[76,770,771],{"class":183},"(request: Request, call_next):\n",[76,773,774,777,779,782,785,787,789,792],{"class":78,"line":259},[76,775,776],{"class":183}," user_id ",[76,778,231],{"class":179},[76,780,781],{"class":283}," getattr",[76,783,784],{"class":183},"(request.state, ",[76,786,569],{"class":86},[76,788,51],{"class":183},[76,790,791],{"class":283},"None",[76,793,251],{"class":183},[76,795,796,798,801],{"class":78,"line":277},[76,797,387],{"class":179},[76,799,800],{"class":179}," not",[76,802,803],{"class":183}," user_id:\n",[76,805,806,808,811],{"class":78,"line":287},[76,807,601],{"class":179},[76,809,810],{"class":179}," await",[76,812,813],{"class":183}," call_next(request)\n",[76,815,816],{"class":78,"line":300},[76,817,381],{"class":183},[76,819,820],{"class":78,"line":305},[76,821,822],{"class":296}," # Fetch tier limit from DB\u002Fcache (example: 1000 req\u002Fday for starter)\n",[76,824,825,828,830,832],{"class":78,"line":318},[76,826,827],{"class":183}," limit ",[76,829,231],{"class":179},[76,831,810],{"class":179},[76,833,834],{"class":183}," get_user_daily_limit(user_id) \n",[76,836,837],{"class":78,"line":333},[76,838,381],{"class":183},[76,840,841,843],{"class":78,"line":344},[76,842,440],{"class":179},[76,844,443],{"class":183},[76,846,847],{"class":78,"line":359},[76,848,849],{"class":296}," # Atomic increment with 24h TTL to prevent memory leaks\n",[76,851,852,855,857,859,862,864,867,869,871,873,875],{"class":78,"line":372},[76,853,854],{"class":183}," current ",[76,856,231],{"class":179},[76,858,810],{"class":179},[76,860,861],{"class":183}," redis_client.incr(",[76,863,525],{"class":179},[76,865,866],{"class":86},"\"usage:",[76,868,566],{"class":283},[76,870,672],{"class":183},[76,872,659],{"class":283},[76,874,540],{"class":86},[76,876,251],{"class":183},[76,878,879,881,883,886,889],{"class":78,"line":378},[76,880,387],{"class":179},[76,882,854],{"class":183},[76,884,885],{"class":179},"==",[76,887,888],{"class":283}," 1",[76,890,443],{"class":183},[76,892,893,895,898,900,902,904,906,908,910,912,915],{"class":78,"line":384},[76,894,810],{"class":179},[76,896,897],{"class":183}," redis_client.expire(",[76,899,525],{"class":179},[76,901,866],{"class":86},[76,903,566],{"class":283},[76,905,672],{"class":183},[76,907,659],{"class":283},[76,909,540],{"class":86},[76,911,51],{"class":183},[76,913,914],{"class":283},"86400",[76,916,251],{"class":183},[76,918,919],{"class":78,"line":402},[76,920,381],{"class":183},[76,922,923,925,927,930],{"class":78,"line":432},[76,924,387],{"class":179},[76,926,854],{"class":183},[76,928,929],{"class":179},">",[76,931,932],{"class":183}," limit:\n",[76,934,935],{"class":78,"line":437},[76,936,937],{"class":296}," # Log overage for Stripe UsageRecord sync later\n",[76,939,940,942,945,948],{"class":78,"line":446},[76,941,810],{"class":179},[76,943,944],{"class":183}," log_overage_event(user_id, current ",[76,946,947],{"class":179},"-",[76,949,950],{"class":183}," limit)\n",[76,952,953,955],{"class":78,"line":457},[76,954,601],{"class":179},[76,956,957],{"class":183}," JSONResponse(\n",[76,959,960,963,965,968],{"class":78,"line":474},[76,961,962],{"class":411}," status_code",[76,964,231],{"class":179},[76,966,967],{"class":283},"402",[76,969,514],{"class":183},[76,971,972,975,977,979,982,984,987],{"class":78,"line":503},[76,973,974],{"class":411}," content",[76,976,231],{"class":179},[76,978,566],{"class":183},[76,980,981],{"class":86},"\"error\"",[76,983,494],{"class":183},[76,985,986],{"class":86},"\"Quota exceeded. Upgrade your plan or wait for reset.\"",[76,988,989],{"class":183},"}\n",[76,991,992],{"class":78,"line":517},[76,993,595],{"class":183},[76,995,996],{"class":78,"line":545},[76,997,381],{"class":183},[76,999,1000,1002],{"class":78,"line":558},[76,1001,616],{"class":179},[76,1003,1004],{"class":183}," redis.ConnectionError:\n",[76,1006,1007],{"class":78,"line":575},[76,1008,1009],{"class":296}," # Fail open or closed based on business logic. \n",[76,1011,1012],{"class":78,"line":581},[76,1013,1014],{"class":296}," # Here we fail closed to protect infrastructure.\n",[76,1016,1017,1019,1022,1024,1026,1029,1031,1034,1036,1038,1040,1042,1045],{"class":78,"line":592},[76,1018,601],{"class":179},[76,1020,1021],{"class":183}," JSONResponse(",[76,1023,412],{"class":411},[76,1025,231],{"class":179},[76,1027,1028],{"class":283},"503",[76,1030,51],{"class":183},[76,1032,1033],{"class":411},"content",[76,1035,231],{"class":179},[76,1037,566],{"class":183},[76,1039,981],{"class":86},[76,1041,494],{"class":183},[76,1043,1044],{"class":86},"\"Usage tracking unavailable\"",[76,1046,1047],{"class":183},"})\n",[76,1049,1050],{"class":78,"line":598},[76,1051,381],{"class":183},[76,1053,1054,1056,1058],{"class":78,"line":613},[76,1055,601],{"class":179},[76,1057,810],{"class":179},[76,1059,813],{"class":183},[14,1061,1062,1065],{},[20,1063,1064],{},"Production Note:"," Do not call the Stripe UsageRecord API on every request. It will throttle your API and add unacceptable latency. Instead, batch-sync Redis counters to Stripe hourly using a background worker (Celery\u002FRQ).",[40,1067,1069],{"id":1068},"_4-webhook-handling-idempotent-event-processing","4. Webhook Handling & Idempotent Event Processing",[14,1071,1072,1073,1076,1077,65],{},"Stripe retries failed webhook deliveries. Without idempotency, you will double-provision access or trigger duplicate billing events. Verify the ",[48,1074,1075],{},"stripe-signature"," header and store processed event IDs in Redis. This idempotent processing pattern scales efficiently across multi-tenant environments like ",[164,1078,1080],{"href":1079},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fbuilding-api-marketplaces\u002F","Building API Marketplaces",[67,1082,1084],{"className":170,"code":1083,"language":172,"meta":72,"style":72},"import os\nimport stripe\nfrom fastapi import Request, HTTPException\n\n@router.post(\"\u002Fbilling\u002Fwebhook\")\nasync def handle_webhook(request: Request):\n payload = await request.body()\n sig_header = request.headers.get(\"stripe-signature\")\n \n try:\n event = stripe.Webhook.construct_event(\n payload, sig_header, os.getenv(\"STRIPE_WEBHOOK_SECRET\")\n )\n except ValueError:\n raise HTTPException(status_code=400, detail=\"Invalid JSON payload\")\n except stripe.error.SignatureVerificationError:\n raise HTTPException(status_code=400, detail=\"Invalid webhook signature\")\n \n # Idempotency guard\n if await redis_client.sadd(\"processed_webhooks\", event[\"id\"]) == 0:\n return {\"status\": \"already_processed\"}\n \n try:\n if event[\"type\"] == \"customer.subscription.created\":\n user_id = event[\"data\"][\"object\"][\"metadata\"][\"user_id\"]\n await activate_subscription(user_id, event[\"data\"][\"object\"][\"id\"])\n \n elif event[\"type\"] == \"invoice.payment_failed\":\n user_id = event[\"data\"][\"object\"][\"metadata\"][\"user_id\"]\n await downgrade_or_suspend(user_id)\n \n elif event[\"type\"] == \"customer.subscription.deleted\":\n user_id = event[\"data\"][\"object\"][\"metadata\"][\"user_id\"]\n await revoke_api_access(user_id)\n \n except Exception as e:\n # Return 500 to trigger Stripe retry. Do not swallow errors.\n raise HTTPException(status_code=500, detail=str(e))\n \n return {\"status\": \"success\"}\n",[48,1085,1086,1092,1098,1108,1112,1123,1135,1147,1162,1166,1172,1182,1192,1196,1205,1228,1235,1258,1262,1267,1295,1311,1315,1321,1341,1370,1390,1394,1412,1436,1443,1447,1464,1488,1495,1499,1511,1517,1542,1547],{"__ignoreMap":72},[76,1087,1088,1090],{"class":78,"line":79},[76,1089,180],{"class":179},[76,1091,184],{"class":183},[76,1093,1094,1096],{"class":78,"line":123},[76,1095,180],{"class":179},[76,1097,191],{"class":183},[76,1099,1100,1102,1104,1106],{"class":78,"line":129},[76,1101,196],{"class":179},[76,1103,199],{"class":183},[76,1105,180],{"class":179},[76,1107,708],{"class":183},[76,1109,1110],{"class":78,"line":135},[76,1111,222],{"emptyLinePlaceholder":221},[76,1113,1114,1116,1118,1121],{"class":78,"line":141},[76,1115,308],{"class":82},[76,1117,268],{"class":183},[76,1119,1120],{"class":86},"\"\u002Fbilling\u002Fwebhook\"",[76,1122,251],{"class":183},[76,1124,1125,1127,1129,1132],{"class":78,"line":225},[76,1126,321],{"class":179},[76,1128,324],{"class":179},[76,1130,1131],{"class":82}," handle_webhook",[76,1133,1134],{"class":183},"(request: Request):\n",[76,1136,1137,1140,1142,1144],{"class":78,"line":237},[76,1138,1139],{"class":183}," payload ",[76,1141,231],{"class":179},[76,1143,810],{"class":179},[76,1145,1146],{"class":183}," request.body()\n",[76,1148,1149,1152,1154,1157,1160],{"class":78,"line":254},[76,1150,1151],{"class":183}," sig_header ",[76,1153,231],{"class":179},[76,1155,1156],{"class":183}," request.headers.get(",[76,1158,1159],{"class":86},"\"stripe-signature\"",[76,1161,251],{"class":183},[76,1163,1164],{"class":78,"line":259},[76,1165,381],{"class":183},[76,1167,1168,1170],{"class":78,"line":277},[76,1169,440],{"class":179},[76,1171,443],{"class":183},[76,1173,1174,1177,1179],{"class":78,"line":287},[76,1175,1176],{"class":183}," event ",[76,1178,231],{"class":179},[76,1180,1181],{"class":183}," stripe.Webhook.construct_event(\n",[76,1183,1184,1187,1190],{"class":78,"line":300},[76,1185,1186],{"class":183}," payload, sig_header, os.getenv(",[76,1188,1189],{"class":86},"\"STRIPE_WEBHOOK_SECRET\"",[76,1191,251],{"class":183},[76,1193,1194],{"class":78,"line":305},[76,1195,595],{"class":183},[76,1197,1198,1200,1203],{"class":78,"line":318},[76,1199,616],{"class":179},[76,1201,1202],{"class":283}," ValueError",[76,1204,443],{"class":183},[76,1206,1207,1209,1211,1213,1215,1217,1219,1221,1223,1226],{"class":78,"line":333},[76,1208,405],{"class":179},[76,1210,408],{"class":183},[76,1212,412],{"class":411},[76,1214,231],{"class":179},[76,1216,417],{"class":283},[76,1218,51],{"class":183},[76,1220,422],{"class":411},[76,1222,231],{"class":179},[76,1224,1225],{"class":86},"\"Invalid JSON payload\"",[76,1227,251],{"class":183},[76,1229,1230,1232],{"class":78,"line":344},[76,1231,616],{"class":179},[76,1233,1234],{"class":183}," stripe.error.SignatureVerificationError:\n",[76,1236,1237,1239,1241,1243,1245,1247,1249,1251,1253,1256],{"class":78,"line":359},[76,1238,405],{"class":179},[76,1240,408],{"class":183},[76,1242,412],{"class":411},[76,1244,231],{"class":179},[76,1246,417],{"class":283},[76,1248,51],{"class":183},[76,1250,422],{"class":411},[76,1252,231],{"class":179},[76,1254,1255],{"class":86},"\"Invalid webhook signature\"",[76,1257,251],{"class":183},[76,1259,1260],{"class":78,"line":372},[76,1261,381],{"class":183},[76,1263,1264],{"class":78,"line":378},[76,1265,1266],{"class":296}," # Idempotency guard\n",[76,1268,1269,1271,1273,1276,1279,1282,1285,1288,1290,1293],{"class":78,"line":384},[76,1270,387],{"class":179},[76,1272,810],{"class":179},[76,1274,1275],{"class":183}," redis_client.sadd(",[76,1277,1278],{"class":86},"\"processed_webhooks\"",[76,1280,1281],{"class":183},", event[",[76,1283,1284],{"class":86},"\"id\"",[76,1286,1287],{"class":183},"]) ",[76,1289,885],{"class":179},[76,1291,1292],{"class":283}," 0",[76,1294,443],{"class":183},[76,1296,1297,1299,1301,1304,1306,1309],{"class":78,"line":402},[76,1298,601],{"class":179},[76,1300,604],{"class":183},[76,1302,1303],{"class":86},"\"status\"",[76,1305,494],{"class":183},[76,1307,1308],{"class":86},"\"already_processed\"",[76,1310,989],{"class":183},[76,1312,1313],{"class":78,"line":432},[76,1314,381],{"class":183},[76,1316,1317,1319],{"class":78,"line":437},[76,1318,440],{"class":179},[76,1320,443],{"class":183},[76,1322,1323,1325,1328,1331,1334,1336,1339],{"class":78,"line":446},[76,1324,387],{"class":179},[76,1326,1327],{"class":183}," event[",[76,1329,1330],{"class":86},"\"type\"",[76,1332,1333],{"class":183},"] ",[76,1335,885],{"class":179},[76,1337,1338],{"class":86}," \"customer.subscription.created\"",[76,1340,443],{"class":183},[76,1342,1343,1345,1347,1349,1352,1355,1358,1360,1363,1365,1367],{"class":78,"line":457},[76,1344,776],{"class":183},[76,1346,231],{"class":179},[76,1348,1327],{"class":183},[76,1350,1351],{"class":86},"\"data\"",[76,1353,1354],{"class":183},"][",[76,1356,1357],{"class":86},"\"object\"",[76,1359,1354],{"class":183},[76,1361,1362],{"class":86},"\"metadata\"",[76,1364,1354],{"class":183},[76,1366,569],{"class":86},[76,1368,1369],{"class":183},"]\n",[76,1371,1372,1374,1377,1379,1381,1383,1385,1387],{"class":78,"line":474},[76,1373,810],{"class":179},[76,1375,1376],{"class":183}," activate_subscription(user_id, event[",[76,1378,1351],{"class":86},[76,1380,1354],{"class":183},[76,1382,1357],{"class":86},[76,1384,1354],{"class":183},[76,1386,1284],{"class":86},[76,1388,1389],{"class":183},"])\n",[76,1391,1392],{"class":78,"line":503},[76,1393,381],{"class":183},[76,1395,1396,1399,1401,1403,1405,1407,1410],{"class":78,"line":517},[76,1397,1398],{"class":179}," elif",[76,1400,1327],{"class":183},[76,1402,1330],{"class":86},[76,1404,1333],{"class":183},[76,1406,885],{"class":179},[76,1408,1409],{"class":86}," \"invoice.payment_failed\"",[76,1411,443],{"class":183},[76,1413,1414,1416,1418,1420,1422,1424,1426,1428,1430,1432,1434],{"class":78,"line":545},[76,1415,776],{"class":183},[76,1417,231],{"class":179},[76,1419,1327],{"class":183},[76,1421,1351],{"class":86},[76,1423,1354],{"class":183},[76,1425,1357],{"class":86},[76,1427,1354],{"class":183},[76,1429,1362],{"class":86},[76,1431,1354],{"class":183},[76,1433,569],{"class":86},[76,1435,1369],{"class":183},[76,1437,1438,1440],{"class":78,"line":558},[76,1439,810],{"class":179},[76,1441,1442],{"class":183}," downgrade_or_suspend(user_id)\n",[76,1444,1445],{"class":78,"line":575},[76,1446,381],{"class":183},[76,1448,1449,1451,1453,1455,1457,1459,1462],{"class":78,"line":581},[76,1450,1398],{"class":179},[76,1452,1327],{"class":183},[76,1454,1330],{"class":86},[76,1456,1333],{"class":183},[76,1458,885],{"class":179},[76,1460,1461],{"class":86}," \"customer.subscription.deleted\"",[76,1463,443],{"class":183},[76,1465,1466,1468,1470,1472,1474,1476,1478,1480,1482,1484,1486],{"class":78,"line":592},[76,1467,776],{"class":183},[76,1469,231],{"class":179},[76,1471,1327],{"class":183},[76,1473,1351],{"class":86},[76,1475,1354],{"class":183},[76,1477,1357],{"class":86},[76,1479,1354],{"class":183},[76,1481,1362],{"class":86},[76,1483,1354],{"class":183},[76,1485,569],{"class":86},[76,1487,1369],{"class":183},[76,1489,1490,1492],{"class":78,"line":598},[76,1491,810],{"class":179},[76,1493,1494],{"class":183}," revoke_api_access(user_id)\n",[76,1496,1497],{"class":78,"line":613},[76,1498,381],{"class":183},[76,1500,1501,1503,1506,1509],{"class":78,"line":628},[76,1502,616],{"class":179},[76,1504,1505],{"class":283}," Exception",[76,1507,1508],{"class":179}," as",[76,1510,625],{"class":183},[76,1512,1514],{"class":78,"line":1513},37,[76,1515,1516],{"class":296}," # Return 500 to trigger Stripe retry. Do not swallow errors.\n",[76,1518,1520,1522,1524,1526,1528,1531,1533,1535,1537,1539],{"class":78,"line":1519},38,[76,1521,405],{"class":179},[76,1523,408],{"class":183},[76,1525,412],{"class":411},[76,1527,231],{"class":179},[76,1529,1530],{"class":283},"500",[76,1532,51],{"class":183},[76,1534,422],{"class":411},[76,1536,231],{"class":179},[76,1538,293],{"class":283},[76,1540,1541],{"class":183},"(e))\n",[76,1543,1545],{"class":78,"line":1544},39,[76,1546,381],{"class":183},[76,1548,1550,1552,1554,1556,1558,1561],{"class":78,"line":1549},40,[76,1551,601],{"class":179},[76,1553,604],{"class":183},[76,1555,1303],{"class":86},[76,1557,494],{"class":183},[76,1559,1560],{"class":86},"\"success\"",[76,1562,989],{"class":183},[14,1564,1565,1568,1569,1572,1573,1576],{},[20,1566,1567],{},"Security Critical:"," Always return ",[48,1570,1571],{},"200 OK"," only after successful processing. Returning ",[48,1574,1575],{},"200"," on failure tells Stripe to stop retrying, leaving your system in an inconsistent state.",[40,1578,1580],{"id":1579},"_5-deployment-production-troubleshooting","5. Deployment & Production Troubleshooting",[14,1582,1583,1584,65],{},"Deploy your FastAPI app to Render, Vercel, or AWS. Configure your webhook endpoint in the Stripe Dashboard to point to ",[48,1585,1586],{},"https:\u002F\u002Fyourdomain.com\u002Fbilling\u002Fwebhook",[14,1588,1589],{},[20,1590,1591],{},"Local Testing Workflow:",[1593,1594,1595,1601,1607],"ol",{},[27,1596,1597,1598],{},"Install Stripe CLI: ",[48,1599,1600],{},"brew install stripe\u002Fstripe-cli\u002Fstripe",[27,1602,1603,1604],{},"Forward events locally: ",[48,1605,1606],{},"stripe listen --forward-to localhost:8000\u002Fbilling\u002Fwebhook",[27,1608,1609,1610],{},"Trigger test events: ",[48,1611,1612],{},"stripe trigger checkout.session.completed",[14,1614,1615],{},[20,1616,1617],{},"Common Runtime Failures & Fixes:",[24,1619,1620,1630,1639],{},[27,1621,1622,1625,1626,1629],{},[20,1623,1624],{},"Signature Mismatch:"," Ensure ",[48,1627,1628],{},"STRIPE_WEBHOOK_SECRET"," matches the exact endpoint secret in Stripe. Do not use the account-level secret.",[27,1631,1632,1635,1636,1638],{},[20,1633,1634],{},"Timeout Errors:"," Stripe expects a ",[48,1637,1575],{}," response within 10 seconds. Offload heavy provisioning tasks to a background queue.",[27,1640,1641,1644,1645,1648],{},[20,1642,1643],{},"Exponential Backoff:"," Wrap Stripe API calls in retry logic. Use ",[48,1646,1647],{},"tenacity"," or custom decorators with jitter to handle transient network failures gracefully.",[40,1650,1652],{"id":1651},"common-mistakes","Common Mistakes",[24,1654,1655,1661,1671,1685,1691],{},[27,1656,1657,1660],{},[20,1658,1659],{},"Skipping webhook signature verification:"," Leaves your API vulnerable to forged payment events and unauthorized tier upgrades.",[27,1662,1663,1670],{},[20,1664,1665,1666,1669],{},"Ignoring ",[48,1667,1668],{},"invoice.payment_failed"," events:"," Results in continued API access for non-paying customers. Always suspend or downgrade immediately.",[27,1672,1673,1676,1677,1680,1681,1684],{},[20,1674,1675],{},"Synchronous DB calls in middleware:"," Blocks the FastAPI event loop. Use ",[48,1678,1679],{},"redis.asyncio"," or ",[48,1682,1683],{},"asyncpg"," to maintain high throughput.",[27,1686,1687,1690],{},[20,1688,1689],{},"Hardcoding Price IDs:"," Breaks your billing flow when Stripe updates pricing. Always load from environment variables or a config table.",[27,1692,1693,1696],{},[20,1694,1695],{},"Missing idempotency keys:"," Causes duplicate subscription creation and double-charging during webhook retries.",[40,1698,1700],{"id":1699},"faq","FAQ",[14,1702,1703,1706,1707,1710,1711,1714],{},[20,1704,1705],{},"How do I prevent API abuse during the Stripe webhook processing delay?","\nImplement a provisional access state with strict rate limits. Alternatively, use Stripe's ",[48,1708,1709],{},"pending_setup_intent"," status to grant temporary low-tier access until the ",[48,1712,1713],{},"invoice.paid"," event confirms successful payment.",[14,1716,1717,1720,1721,1724],{},[20,1718,1719],{},"What is the most reliable way to handle Stripe webhook retries in Python?","\nAlways check the ",[48,1722,1723],{},"event.id"," against a Redis set or database before executing business logic. Stripe automatically retries failed deliveries; idempotency guarantees duplicate events never trigger duplicate provisioning or billing.",[14,1726,1727,1730],{},[20,1728,1729],{},"Can I track metered API usage directly in Stripe without Redis?","\nYes, but it is not recommended for production. Direct Stripe API calls per request will hit rate limits and add 100-300ms latency. Use Redis for real-time, atomic counting and batch-sync to Stripe Usage Records hourly.",[14,1732,1733,1736,1737,1739,1740,1743],{},[20,1734,1735],{},"How do I test subscription billing locally before deploying?","\nUse the Stripe CLI (",[48,1738,1606],{},") to forward live webhook events to your local FastAPI instance. Combine this with Stripe's test card numbers and ",[48,1741,1742],{},"stripe trigger"," commands to simulate the full subscription lifecycle.",[1745,1746,1747],"style",{},"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);}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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}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 .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":72,"searchDepth":123,"depth":123,"links":1749},[1750,1751,1752,1753,1754,1755,1756],{"id":42,"depth":123,"text":43},{"id":158,"depth":123,"text":159},{"id":676,"depth":123,"text":677},{"id":1068,"depth":123,"text":1069},{"id":1579,"depth":123,"text":1580},{"id":1651,"depth":123,"text":1652},{"id":1699,"depth":123,"text":1700},"md",{},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fbuilding-api-marketplaces\u002Fpython-api-subscription-billing-tutorial",{"title":5,"description":16},"building-monetizing-api-driven-micro-saas\u002Fbuilding-api-marketplaces\u002Fpython-api-subscription-billing-tutorial\u002Findex","uvg0-ucyJLeW-e-V0LYWXJXHOT5oQFt1u5ohzUKRGdI",{"@context":1764,"@type":1765,"mainEntity":1766},"https:\u002F\u002Fschema.org","FAQPage",[1767,1772,1775,1778],{"@type":1768,"name":1705,"acceptedAnswer":1769},"Question",{"@type":1770,"text":1771},"Answer","Implement a provisional access state with strict rate limits. Alternatively, use Stripe's pending_setup_intent status to grant temporary low-tier access until the invoice.paid event confirms successful payment.",{"@type":1768,"name":1719,"acceptedAnswer":1773},{"@type":1770,"text":1774},"Always check the event.id against a Redis set or database before executing business logic. Stripe automatically retries failed deliveries; idempotency guarantees duplicate events never trigger duplicate provisioning or billing.",{"@type":1768,"name":1729,"acceptedAnswer":1776},{"@type":1770,"text":1777},"Yes, but it is not recommended for production. Direct Stripe API calls per request will hit rate limits and add 100-300ms latency. Use Redis for real-time, atomic counting and batch-sync to Stripe Usage Records hourly.",{"@type":1768,"name":1735,"acceptedAnswer":1779},{"@type":1770,"text":1780},"Use the Stripe CLI (stripe listen --forward-to localhost:8000\u002Fbilling\u002Fwebhook) to forward live webhook events to your local FastAPI instance. Combine this with Stripe's test card numbers and stripe trigger commands to simulate the full subscription lifecycle.",1778017886387]