[{"data":1,"prerenderedAt":1484},["ShallowReactive",2],{"page-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002F":3,"faq-schema-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002F":1465},{"id":4,"title":5,"body":6,"description":16,"extension":1459,"meta":1460,"navigation":127,"path":1461,"seo":1462,"stem":1463,"__hash__":1464},"content\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002Findex.md","Creating a Developer Portal for Your API: Python Implementation Guide",{"type":7,"value":8,"toc":1450},"minimark",[9,13,17,23,39,44,47,56,60,63,479,484,499,503,514,894,898,901,1336,1340,1355,1360,1390,1394,1414,1418,1428,1434,1440,1446],[10,11,5],"h1",{"id":12},"creating-a-developer-portal-for-your-api-python-implementation-guide",[14,15,16],"p",{},"A direct, code-first blueprint for building a lightweight, self-serve developer portal using Python. This guide covers automated OpenAPI generation, secure API key provisioning, Redis-backed rate limiting, and seamless deployment to production.",[14,18,19],{},[20,21,22],"strong",{},"Key Implementation Points:",[24,25,26,30,33,36],"ul",{},[27,28,29],"li",{},"Leverage FastAPI's native OpenAPI spec to auto-generate interactive documentation",[27,31,32],{},"Implement middleware for API key validation and usage metering",[27,34,35],{},"Integrate Stripe webhooks for automated tier provisioning",[27,37,38],{},"Deploy with zero-downtime configuration for high availability",[40,41,43],"h2",{"id":42},"architecture-stack-selection","Architecture & Stack Selection",[14,45,46],{},"Define the minimal viable stack for a self-serve portal aligned with micro-SaaS business models. FastAPI outperforms traditional Flask setups for this use case due to native async request handling, Pydantic data validation, and automatic OpenAPI schema generation. By defining strict request\u002Fresponse models, you instantly generate Swagger UI and Redoc endpoints without manual documentation overhead.",[14,48,49,50,55],{},"Align your portal's feature rollout with the ",[51,52,54],"a",{"href":53},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002F","Building & Monetizing API-Driven Micro-SaaS"," lifecycle to ensure early monetization readiness. Start with core auth and rate limiting, then layer in billing dashboards and analytics once you validate initial traffic patterns.",[40,57,59],{"id":58},"api-key-provisioning-auth-middleware","API Key Provisioning & Auth Middleware",[14,61,62],{},"Secure, stateless API key validation is the foundation of your portal. Store keys, quotas, and tenant mappings in a relational database (PostgreSQL), but validate them via a fast, in-memory cache to avoid blocking request throughput. Use FastAPI's dependency injection system to intercept requests before they hit your business logic.",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-python shiki shiki-themes github-light github-dark","import os\nimport logging\nfrom fastapi import Depends, HTTPException, Request, status\nimport redis.asyncio as aioredis\n\nlogger = logging.getLogger(__name__)\n\nREDIS_URL = os.getenv(\"REDIS_URL\", \"redis:\u002F\u002Flocalhost:6379\u002F0\")\nredis_client = aioredis.from_url(REDIS_URL, decode_responses=True, socket_timeout=2.0)\n\nasync def validate_api_key(request: Request) -> str:\n api_key = request.headers.get(\"X-API-Key\")\n if not api_key:\n raise HTTPException(\n status_code=status.HTTP_401_UNAUTHORIZED,\n detail=\"Missing API Key. Provide via X-API-Key header.\"\n )\n\n try:\n # Atomic check against a known valid key registry\n is_valid = await redis_client.get(f\"api:keys:{api_key}\")\n if not is_valid:\n raise HTTPException(status_code=403, detail=\"Invalid or revoked API Key\")\n return api_key\n except aioredis.RedisError as e:\n logger.error(f\"Redis validation failed: {e}\")\n # Fail closed for security\n raise HTTPException(status_code=503, detail=\"Auth service unavailable\")\n","python","",[71,72,73,86,94,108,122,129,148,153,177,213,218,240,256,268,277,294,305,311,316,324,331,365,375,403,412,426,448,454],"code",{"__ignoreMap":69},[74,75,78,82],"span",{"class":76,"line":77},"line",1,[74,79,81],{"class":80},"szBVR","import",[74,83,85],{"class":84},"sVt8B"," os\n",[74,87,89,91],{"class":76,"line":88},2,[74,90,81],{"class":80},[74,92,93],{"class":84}," logging\n",[74,95,97,100,103,105],{"class":76,"line":96},3,[74,98,99],{"class":80},"from",[74,101,102],{"class":84}," fastapi ",[74,104,81],{"class":80},[74,106,107],{"class":84}," Depends, HTTPException, Request, status\n",[74,109,111,113,116,119],{"class":76,"line":110},4,[74,112,81],{"class":80},[74,114,115],{"class":84}," redis.asyncio ",[74,117,118],{"class":80},"as",[74,120,121],{"class":84}," aioredis\n",[74,123,125],{"class":76,"line":124},5,[74,126,128],{"emptyLinePlaceholder":127},true,"\n",[74,130,132,135,138,141,145],{"class":76,"line":131},6,[74,133,134],{"class":84},"logger ",[74,136,137],{"class":80},"=",[74,139,140],{"class":84}," logging.getLogger(",[74,142,144],{"class":143},"sj4cs","__name__",[74,146,147],{"class":84},")\n",[74,149,151],{"class":76,"line":150},7,[74,152,128],{"emptyLinePlaceholder":127},[74,154,156,159,162,165,169,172,175],{"class":76,"line":155},8,[74,157,158],{"class":143},"REDIS_URL",[74,160,161],{"class":80}," =",[74,163,164],{"class":84}," os.getenv(",[74,166,168],{"class":167},"sZZnC","\"REDIS_URL\"",[74,170,171],{"class":84},", ",[74,173,174],{"class":167},"\"redis:\u002F\u002Flocalhost:6379\u002F0\"",[74,176,147],{"class":84},[74,178,180,183,185,188,190,192,196,198,201,203,206,208,211],{"class":76,"line":179},9,[74,181,182],{"class":84},"redis_client ",[74,184,137],{"class":80},[74,186,187],{"class":84}," aioredis.from_url(",[74,189,158],{"class":143},[74,191,171],{"class":84},[74,193,195],{"class":194},"s4XuR","decode_responses",[74,197,137],{"class":80},[74,199,200],{"class":143},"True",[74,202,171],{"class":84},[74,204,205],{"class":194},"socket_timeout",[74,207,137],{"class":80},[74,209,210],{"class":143},"2.0",[74,212,147],{"class":84},[74,214,216],{"class":76,"line":215},10,[74,217,128],{"emptyLinePlaceholder":127},[74,219,221,224,227,231,234,237],{"class":76,"line":220},11,[74,222,223],{"class":80},"async",[74,225,226],{"class":80}," def",[74,228,230],{"class":229},"sScJk"," validate_api_key",[74,232,233],{"class":84},"(request: Request) -> ",[74,235,236],{"class":143},"str",[74,238,239],{"class":84},":\n",[74,241,243,246,248,251,254],{"class":76,"line":242},12,[74,244,245],{"class":84}," api_key ",[74,247,137],{"class":80},[74,249,250],{"class":84}," request.headers.get(",[74,252,253],{"class":167},"\"X-API-Key\"",[74,255,147],{"class":84},[74,257,259,262,265],{"class":76,"line":258},13,[74,260,261],{"class":80}," if",[74,263,264],{"class":80}," not",[74,266,267],{"class":84}," api_key:\n",[74,269,271,274],{"class":76,"line":270},14,[74,272,273],{"class":80}," raise",[74,275,276],{"class":84}," HTTPException(\n",[74,278,280,283,285,288,291],{"class":76,"line":279},15,[74,281,282],{"class":194}," status_code",[74,284,137],{"class":80},[74,286,287],{"class":84},"status.",[74,289,290],{"class":143},"HTTP_401_UNAUTHORIZED",[74,292,293],{"class":84},",\n",[74,295,297,300,302],{"class":76,"line":296},16,[74,298,299],{"class":194}," detail",[74,301,137],{"class":80},[74,303,304],{"class":167},"\"Missing API Key. Provide via X-API-Key header.\"\n",[74,306,308],{"class":76,"line":307},17,[74,309,310],{"class":84}," )\n",[74,312,314],{"class":76,"line":313},18,[74,315,128],{"emptyLinePlaceholder":127},[74,317,319,322],{"class":76,"line":318},19,[74,320,321],{"class":80}," try",[74,323,239],{"class":84},[74,325,327],{"class":76,"line":326},20,[74,328,330],{"class":329},"sJ8bj"," # Atomic check against a known valid key registry\n",[74,332,334,337,339,342,345,348,351,354,357,360,363],{"class":76,"line":333},21,[74,335,336],{"class":84}," is_valid ",[74,338,137],{"class":80},[74,340,341],{"class":80}," await",[74,343,344],{"class":84}," redis_client.get(",[74,346,347],{"class":80},"f",[74,349,350],{"class":167},"\"api:keys:",[74,352,353],{"class":143},"{",[74,355,356],{"class":84},"api_key",[74,358,359],{"class":143},"}",[74,361,362],{"class":167},"\"",[74,364,147],{"class":84},[74,366,368,370,372],{"class":76,"line":367},22,[74,369,261],{"class":80},[74,371,264],{"class":80},[74,373,374],{"class":84}," is_valid:\n",[74,376,378,380,383,386,388,391,393,396,398,401],{"class":76,"line":377},23,[74,379,273],{"class":80},[74,381,382],{"class":84}," HTTPException(",[74,384,385],{"class":194},"status_code",[74,387,137],{"class":80},[74,389,390],{"class":143},"403",[74,392,171],{"class":84},[74,394,395],{"class":194},"detail",[74,397,137],{"class":80},[74,399,400],{"class":167},"\"Invalid or revoked API Key\"",[74,402,147],{"class":84},[74,404,406,409],{"class":76,"line":405},24,[74,407,408],{"class":80}," return",[74,410,411],{"class":84}," api_key\n",[74,413,415,418,421,423],{"class":76,"line":414},25,[74,416,417],{"class":80}," except",[74,419,420],{"class":84}," aioredis.RedisError ",[74,422,118],{"class":80},[74,424,425],{"class":84}," e:\n",[74,427,429,432,434,437,439,442,444,446],{"class":76,"line":428},26,[74,430,431],{"class":84}," logger.error(",[74,433,347],{"class":80},[74,435,436],{"class":167},"\"Redis validation failed: ",[74,438,353],{"class":143},[74,440,441],{"class":84},"e",[74,443,359],{"class":143},[74,445,362],{"class":167},[74,447,147],{"class":84},[74,449,451],{"class":76,"line":450},27,[74,452,453],{"class":329}," # Fail closed for security\n",[74,455,457,459,461,463,465,468,470,472,474,477],{"class":76,"line":456},28,[74,458,273],{"class":80},[74,460,382],{"class":84},[74,462,385],{"class":194},[74,464,137],{"class":80},[74,466,467],{"class":143},"503",[74,469,171],{"class":84},[74,471,395],{"class":194},[74,473,137],{"class":80},[74,475,476],{"class":167},"\"Auth service unavailable\"",[74,478,147],{"class":84},[14,480,481],{},[20,482,483],{},"Security Best Practices:",[24,485,486,493,496],{},[27,487,488,489,492],{},"Never hardcode secrets; inject via ",[71,490,491],{},".env"," or cloud secret managers",[27,494,495],{},"Rotate keys using a dual-validation window (accept old + new keys for 72 hours)",[27,497,498],{},"Strip keys from logs and error traces using middleware sanitization",[40,500,502],{"id":501},"usage-tracking-rate-limiting","Usage Tracking & Rate Limiting",[14,504,505,506,509,510,513],{},"Enforce quotas and prevent infrastructure abuse using Redis-backed sliding window counters. While fixed windows are simpler, sliding windows or token bucket algorithms prevent burst abuse at window boundaries. Always return ",[71,507,508],{},"429 Too Many Requests"," with a ",[71,511,512],{},"Retry-After"," header to maintain client trust and prevent aggressive retry loops.",[64,515,517],{"className":66,"code":516,"language":68,"meta":69,"style":69},"import time\nfrom fastapi import Depends, HTTPException, Request, status\nimport redis.asyncio as aioredis\n\nasync def enforce_rate_limit(request: Request, api_key: str = Depends(validate_api_key)):\n # Sliding window: track timestamps for the last N seconds\n window_seconds = int(os.getenv(\"RATE_LIMIT_WINDOW\", \"3600\"))\n max_requests = int(os.getenv(\"RATE_LIMIT_MAX\", \"1000\"))\n key = f\"api:ratelimit:{api_key}\"\n\n try:\n pipe = redis_client.pipeline()\n now = time.time()\n # Remove expired entries\n await pipe.zremrangebyscore(key, 0, now - window_seconds)\n # Count current requests in window\n await pipe.zcard(key)\n # Add current request\n await pipe.zadd(key, {str(now): now})\n # Set expiry to clean up unused keys\n await pipe.expire(key, window_seconds + 10)\n \n results = await pipe.execute()\n current_count = results[1]\n\n if current_count >= max_requests:\n raise HTTPException(\n status_code=status.HTTP_429_TOO_MANY_REQUESTS,\n detail=\"Rate limit exceeded\",\n headers={\"Retry-After\": str(window_seconds)}\n )\n except aioredis.RedisError as e:\n logger.warning(f\"Rate limit service degraded: {e}\")\n # Graceful degradation: allow request but log\n return\n",[71,518,519,526,536,546,550,569,574,598,619,641,645,651,661,671,676,695,700,707,712,724,729,744,749,761,777,781,793,799,812,824,845,850,861,882,888],{"__ignoreMap":69},[74,520,521,523],{"class":76,"line":77},[74,522,81],{"class":80},[74,524,525],{"class":84}," time\n",[74,527,528,530,532,534],{"class":76,"line":88},[74,529,99],{"class":80},[74,531,102],{"class":84},[74,533,81],{"class":80},[74,535,107],{"class":84},[74,537,538,540,542,544],{"class":76,"line":96},[74,539,81],{"class":80},[74,541,115],{"class":84},[74,543,118],{"class":80},[74,545,121],{"class":84},[74,547,548],{"class":76,"line":110},[74,549,128],{"emptyLinePlaceholder":127},[74,551,552,554,556,559,562,564,566],{"class":76,"line":124},[74,553,223],{"class":80},[74,555,226],{"class":80},[74,557,558],{"class":229}," enforce_rate_limit",[74,560,561],{"class":84},"(request: Request, api_key: ",[74,563,236],{"class":143},[74,565,161],{"class":80},[74,567,568],{"class":84}," Depends(validate_api_key)):\n",[74,570,571],{"class":76,"line":131},[74,572,573],{"class":329}," # Sliding window: track timestamps for the last N seconds\n",[74,575,576,579,581,584,587,590,592,595],{"class":76,"line":150},[74,577,578],{"class":84}," window_seconds ",[74,580,137],{"class":80},[74,582,583],{"class":143}," int",[74,585,586],{"class":84},"(os.getenv(",[74,588,589],{"class":167},"\"RATE_LIMIT_WINDOW\"",[74,591,171],{"class":84},[74,593,594],{"class":167},"\"3600\"",[74,596,597],{"class":84},"))\n",[74,599,600,603,605,607,609,612,614,617],{"class":76,"line":155},[74,601,602],{"class":84}," max_requests ",[74,604,137],{"class":80},[74,606,583],{"class":143},[74,608,586],{"class":84},[74,610,611],{"class":167},"\"RATE_LIMIT_MAX\"",[74,613,171],{"class":84},[74,615,616],{"class":167},"\"1000\"",[74,618,597],{"class":84},[74,620,621,624,626,629,632,634,636,638],{"class":76,"line":179},[74,622,623],{"class":84}," key ",[74,625,137],{"class":80},[74,627,628],{"class":80}," f",[74,630,631],{"class":167},"\"api:ratelimit:",[74,633,353],{"class":143},[74,635,356],{"class":84},[74,637,359],{"class":143},[74,639,640],{"class":167},"\"\n",[74,642,643],{"class":76,"line":215},[74,644,128],{"emptyLinePlaceholder":127},[74,646,647,649],{"class":76,"line":220},[74,648,321],{"class":80},[74,650,239],{"class":84},[74,652,653,656,658],{"class":76,"line":242},[74,654,655],{"class":84}," pipe ",[74,657,137],{"class":80},[74,659,660],{"class":84}," redis_client.pipeline()\n",[74,662,663,666,668],{"class":76,"line":258},[74,664,665],{"class":84}," now ",[74,667,137],{"class":80},[74,669,670],{"class":84}," time.time()\n",[74,672,673],{"class":76,"line":270},[74,674,675],{"class":329}," # Remove expired entries\n",[74,677,678,680,683,686,689,692],{"class":76,"line":279},[74,679,341],{"class":80},[74,681,682],{"class":84}," pipe.zremrangebyscore(key, ",[74,684,685],{"class":143},"0",[74,687,688],{"class":84},", now ",[74,690,691],{"class":80},"-",[74,693,694],{"class":84}," window_seconds)\n",[74,696,697],{"class":76,"line":296},[74,698,699],{"class":329}," # Count current requests in window\n",[74,701,702,704],{"class":76,"line":307},[74,703,341],{"class":80},[74,705,706],{"class":84}," pipe.zcard(key)\n",[74,708,709],{"class":76,"line":313},[74,710,711],{"class":329}," # Add current request\n",[74,713,714,716,719,721],{"class":76,"line":318},[74,715,341],{"class":80},[74,717,718],{"class":84}," pipe.zadd(key, {",[74,720,236],{"class":143},[74,722,723],{"class":84},"(now): now})\n",[74,725,726],{"class":76,"line":326},[74,727,728],{"class":329}," # Set expiry to clean up unused keys\n",[74,730,731,733,736,739,742],{"class":76,"line":333},[74,732,341],{"class":80},[74,734,735],{"class":84}," pipe.expire(key, window_seconds ",[74,737,738],{"class":80},"+",[74,740,741],{"class":143}," 10",[74,743,147],{"class":84},[74,745,746],{"class":76,"line":367},[74,747,748],{"class":84}," \n",[74,750,751,754,756,758],{"class":76,"line":377},[74,752,753],{"class":84}," results ",[74,755,137],{"class":80},[74,757,341],{"class":80},[74,759,760],{"class":84}," pipe.execute()\n",[74,762,763,766,768,771,774],{"class":76,"line":405},[74,764,765],{"class":84}," current_count ",[74,767,137],{"class":80},[74,769,770],{"class":84}," results[",[74,772,773],{"class":143},"1",[74,775,776],{"class":84},"]\n",[74,778,779],{"class":76,"line":414},[74,780,128],{"emptyLinePlaceholder":127},[74,782,783,785,787,790],{"class":76,"line":428},[74,784,261],{"class":80},[74,786,765],{"class":84},[74,788,789],{"class":80},">=",[74,791,792],{"class":84}," max_requests:\n",[74,794,795,797],{"class":76,"line":450},[74,796,273],{"class":80},[74,798,276],{"class":84},[74,800,801,803,805,807,810],{"class":76,"line":456},[74,802,282],{"class":194},[74,804,137],{"class":80},[74,806,287],{"class":84},[74,808,809],{"class":143},"HTTP_429_TOO_MANY_REQUESTS",[74,811,293],{"class":84},[74,813,815,817,819,822],{"class":76,"line":814},29,[74,816,299],{"class":194},[74,818,137],{"class":80},[74,820,821],{"class":167},"\"Rate limit exceeded\"",[74,823,293],{"class":84},[74,825,827,830,832,834,837,840,842],{"class":76,"line":826},30,[74,828,829],{"class":194}," headers",[74,831,137],{"class":80},[74,833,353],{"class":84},[74,835,836],{"class":167},"\"Retry-After\"",[74,838,839],{"class":84},": ",[74,841,236],{"class":143},[74,843,844],{"class":84},"(window_seconds)}\n",[74,846,848],{"class":76,"line":847},31,[74,849,310],{"class":84},[74,851,853,855,857,859],{"class":76,"line":852},32,[74,854,417],{"class":80},[74,856,420],{"class":84},[74,858,118],{"class":80},[74,860,425],{"class":84},[74,862,864,867,869,872,874,876,878,880],{"class":76,"line":863},33,[74,865,866],{"class":84}," logger.warning(",[74,868,347],{"class":80},[74,870,871],{"class":167},"\"Rate limit service degraded: ",[74,873,353],{"class":143},[74,875,441],{"class":84},[74,877,359],{"class":143},[74,879,362],{"class":167},[74,881,147],{"class":84},[74,883,885],{"class":76,"line":884},34,[74,886,887],{"class":329}," # Graceful degradation: allow request but log\n",[74,889,891],{"class":76,"line":890},35,[74,892,893],{"class":80}," return\n",[40,895,897],{"id":896},"self-serve-dashboard-billing-integration","Self-Serve Dashboard & Billing Integration",[14,899,900],{},"Connect tier upgrades directly to Stripe webhooks for automated access control and quota scaling. Webhook processing must be strictly idempotent to prevent duplicate provisioning or quota inflation during network retries.",[64,902,904],{"className":66,"code":903,"language":68,"meta":69,"style":69},"import os\nimport stripe\nfrom fastapi import APIRouter, Request, HTTPException\nfrom fastapi.responses import JSONResponse\n\nrouter = APIRouter()\nstripe.api_key = os.getenv(\"STRIPE_SECRET_KEY\")\nWEBHOOK_SECRET = os.getenv(\"STRIPE_WEBHOOK_SECRET\")\n\n@router.post(\"\u002Fwebhooks\u002Fstripe\")\nasync def handle_stripe_webhook(request: Request):\n payload = await request.body()\n sig_header = request.headers.get(\"stripe-signature\")\n\n if not sig_header:\n raise HTTPException(status_code=400, detail=\"Missing Stripe-Signature header\")\n\n try:\n event = stripe.Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)\n except ValueError as e:\n raise HTTPException(status_code=400, detail=\"Invalid payload\")\n except stripe.error.SignatureVerificationError as e:\n raise HTTPException(status_code=400, detail=\"Invalid signature\")\n\n # Idempotency guard: check if event.id exists in your processed_events table\n # if await db.event_exists(event.id): return JSONResponse({\"status\": \"already_processed\"})\n\n if event.type == \"checkout.session.completed\":\n session = event.data.object\n tier = session.metadata.get(\"tier\", \"basic\")\n customer_id = session.customer\n \n # Atomic quota update logic here\n # await db.update_tier_quota(customer_id, tier)\n # await db.mark_event_processed(event.id)\n \n return JSONResponse(content={\"status\": \"provisioned\"}, status_code=200)\n\n return JSONResponse(content={\"status\": \"ignored\"}, status_code=200)\n",[71,905,906,912,919,930,942,946,956,970,984,988,1001,1013,1025,1039,1043,1052,1076,1080,1086,1100,1112,1135,1146,1169,1173,1178,1183,1187,1202,1212,1232,1242,1246,1251,1256,1261,1266,1301,1306],{"__ignoreMap":69},[74,907,908,910],{"class":76,"line":77},[74,909,81],{"class":80},[74,911,85],{"class":84},[74,913,914,916],{"class":76,"line":88},[74,915,81],{"class":80},[74,917,918],{"class":84}," stripe\n",[74,920,921,923,925,927],{"class":76,"line":96},[74,922,99],{"class":80},[74,924,102],{"class":84},[74,926,81],{"class":80},[74,928,929],{"class":84}," APIRouter, Request, HTTPException\n",[74,931,932,934,937,939],{"class":76,"line":110},[74,933,99],{"class":80},[74,935,936],{"class":84}," fastapi.responses ",[74,938,81],{"class":80},[74,940,941],{"class":84}," JSONResponse\n",[74,943,944],{"class":76,"line":124},[74,945,128],{"emptyLinePlaceholder":127},[74,947,948,951,953],{"class":76,"line":131},[74,949,950],{"class":84},"router ",[74,952,137],{"class":80},[74,954,955],{"class":84}," APIRouter()\n",[74,957,958,961,963,965,968],{"class":76,"line":150},[74,959,960],{"class":84},"stripe.api_key ",[74,962,137],{"class":80},[74,964,164],{"class":84},[74,966,967],{"class":167},"\"STRIPE_SECRET_KEY\"",[74,969,147],{"class":84},[74,971,972,975,977,979,982],{"class":76,"line":155},[74,973,974],{"class":143},"WEBHOOK_SECRET",[74,976,161],{"class":80},[74,978,164],{"class":84},[74,980,981],{"class":167},"\"STRIPE_WEBHOOK_SECRET\"",[74,983,147],{"class":84},[74,985,986],{"class":76,"line":179},[74,987,128],{"emptyLinePlaceholder":127},[74,989,990,993,996,999],{"class":76,"line":215},[74,991,992],{"class":229},"@router.post",[74,994,995],{"class":84},"(",[74,997,998],{"class":167},"\"\u002Fwebhooks\u002Fstripe\"",[74,1000,147],{"class":84},[74,1002,1003,1005,1007,1010],{"class":76,"line":220},[74,1004,223],{"class":80},[74,1006,226],{"class":80},[74,1008,1009],{"class":229}," handle_stripe_webhook",[74,1011,1012],{"class":84},"(request: Request):\n",[74,1014,1015,1018,1020,1022],{"class":76,"line":242},[74,1016,1017],{"class":84}," payload ",[74,1019,137],{"class":80},[74,1021,341],{"class":80},[74,1023,1024],{"class":84}," request.body()\n",[74,1026,1027,1030,1032,1034,1037],{"class":76,"line":258},[74,1028,1029],{"class":84}," sig_header ",[74,1031,137],{"class":80},[74,1033,250],{"class":84},[74,1035,1036],{"class":167},"\"stripe-signature\"",[74,1038,147],{"class":84},[74,1040,1041],{"class":76,"line":270},[74,1042,128],{"emptyLinePlaceholder":127},[74,1044,1045,1047,1049],{"class":76,"line":279},[74,1046,261],{"class":80},[74,1048,264],{"class":80},[74,1050,1051],{"class":84}," sig_header:\n",[74,1053,1054,1056,1058,1060,1062,1065,1067,1069,1071,1074],{"class":76,"line":296},[74,1055,273],{"class":80},[74,1057,382],{"class":84},[74,1059,385],{"class":194},[74,1061,137],{"class":80},[74,1063,1064],{"class":143},"400",[74,1066,171],{"class":84},[74,1068,395],{"class":194},[74,1070,137],{"class":80},[74,1072,1073],{"class":167},"\"Missing Stripe-Signature header\"",[74,1075,147],{"class":84},[74,1077,1078],{"class":76,"line":307},[74,1079,128],{"emptyLinePlaceholder":127},[74,1081,1082,1084],{"class":76,"line":313},[74,1083,321],{"class":80},[74,1085,239],{"class":84},[74,1087,1088,1091,1093,1096,1098],{"class":76,"line":318},[74,1089,1090],{"class":84}," event ",[74,1092,137],{"class":80},[74,1094,1095],{"class":84}," stripe.Webhook.construct_event(payload, sig_header, ",[74,1097,974],{"class":143},[74,1099,147],{"class":84},[74,1101,1102,1104,1107,1110],{"class":76,"line":326},[74,1103,417],{"class":80},[74,1105,1106],{"class":143}," ValueError",[74,1108,1109],{"class":80}," as",[74,1111,425],{"class":84},[74,1113,1114,1116,1118,1120,1122,1124,1126,1128,1130,1133],{"class":76,"line":333},[74,1115,273],{"class":80},[74,1117,382],{"class":84},[74,1119,385],{"class":194},[74,1121,137],{"class":80},[74,1123,1064],{"class":143},[74,1125,171],{"class":84},[74,1127,395],{"class":194},[74,1129,137],{"class":80},[74,1131,1132],{"class":167},"\"Invalid payload\"",[74,1134,147],{"class":84},[74,1136,1137,1139,1142,1144],{"class":76,"line":367},[74,1138,417],{"class":80},[74,1140,1141],{"class":84}," stripe.error.SignatureVerificationError ",[74,1143,118],{"class":80},[74,1145,425],{"class":84},[74,1147,1148,1150,1152,1154,1156,1158,1160,1162,1164,1167],{"class":76,"line":377},[74,1149,273],{"class":80},[74,1151,382],{"class":84},[74,1153,385],{"class":194},[74,1155,137],{"class":80},[74,1157,1064],{"class":143},[74,1159,171],{"class":84},[74,1161,395],{"class":194},[74,1163,137],{"class":80},[74,1165,1166],{"class":167},"\"Invalid signature\"",[74,1168,147],{"class":84},[74,1170,1171],{"class":76,"line":405},[74,1172,128],{"emptyLinePlaceholder":127},[74,1174,1175],{"class":76,"line":414},[74,1176,1177],{"class":329}," # Idempotency guard: check if event.id exists in your processed_events table\n",[74,1179,1180],{"class":76,"line":428},[74,1181,1182],{"class":329}," # if await db.event_exists(event.id): return JSONResponse({\"status\": \"already_processed\"})\n",[74,1184,1185],{"class":76,"line":450},[74,1186,128],{"emptyLinePlaceholder":127},[74,1188,1189,1191,1194,1197,1200],{"class":76,"line":456},[74,1190,261],{"class":80},[74,1192,1193],{"class":84}," event.type ",[74,1195,1196],{"class":80},"==",[74,1198,1199],{"class":167}," \"checkout.session.completed\"",[74,1201,239],{"class":84},[74,1203,1204,1207,1209],{"class":76,"line":814},[74,1205,1206],{"class":84}," session ",[74,1208,137],{"class":80},[74,1210,1211],{"class":84}," event.data.object\n",[74,1213,1214,1217,1219,1222,1225,1227,1230],{"class":76,"line":826},[74,1215,1216],{"class":84}," tier ",[74,1218,137],{"class":80},[74,1220,1221],{"class":84}," session.metadata.get(",[74,1223,1224],{"class":167},"\"tier\"",[74,1226,171],{"class":84},[74,1228,1229],{"class":167},"\"basic\"",[74,1231,147],{"class":84},[74,1233,1234,1237,1239],{"class":76,"line":847},[74,1235,1236],{"class":84}," customer_id ",[74,1238,137],{"class":80},[74,1240,1241],{"class":84}," session.customer\n",[74,1243,1244],{"class":76,"line":852},[74,1245,748],{"class":84},[74,1247,1248],{"class":76,"line":863},[74,1249,1250],{"class":329}," # Atomic quota update logic here\n",[74,1252,1253],{"class":76,"line":884},[74,1254,1255],{"class":329}," # await db.update_tier_quota(customer_id, tier)\n",[74,1257,1258],{"class":76,"line":890},[74,1259,1260],{"class":329}," # await db.mark_event_processed(event.id)\n",[74,1262,1264],{"class":76,"line":1263},36,[74,1265,748],{"class":84},[74,1267,1269,1271,1274,1277,1279,1281,1284,1286,1289,1292,1294,1296,1299],{"class":76,"line":1268},37,[74,1270,408],{"class":80},[74,1272,1273],{"class":84}," JSONResponse(",[74,1275,1276],{"class":194},"content",[74,1278,137],{"class":80},[74,1280,353],{"class":84},[74,1282,1283],{"class":167},"\"status\"",[74,1285,839],{"class":84},[74,1287,1288],{"class":167},"\"provisioned\"",[74,1290,1291],{"class":84},"}, ",[74,1293,385],{"class":194},[74,1295,137],{"class":80},[74,1297,1298],{"class":143},"200",[74,1300,147],{"class":84},[74,1302,1304],{"class":76,"line":1303},38,[74,1305,128],{"emptyLinePlaceholder":127},[74,1307,1309,1311,1313,1315,1317,1319,1321,1323,1326,1328,1330,1332,1334],{"class":76,"line":1308},39,[74,1310,408],{"class":80},[74,1312,1273],{"class":84},[74,1314,1276],{"class":194},[74,1316,137],{"class":80},[74,1318,353],{"class":84},[74,1320,1283],{"class":167},[74,1322,839],{"class":84},[74,1324,1325],{"class":167},"\"ignored\"",[74,1327,1291],{"class":84},[74,1329,385],{"class":194},[74,1331,137],{"class":80},[74,1333,1298],{"class":143},[74,1335,147],{"class":84},[40,1337,1339],{"id":1338},"deployment-production-hardening","Deployment & Production Hardening",[14,1341,1342,1343,171,1346,1349,1350,1354],{},"Package the portal for reliable delivery using multi-stage Docker builds to minimize image size and attack surface. Configure reverse proxy headers (",[71,1344,1345],{},"X-Forwarded-For",[71,1347,1348],{},"X-Real-IP","), strict CORS policies, and HTTPS termination at the edge. Follow the exact hosting configurations and automated rollout pipelines outlined in ",[51,1351,1353],{"href":1352},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002F","Deploying APIs to Render or Vercel"," to ensure scalable infrastructure and zero-downtime deployments.",[14,1356,1357],{},[20,1358,1359],{},"Production Checklist:",[24,1361,1362,1373,1380,1387],{},[27,1363,1364,1365,1368,1369,1372],{},"Set ",[71,1366,1367],{},"workers"," and ",[71,1370,1371],{},"timeout"," in Gunicorn\u002FUvicorn for optimal thread pooling",[27,1374,1375,1376,1379],{},"Enable ",[71,1377,1378],{},"--proxy-headers"," to trust load balancer IPs",[27,1381,1382,1383,1386],{},"Implement health check endpoints (",[71,1384,1385],{},"\u002Fhealth",") for orchestrator readiness probes",[27,1388,1389],{},"Isolate admin routes behind IP allowlists or separate auth middleware",[40,1391,1393],{"id":1392},"common-mistakes","Common Mistakes",[24,1395,1396,1402,1405,1408,1411],{},[27,1397,1398,1399,1401],{},"Hardcoding API keys or secrets in version control instead of using ",[71,1400,491],{}," or secret managers",[27,1403,1404],{},"Failing to implement idempotency keys in Stripe webhooks, causing duplicate quota assignments",[27,1406,1407],{},"Using synchronous HTTP clients in async FastAPI routes, blocking the event loop",[27,1409,1410],{},"Exposing internal admin endpoints via misconfigured CORS or missing middleware guards",[27,1412,1413],{},"Ignoring API versioning in the portal, breaking backward compatibility for existing integrators",[40,1415,1417],{"id":1416},"faq","FAQ",[14,1419,1420,1427],{},[20,1421,1422,1423,1426],{},"Can I use FastAPI's built-in ",[71,1424,1425],{},"\u002Fdocs"," endpoint as a full developer portal?","\nYes for basic reference, but a true portal requires custom routing, auth middleware, usage dashboards, and billing integration beyond auto-generated Swagger UI.",[14,1429,1430,1433],{},[20,1431,1432],{},"How do I handle API key rotation without breaking client integrations?","\nImplement a dual-key validation period where both old and new keys are accepted for 72 hours, then automatically invalidate the legacy key via a scheduled task.",[14,1435,1436,1439],{},[20,1437,1438],{},"Is Redis strictly required for rate limiting in Python APIs?","\nNot strictly required, but highly recommended for distributed deployments. In-memory dicts fail across multiple workers, while Redis provides atomic, cross-instance counters.",[14,1441,1442,1445],{},[20,1443,1444],{},"How do I prevent abuse without blocking legitimate high-volume users?","\nUse tiered rate limits mapped to Stripe subscription levels, implement exponential backoff headers, and allow burst capacity via token bucket algorithms.",[1447,1448,1449],"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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":69,"searchDepth":88,"depth":88,"links":1451},[1452,1453,1454,1455,1456,1457,1458],{"id":42,"depth":88,"text":43},{"id":58,"depth":88,"text":59},{"id":501,"depth":88,"text":502},{"id":896,"depth":88,"text":897},{"id":1338,"depth":88,"text":1339},{"id":1392,"depth":88,"text":1393},{"id":1416,"depth":88,"text":1417},"md",{},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api",{"title":5,"description":16},"building-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002Findex","IS3ajuEsmjPF5P0Rv8N7g_O1Zc8AcNJYr6FL7bbl11U",{"@context":1466,"@type":1467,"mainEntity":1468},"https:\u002F\u002Fschema.org","FAQPage",[1469,1475,1478,1481],{"@type":1470,"name":1471,"acceptedAnswer":1472},"Question","Can I use FastAPI's built-in \u002Fdocs endpoint as a full developer portal?",{"@type":1473,"text":1474},"Answer","Yes for basic reference, but a true portal requires custom routing, auth middleware, usage dashboards, and billing integration beyond auto-generated Swagger UI.",{"@type":1470,"name":1432,"acceptedAnswer":1476},{"@type":1473,"text":1477},"Implement a dual-key validation period where both old and new keys are accepted for 72 hours, then automatically invalidate the legacy key via a scheduled task.",{"@type":1470,"name":1438,"acceptedAnswer":1479},{"@type":1473,"text":1480},"Not strictly required, but highly recommended for distributed deployments. In-memory dicts fail across multiple workers, while Redis provides atomic, cross-instance counters.",{"@type":1470,"name":1444,"acceptedAnswer":1482},{"@type":1473,"text":1483},"Use tiered rate limits mapped to Stripe subscription levels, implement exponential backoff headers, and allow burst capacity via token bucket algorithms.",1778017886175]