Getting Started with Python APIs for Builders
Building a Python API that earns money is a different job from building one that works. A working API returns 200s on your laptop. A commercial API survives a customer's retry storm at 3am, tells you exactly what each request cost you, and never leaks a key that lets someone burn your upstream credits. This guide covers that whole arc: protocol selection, an async-native framework setup, external service integration, authentication, rate limiting, resilience, and metered monetization — the sequence in which you should actually build them.
Everything here assumes you already write Python and have called an API before. Nothing in this guide explains what HTTP is. What it does explain is why the default httpx.AsyncClient usage pattern quietly halves your throughput, why a 401 in production usually means a clock problem rather than a bad key, and what your infrastructure actually costs at a million requests a month. Once your service is live, Scaling and Operating Production Python APIs carries it through caching, background jobs and observability, Building & Monetizing API-Driven Micro-SaaS turns it into a billed product, and Automating Side-Hustle Operations with APIs shows what to consume rather than build.
Key takeaways:
- Protocol choice locks in your caching, rate-limiting and metering options before a line of code exists — pick it deliberately, not by habit.
- An async-native stack removes the thread-per-request ceiling, so one $25 container absorbs traffic that would otherwise cost you four.
- Metering from day one is what converts an API into a business; retrofitting usage tracking after launch means guessing at six months of unbilled revenue.
The Build Order That Gets You Paid Fastest
Most first commercial APIs die from sequencing errors, not from technical ones. Builders spend three weeks on a perfect data model and ship without a way to charge anyone, or they wire up Stripe before they can reliably authenticate a request. The order below front-loads the decisions that are expensive to reverse and defers the ones that are cheap to change.
Week one is architecture and skeleton: choose your protocol, scaffold the service, and get a health check deployed to a real URL. Deploying on day two rather than day twenty is not a stunt — it flushes out the TLS, port-binding and environment-variable problems that otherwise ambush you the night before launch. Week two is correctness: request and response validation, authentication, and the error contract you will be stuck supporting. Week three is money: metering, quotas, and a billing hook. Week four is the operational tail — retries, structured logs, alerts, and a test suite that stops you from breaking paying customers.
The reason this order works is that each week produces something you can charge for or learn from, and none of it depends on work you have not done yet. A deployed health check with no business logic is still a URL you can put in front of a prospective customer to ask "would you pay for this if it returned X?". Validation and auth in week two mean the traffic you see in week three is real traffic from real accounts rather than anonymous noise you cannot attribute. And metering before billing means that when you finally connect a payment processor, you are configuring prices against a dataset instead of guessing.
Two things belong earlier than instinct suggests. The first is your error contract: the exact JSON body, status code and machine-readable error code your API returns when something fails. Customers write code against your failures, so changing them later is a breaking change that needs the same care as versioning any public endpoint. The second is your usage record. Even if you have no billing integration yet, writing one row per request into a table from the very first deploy costs you an afternoon and gives you the traffic history you need to price the product honestly.
Architectural Foundations: Choosing the Right Protocol
Align the architecture with the business model before writing code. The protocol you choose dictates caching behaviour, client developer experience, and how easily you enforce usage limits across pricing tiers.
| Use Case | Protocol | Caching | Metering Fit |
|---|---|---|---|
| Public SaaS and mobile | REST | HTTP headers, CDN edge | Per-route rate limits |
| Dashboards and admin | GraphQL | DataLoader, persisted queries | Query complexity units |
| Live feeds | WebSockets or SSE | None (stateful) | Connection minutes |
| Internal services | gRPC | Protobuf, service mesh | Compute chargeback |
Start with REST. It is cache-friendly, universally understood, and trivial to meter at the route level, which means your pricing page can say "10,000 requests a month" and your middleware can enforce exactly that. The REST versus GraphQL trade-off only tilts toward GraphQL once client data requirements vary so much that over-fetching becomes a measurable bandwidth or latency cost — and by then you have customers telling you so.
The metering column is where builders get hurt. GraphQL exposes a single POST endpoint, so route-level rate limiting is meaningless: one query can pull twelve related collections and another can pull one field, and both count as one request. You have to implement query cost analysis before you can price it, which is a week of work nobody budgets. WebSockets have the opposite problem — the connection is cheap to open and expensive to hold, so per-request pricing collapses entirely and you end up billing connection-minutes or messages. Before you commit, read when to use webhooks instead of polling, because a webhook callback often replaces the streaming requirement you thought you had at a fraction of the operational cost.
Resource shape matters as much as protocol. Design endpoints around the unit you intend to bill, not around your database tables. If you sell "one enriched company record", then POST /v1/enrich returning one record is a clean billable unit, while a generic GET /v1/companies?ids=1,2,3...200 that returns two hundred records for one request quietly gives away 199 units. Batch endpoints are still worth offering — they cut your own overhead — but count units inside them rather than counting HTTP calls. Builders who skip this end up rewriting their metering layer six months in, which is far more painful than getting the route design right on day one.
There is a third axis most comparisons skip: what your customers' code generators can consume. A REST API with a clean OpenAPI document gives every customer a typed client in their own language for free, which is why documenting the API with OpenAPI belongs in your first month rather than your first year. gRPC gives you the same benefit with better wire efficiency and a far worse browser story. Unless you control both ends, REST plus a good spec wins on adoption every time.
Production-Ready Framework Setup
High-concurrency APIs need an async-native foundation. FastAPI combines automatic OpenAPI generation, Pydantic v2 validation and native asyncio support with almost no boilerplate, and its performance ceiling is set by your database access pattern rather than the framework. If you are weighing alternatives, FastAPI versus Flask covers the sync-stack comparison and FastAPI versus Litestar covers the closest async rival.
Configure for production from the first commit. Read every setting from the environment, fail loudly at startup when a required variable is missing, and never run uvicorn --reload anywhere a customer can reach. The example below reads its version from pyproject.toml with tomllib so the health endpoint never drifts from the deployed artifact, and uses match to select environment-specific behaviour without a chain of conditionals.
# main.py
import os
import tomllib
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
class HealthCheck(BaseModel):
status: str
version: str
environment: str
def read_version() -> str:
pyproject = Path(os.getenv("PYPROJECT_PATH", "pyproject.toml"))
if not pyproject.exists():
return os.getenv("APP_VERSION", "0.0.0-dev")
with pyproject.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
APP_NAME = os.getenv("APP_NAME", "builder-api")
match ENVIRONMENT:
case "production":
docs_url, redoc_url = None, "/docs"
case "staging":
docs_url, redoc_url = "/swagger", "/docs"
case _:
docs_url, redoc_url = "/swagger", "/docs"
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(
timeout=httpx.Timeout(10.0, connect=3.0, read=15.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
headers={"user-agent": f"{APP_NAME}/{read_version()}"},
)
yield
await app.state.http.aclose()
app = FastAPI(
title=APP_NAME,
version=read_version(),
lifespan=lifespan,
docs_url=docs_url,
redoc_url=redoc_url,
)
@app.get("/health", response_model=HealthCheck)
async def health_check() -> HealthCheck:
return HealthCheck(
status="ok",
version=read_version(),
environment=ENVIRONMENT,
)
The lifespan context manager is the single most under-used piece of FastAPI. Every long-lived resource — HTTP client, database pool, Redis connection, background scheduler — belongs there, created once per worker process and closed on shutdown. Creating them per request is the most common self-inflicted performance wound in Python APIs, and it hides well in development where you never open more than one connection at a time.
Two configuration details in that snippet earn their keep in production. Reading the version from pyproject.toml means your health endpoint reports the artifact that is actually running, so when a deploy half-succeeds you can see it in one curl instead of guessing from log timestamps. Hiding the interactive docs in production is a deliberate choice too — you still ship /docs, but you serve the read-only renderer rather than a form that lets anyone fire authenticated requests from your marketing site, a distinction covered in ReDoc versus Swagger UI. Both cost one line and save an incident.
Fail fast on missing configuration. Use os.environ["UPSTREAM_API_KEY"] for anything the service genuinely cannot run without, so a misconfigured deploy crashes at startup and your platform rolls back, rather than booting happily and returning 500s to customers an hour later. Reserve os.getenv with a default for genuinely optional tuning knobs. This one habit converts a class of silent production failures into loud deploy failures, which is always the cheaper place to find them.
Run it behind a process manager. uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 is fine for a single container; use gunicorn -k uvicorn.workers.UvicornWorker when you want worker recycling and graceful reloads. Set the worker count to your vCPU count and no higher — Uvicorn versus Gunicorn worker configuration walks through why eight workers on two vCPUs makes p99 latency worse, not better. Each worker is a separate process with a separate event loop and separate memory, so anything you cache in a module-level dictionary exists four times and diverges immediately. Shared state belongs in Redis or Postgres, which is exactly the argument behind Redis versus in-memory caching for FastAPI.
Integration and Data Flow
Most commercial APIs are worth money because they wrap something else: a payment processor, a data vendor, a model provider, a marketplace. Your reliability is therefore mostly other people's reliability, and your job is to fail gracefully around it. The rule is absolute — a synchronous call inside an async route blocks the entire event loop for that worker, so one slow vendor stalls every concurrent request the worker is handling, not just the one that made the call.
Use httpx.AsyncClient for outbound traffic. The foundational sync patterns still live in making HTTP requests with the requests library, and httpx versus requests for async explains why new code should start on httpx. The critical detail is client reuse: async with httpx.AsyncClient() inside a request handler discards the connection pool on every call, forcing a fresh TCP and TLS handshake that costs 80–150ms against a typical vendor. Create the client once in lifespan and hand it to your service objects.
# client.py
import os
from typing import Any
import httpx
from fastapi import HTTPException
class UpstreamClient:
"""Wraps one vendor API. The httpx client is created once, in lifespan."""
def __init__(self, http: httpx.AsyncClient, base_url: str, api_key: str) -> None:
self._http = http
self._base_url = base_url.rstrip("/")
self._api_key = api_key
async def fetch(self, path: str, params: dict[str, Any] | None = None) -> dict:
headers = {
"authorization": f"Bearer {self._api_key}",
"accept": "application/json",
}
try:
response = await self._http.get(
f"{self._base_url}/{path.lstrip('/')}",
headers=headers,
params=params,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
match exc.response.status_code:
case 401 | 403:
raise HTTPException(502, "upstream rejected our credentials") from exc
case 429:
retry_after = exc.response.headers.get("retry-after", "60")
raise HTTPException(
503,
"upstream rate limit reached",
headers={"retry-after": retry_after},
) from exc
case _:
raise HTTPException(502, "upstream error") from exc
except httpx.TimeoutException as exc:
raise HTTPException(504, "upstream timed out") from exc
except httpx.RequestError as exc:
raise HTTPException(502, "upstream unreachable") from exc
def build_client(http: httpx.AsyncClient) -> UpstreamClient:
return UpstreamClient(
http=http,
base_url=os.environ["UPSTREAM_API_URL"],
api_key=os.environ["UPSTREAM_API_KEY"],
)
Notice what the error mapping does commercially. A 401 from your vendor is your problem, not your customer's, so it becomes a 502 and pages you — never pass an upstream 401 straight through, or your customer will spend an hour checking their own key. A 429 from the vendor becomes a 503 with a Retry-After header your customer's client can honour, which is the behaviour described in debugging 429 Too Many Requests errors. And every failure mode returns a body your customers can branch on, rather than an HTML stack trace.
Timeouts deserve an explicit budget. If you promise a 250ms p95, every downstream call has to fit inside it with room for your own work — a 30-second default timeout is not a safety net, it is a queue of stuck workers waiting to happen. Set the connect timeout low (3 seconds; a healthy vendor connects in under 100ms) and the read timeout to whatever the slow tail of that vendor genuinely needs. Then validate what comes back with a Pydantic model, because an upstream that silently changes a field type will otherwise corrupt your data quietly. That validation layer is covered in parsing JSON responses and in detail in validating JSON with Pydantic v2; if the payloads are large, streaming them keeps memory flat.
That chart is the whole argument for caching in one picture. Your own code accounts for 17 of 250 milliseconds; the vendor call is 84% of the response. Caching that upstream response for even 60 seconds does more for p95 latency than any amount of Python optimisation, which is why caching API responses with Redis is the first performance lever to pull. It is also the cheapest, since a cache hit costs you nothing in vendor fees.
Security and Access Control
Security on a commercial API is margin protection. A leaked key is not an abstract risk — it is somebody else running your upstream model calls on your credit card until you notice. Three controls carry most of the weight: never store a credential in plaintext, never trust a client-supplied identifier, and always throttle before you do expensive work.
Store hashes, not keys. Issue a key once, show it once, and keep only its SHA-256 digest with a short non-secret prefix you can index and display in the customer's dashboard. Compare with hmac.compare_digest so response timing does not leak the digest byte by byte. That structure is what makes rotating API keys without downtime possible: two active rows, one grace period, zero failed requests.
# security.py
import hashlib
import hmac
import os
import secrets
from dataclasses import dataclass
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
API_KEY_PREFIX = os.getenv("API_KEY_PREFIX", "sk_live_")
api_key_header = APIKeyHeader(name=os.getenv("API_KEY_HEADER", "x-api-key"))
@dataclass(frozen=True)
class Principal:
account_id: str
tier: str
@dataclass(frozen=True)
class KeyRecord:
account_id: str
tier: str
digest: str
def mint_key() -> tuple[str, str]:
"""Return (plaintext_key, sha256_digest). Show the plaintext exactly once."""
raw = f"{API_KEY_PREFIX}{secrets.token_urlsafe(32)}"
return raw, hashlib.sha256(raw.encode()).hexdigest()
async def active_keys_for_prefix(prefix: str) -> list[KeyRecord]:
"""Replace with an indexed SELECT on api_keys WHERE key_prefix = $1
AND revoked_at IS NULL. During rotation this returns two rows."""
raise NotImplementedError
async def current_principal(presented: str = Depends(api_key_header)) -> Principal:
digest = hashlib.sha256(presented.encode()).hexdigest()
for record in await active_keys_for_prefix(presented[:12]):
if hmac.compare_digest(record.digest, digest):
return Principal(account_id=record.account_id, tier=record.tier)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid or revoked API key",
headers={"www-authenticate": "ApiKey"},
)
API keys suit machine-to-machine traffic; user-facing products that need delegated access want OAuth2 instead, and implementing the OAuth2 authorization code flow in FastAPI shows what that costs you in complexity. The JWT versus API keys comparison is worth reading before you commit, because the two have opposite revocation properties: a database-backed key dies the instant you delete the row, while a signed JWT stays valid until it expires no matter how loudly you want it gone.
Three failure modes bite builders repeatedly. First, clock drift: JWT validation fails with a confusing 401 when your container's clock runs 90 seconds fast, which is the first thing to check in debugging 401 Unauthorized errors. Second, throttling after the expensive work instead of before it — rate limit in middleware, not in the handler, or an abusive client still costs you the database query. Third, per-IP limits on a mobile client base, where an entire carrier NAT shares one address and legitimate users lock each other out. Key limits by account, then apply a coarser IP limit only as an anti-scraping backstop. The mechanics live in best practices for API rate limiting, and the commercial side — stopping people from farming free accounts — in preventing free-tier abuse.
Resilience and Observability
Commercial APIs fail when their dependencies do, and the difference between a blip and an outage is whether you retry the right things. Retry idempotent reads and connection errors. Never blindly retry a POST that charges a card — that is what idempotency keys are for, and the pattern is spelled out in building an idempotent webhook receiver.
# resilience.py
import logging
import os
import httpx
import structlog
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential_jitter,
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)
log = structlog.get_logger(service=os.getenv("APP_NAME", "builder-api"))
RETRY_ATTEMPTS = int(os.getenv("UPSTREAM_RETRY_ATTEMPTS", "3"))
@retry(
stop=stop_after_attempt(RETRY_ATTEMPTS),
wait=wait_exponential_jitter(initial=2, max=10, jitter=1),
retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
before_sleep=before_sleep_log(logging.getLogger("tenacity"), logging.WARNING),
reraise=True,
)
async def resilient_get(http: httpx.AsyncClient, url: str) -> dict:
response = await http.get(url)
response.raise_for_status()
log.info("upstream_ok", url=url, ms=response.elapsed.total_seconds() * 1000)
return response.json()
Three attempts with exponential backoff is the right default for a request that a customer is waiting on. Anything more and you have exceeded the patience of the client anyway; anything less and you fail on transient blips that would have cleared. The jitter matters more than the delays: without it, every client that failed during the same vendor hiccup retries in lockstep and hammers the recovering service back down. The full mechanics, including which status codes deserve a retry at all, are in retrying failed HTTP requests with tenacity.
Logging is the other half. Log structured JSON with a request id, account id, route, status, duration and upstream cost on every request, and nothing else at INFO level. Free-text logs are unqueryable at volume and expensive to retain; structured logging with structlog covers the processor chain, and monitoring and logging Python APIs covers what to alert on. Four numbers tell you whether the business is healthy: p95 and p99 latency per route, the 4xx-versus-5xx error split, cost per request including upstream fees, and retry success rate. That last one is an early-warning system — when retries start succeeding more often, your vendor is degrading before their status page admits it.
Not everything belongs in the request path. Any work a customer does not need to wait for — sending an email, regenerating a report, pushing a usage record to your billing provider — should go to a queue, because holding an HTTP connection open for eight seconds burns a worker slot you paid for. Running background jobs with Celery covers the durable option, and scheduling data pipelines with cron covers the recurring kind. The commercial framing is simple: request-path seconds are rented at premium rates, queue-worker seconds are not.
Watch for the failure mode that async makes easy: connection pool exhaustion. Every worker holds its own pool, so a 20-connection Postgres pool across four workers is 80 connections, and a managed instance capped at 100 will start refusing your deploys mid-rollout. Fixing connection pool exhaustion walks the arithmetic, and async database access with SQLAlchemy sets the pool up correctly in the first place.
Metered Monetization and Unit Economics
An API becomes a business the moment you can answer two questions: how many billable units did this account consume, and what did serving them cost me. Record usage on every request, in the same transaction path as the response, and reconcile to your billing provider asynchronously.
# metering.py
import os
import time
import uuid
from typing import Awaitable, Callable
import redis.asyncio as redis
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
TIER_MONTHLY_QUOTA = {"free": 1_000, "starter": 50_000, "pro": 500_000}
pool = redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
class MeteringMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
principal = getattr(request.state, "principal", None)
if principal is None:
return await call_next(request)
period = time.strftime("%Y-%m")
counter = f"usage:{principal.account_id}:{period}"
used = await pool.incr(counter)
if used == 1:
await pool.expire(counter, 60 * 60 * 24 * 40)
quota = TIER_MONTHLY_QUOTA.get(principal.tier, 0)
if used > quota:
return Response(
content='{"error":"quota_exceeded","upgrade":"/pricing"}',
status_code=429,
media_type="application/json",
)
request_id = str(uuid.uuid4())
started = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - started) * 1000
await pool.xadd(
os.getenv("USAGE_STREAM", "usage_events"),
{
"request_id": request_id,
"account_id": principal.account_id,
"tier": principal.tier,
"route": request.url.path,
"status": str(response.status_code),
"ms": f"{elapsed_ms:.2f}",
},
maxlen=100_000,
approximate=True,
)
response.headers["x-request-id"] = request_id
response.headers["x-quota-remaining"] = str(max(quota - used, 0))
return response
The Redis counter is the enforcement path and must be fast; the stream is the audit path and gets drained by a worker into Postgres for invoicing and dashboards. Keeping them separate means a Redis flush costs you a few minutes of quota accuracy, not a month of billable records. Logging API usage events to Postgres shows the durable side, tracking API usage and analytics covers the aggregation, and building a customer usage dashboard turns those rows into the screen that reduces your support load.
Now the numbers. A typical small commercial API — one 2 vCPU container, a managed Postgres, a small Redis, log retention and egress — costs about $68 a month in fixed infrastructure, and that stack comfortably serves a million requests. That works out to $0.000068 per request. If you charge $0.001 per request, your infrastructure gross margin is roughly 93%, and the fixed floor means margin improves as you grow rather than degrading.
That picture inverts the moment you wrap a paid vendor. An endpoint that makes one language-model call at $0.0012 per call costs $1,200 per million requests — nearly eighteen times the entire infrastructure bill — so the unit economics live entirely in the vendor invoice and your caching hit rate. Price those endpoints per call with a real markup, cap them per tier, and read controlling LLM API costs in production before you offer an unlimited plan. Work the exact arithmetic for your own service with calculating cost per API request, then set the tiers using designing API pricing tiers and wire the invoices with Stripe metered billing or the broader Stripe integration guide.
Quota enforcement has an edge case that costs real money: the concurrent burst. INCR is atomic, so two simultaneous requests cannot both read the same count, but a client that fires 200 requests in parallel against a quota with 5 remaining will get 5 successes and 195 clean 429s — which is correct, and is exactly why you enforce with an atomic counter rather than a read-modify-write against Postgres. The second edge case is the failed request. Decide explicitly whether a 500 caused by your own bug consumes quota; it should not, so credit it back in the middleware or exclude 5xx responses when you aggregate the stream for invoicing. Customers notice being charged for your outages, and the refund conversation costs more than the code.
Where you host changes the fixed floor more than anything in your code. A scale-to-zero platform costs almost nothing while you have ten users but adds a cold start to the first request after idle, which is brutal for an API whose customers benchmark you. A always-on container costs $25 a month and answers in 4ms. Render versus Railway versus Fly.io compares the cold-start and autoscale behaviour, deploying APIs to Render or Vercel covers the mechanics, and containerizing with Docker makes the artifact portable enough that switching costs an afternoon rather than a rewrite.
Common Mistakes
- Calling
requestsinside an async route. It blocks the event loop for the whole worker, so a 400ms vendor call becomes 400ms of stalled concurrency for every other in-flight request. If you must call sync code, wrap it inasyncio.to_thread. - Creating an
httpx.AsyncClientper request. You throw away the connection pool and pay a TLS handshake every time. Create it once inlifespan, close it on shutdown, and watch p95 drop by 80–150ms. - Shipping without a usage record. Retrofitting metering means you cannot invoice for the traffic you already served and cannot price the product from evidence. One insert per request from day one is cheap insurance.
- Returning upstream errors verbatim. Leaking a vendor's 401 or its error body confuses customers, exposes your architecture, and generates support tickets that are not your customers' fault to begin with.
- Rate limiting per IP for a mobile or enterprise client base. Carrier NAT and corporate egress gateways put hundreds of legitimate users behind one address. Limit by account key first; treat IP limits as a scraping backstop only.
- Testing only the happy path. The failures that cost money are timeouts, partial JSON and duplicate webhooks. Mock the vendor and assert on those — mocking external APIs with respx and testing async FastAPI endpoints with httpx show how without hitting the real service.
FAQ
How much does it cost to run a Python API at 1M requests a month? About $68 a month in fixed infrastructure for a 2 vCPU container, managed Postgres, a small Redis, log retention and egress — roughly $0.000068 per request. That number holds only if you make no paid vendor calls; one language-model call at $0.0012 adds $1,200 per million requests and becomes the entire cost story.
Is Python fast enough for a commercial API, or will I have to rewrite it? Fast enough, and the rewrite almost never pays. FastAPI on four workers handles a few thousand concurrent I/O-bound requests on a small box, and your latency is dominated by upstream calls and unindexed queries rather than the interpreter. Fix the blocking calls and the N+1 queries first; you will not find a language problem underneath them.
When should I add metered billing rather than a flat monthly price? Add it when your cost per account varies more than about 3x between your heaviest and lightest customers, or when a single account can move your vendor bill. Flat pricing is faster to ship and easier to sell, so start there, instrument usage from day one, and switch once the data shows heavy users eating your margin.
How often should I rotate API keys, and what breaks when I do? Every 90 days on a schedule, and immediately on any suspected leak. Nothing breaks if you issue the new key first, accept both for a 72-hour overlap, then revoke the old one — customers who never read the email still get a working key until the overlap ends. Rotation only causes outages when you revoke and issue in the same step.
What is the migration risk if I start with REST and later need GraphQL? Low, if you keep your business logic out of the route handlers. Adding a GraphQL endpoint alongside REST is a new transport over the same service layer, not a rewrite. The real cost is operational: you lose route-level rate limiting and caching and must build query-complexity metering before you can price or protect the new surface.
Related
Same area:
- Setting Up FastAPI — the project structure, dependency wiring and worker configuration behind every example on this page.
- Handling API Authentication in Python — API keys, JWTs and OAuth2 compared, with the revocation trade-offs spelled out.
- Documenting APIs with OpenAPI — turn the generated schema into docs a customer can integrate in an afternoon.
Other areas:
- Building & Monetizing API-Driven Micro-SaaS — pricing tiers, Stripe billing and the developer portal that converts trials into revenue.
- Scaling and Operating Production Python APIs — caching, background jobs, testing and observability once traffic is real.
- Automating Side-Hustle Operations with APIs — the consuming side: webhooks, scheduled pipelines and workflow automation in Python.