Day 120: API Versioning — The Contract That Never Breaks
What We Are Building Today
A version router that detects API version from URL path, request header, or Accept header
v1 and v2 handlers with genuinely different schemas — flat
namevs splitfirst_name/last_name, offset vs cursor paginationDeprecation middleware that injects RFC-standard
SunsetandDeprecationresponse headersA backward compatibility shim that serves v2 data shaped as v1 for gradual migration
A version registry holding every version’s lifecycle state, sunset date, and migration guide
A React dashboard with live API explorer, migration guide, and adoption analytics — styled like Stripe’s API versioning console
Why This Matters
Stripe ships a new API version every few months. They have clients running on versions from 2014 still receiving correct responses in 2025. That is not magic — it is a versioning contract enforced in code.
When you change an API without versioning, every client breaks simultaneously. When you version it correctly, clients upgrade on their own schedule while you ship forward. That is the difference between a 3 AM outage call and a quiet, controlled migration.
Preparing for a distributed systems interview?
→Download the free Interview Pack
→ Subscribe now to access source code repository - 200 + coding lessons
The Core Concept: What Is API Versioning?
API versioning is the practice of publishing multiple, simultaneously-supported schemas of your API, each identified by a version tag. Clients declare which version they speak; the router dispatches accordingly.
There are three common detection strategies, and production systems use all three as a priority chain:
1. URL Path — most visible, easiest to debug
GET /api/v1/users
GET /api/v2/users
2. Request Header — clean URLs, preferred by REST purists
X-API-Version: 2
Accept: application/vnd.api+json;version=2
3. Default Fallback — if nothing is specified, route to the latest stable version.
Our middleware checks all three in that priority order on every single request.
Project Structure
day120-api-versioning/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, router mounts
│ │ ├── api/
│ │ │ ├── v1/__init__.py # v1 handlers (deprecated schema)
│ │ │ └── v2/__init__.py # v2 handlers (current schema)
│ │ ├── core/
│ │ │ ├── config.py # Settings
│ │ │ └── registry.py # Version lifecycle registry
│ │ └── middleware/
│ │ └── versioning.py # Detection + deprecation headers
│ ├── tests/test_versioning.py
│ └── requirements.txt
├── frontend/src/
│ ├── pages/VersionsOverview.js
│ ├── pages/MigrationGuide.js
│ ├── pages/ApiExplorer.js
│ └── pages/Analytics.js
├── build.sh
└── stop.sh
Component Architecture
The system has four distinct layers, each with a single job.
Version Router / Middleware intercepts every request before it reaches any handler. It reads the version signal, attaches it to request state, and on exit appends the appropriate response headers.
Version Registry is the single source of truth for what versions exist, which are stable or deprecated, and when each sunsets. No handler ever hardcodes a sunset date — it asks the registry.
Version Handlers (v1/v2) are completely separate FastAPI routers. They share the same underlying database but return different schema shapes. v1 returns {"name": "Alice Johnson"}. v2 returns {"first_name": "Alice", "last_name": "Johnson"}. Same data, different contract.
Backward Compatibility Shim is the clever part. During migration, some clients cannot change fast enough. The shim endpoint (/api/v2/compat/v1/users) calls v2 business logic, then reshapes the response to match the v1 schema. The client sees nothing change; the backend runs modern code.
API Lifecycle: The State Machine
Every API version moves through a defined lifecycle. Understanding these states is what separates engineers who plan migrations from those who create outages.
DRAFT → BETA → STABLE → DEPRECATED → SUNSET → RETIRED
State What Clients See Your Obligation DRAFT Nothing — internal only Build and iterate freely BETA Endpoints active, marked unstable May change without notice STABLE Full SLA. No surprise changes Frozen contract DEPRECATED Sunset + Deprecation headers on every response Migration docs published SUNSET 410 Gone Redirect clients to migration guide RETIRED Archived docs only Done
The Sunset header (RFC 8594) is the industry standard — it is an HTTP date after which the resource will no longer be available. GitHub, Stripe, Twilio, and AWS all use it.
HTTP/1.1 200 OK
X-API-Version: v1
Sunset: 2025-12-31
Deprecation: 2025-03-01
Link: </api/migration/v1/v2>; rel="successor-version"
X-Deprecation-Notice: API v1 deprecated. Migrate to v2. Sunset: 2025-12-31
The Real Engineering: Schema Evolution
The hardest part of versioning is not routing — it is managing field-level differences across versions without duplicating your entire business logic.
Breaking Changes from v1 to v2
v1 Field v2 Field Why It Changed users.name users.first_name + users.last_name Internationalization — full name is not universal pagination.offset + pagination.page pagination.cursor Offset pagination is broken on live datasets teams.member_count teams.member_total Naming consistency with other count fields {message: "..."} errors {code, message, details, request_id} Structured errors for programmatic handling
The Offset Pagination Problem
Imagine you have 100 users. A client is fetching page 2 (users 11–20). While that request is in flight, a user is deleted from position 5. The client receives users 11–20, but they are now actually users 12–21 — and user 11 was skipped silently. Cursor pagination solves this by anchoring to a specific record, not a numeric offset.
Step-by-Step Implementation
GitHub Link:-
https://github.com/sysdr/infrawatch-fullstack-p/tree/main/day120/project
Step 1 — Version Registry
The registry is a singleton that owns all version metadata. No other file hardcodes a sunset date.
# core/registry.py
class VersionRegistry:
versions = {
"v1": VersionInfo(status="deprecated", sunset_date="2025-12-31"),
"v2": VersionInfo(status="stable"),
"v3": VersionInfo(status="beta"),
}
migration_guides = {
"v1->v2": MigrationGuide(steps=[...], field_mappings={...})
}
Every middleware, handler, and test imports the same instance. Changing a version’s state in one place propagates everywhere instantly — that is the whole point of a singleton.
Step 2 — Version Detection Middleware
# middleware/versioning.py — VersioningMiddleware
version = detect_from_url_path(request)
or detect_from_x_api_version_header(request)
or detect_from_accept_header(request)
or "v2" # default to latest stable
request.state.api_version = version
response = await call_next(request)
response.headers["X-API-Version"] = version
return response
Step 3 — Deprecation Header Middleware
This runs after the version detector. It checks the registry and appends headers if needed.
# middleware/versioning.py — DeprecationMiddleware
if registry.get_status(version) == "deprecated":
response.headers["Sunset"] = registry.get_sunset_date(version)
response.headers["Deprecation"] = registry.get_deprecation_date(version)
response.headers["Link"] = f'</api/migration/{version}/v2>; rel="successor-version"'
These are RFC 8594 standard headers. Client SDKs from Stripe, Twilio, and GitHub know how to parse them and surface warnings to developers at build time.
Step 4 — Separate Version Routers
# main.py — mount two completely independent routers
app.include_router(v1_router, prefix="/api/v1", tags=["v1"])
app.include_router(v2_router, prefix="/api/v2", tags=["v2"])
v1 and v2 share the same database underneath but return completely different schema shapes.
# v1 schema
{"name": "Alice Johnson", "page": 1, "limit": 10}
# v2 schema
{"first_name": "Alice", "last_name": "Johnson",
"pagination": {"next_cursor": "abc123", "has_more": True}}
Step 5 — Backward Compatibility Shim
The shim lives in the v2 router but outputs v1-shaped data. This buys migration time without changing your core logic.
# api/v2/__init__.py
@router.get("/compat/v1/users")
async def compat_v1_users():
v1_shaped = [
{"name": f"{u['first_name']} {u['last_name']}", **rest}
for u in get_v2_users()
]
return {"users": v1_shaped, "page": 1, "limit": 10} # v1 envelope
The client sends requests to the shim URL, gets v1-shaped responses, and changes absolutely nothing on its end. You deprecate the shim separately after the client upgrades.
Step 6 — Machine-Readable Migration Guide
GET /api/migration/v1/v2
Returns a structured JSON object: steps[] (ordered), field_mappings{} (old → new), breaking_changes[], estimated_effort. Any client SDK can parse this automatically and generate a migration checklist without reading any documentation.
Step 7 — React Frontend (4 Pages)
Version Overview — version cards with status, new features, breaking changes, and lifecycle timeline
Migration Guide — interactive step-by-step guide fetched live from the backend migration endpoint
API Explorer — fire real requests against both v1 and v2, compare schema diffs and deprecation headers side-by-side
Analytics — adoption charts, latency percentiles, and migration progress bar toward sunset date
The Migration Path
A migration guide is not a blog post. It is a machine-readable artifact that clients and automated tools consume. Response contains: steps[] (ordered), field_mappings{} (old → new), breaking_changes[], and estimated_effort. Any client SDK can parse this and auto-generate a migration checklist.
The Five Migration Steps for v1 to v2
Update auth headers — Unchanged. Bearer tokens remain fully compatible. Zero effort.
Change base URL — Replace
/api/v1/with/api/v2/. One find-and-replace.Fix user name handling — Split
users.nameintofirst_nameandlast_name.Update pagination logic — Replace offset/page params with cursor-based pagination using
next_cursorfrom the response.Update error handling — Parse the new structured format:
error.code,error.message,error.details,error.request_id.
How Stripe Does It
Stripe pins each API call to the version active at the time of your account creation. Their versioning middleware stores the client’s pinned version in the authentication token scope. When a Stripe engineer removes a field in v2024-12-01, it only disappears for clients who explicitly upgraded to that version — everyone else continues working.
They publish version changelogs as a public feed, auto-diff new versions against old schemas using internal tooling, and require every PR that touches the API response schema to include a migration entry.
You are implementing the same pattern at smaller scale today.
Build and Run
Without Docker — Native Python + Node
# Terminal 1 — backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
# Expected:
# INFO: Application startup complete.
# INFO: Uvicorn running on http://0.0.0.0:8000
# Terminal 2 — frontend
cd frontend
npm install --legacy-peer-deps
npm start
# Expected:
# Compiled successfully!
# → http://localhost:3000
With Docker
USE_DOCKER=true ./build.sh
# Starts:
# backend → python:3.12-slim on :8000
# frontend → node:20-alpine on :3000
Running the Tests
cd backend
pytest tests/ -v
Expected output — all 19 tests passing:
test_version_header_v1 PASSED
test_version_header_v2 PASSED
test_v1_user_flat_name PASSED
test_v2_user_split_name PASSED
test_v1_team_member_count PASSED
test_v2_team_member_total PASSED
test_v1_deprecation_headers PASSED
test_v2_no_deprecation_headers PASSED
test_versions_endpoint PASSED
test_version_detail PASSED
test_version_not_found PASSED
test_migration_guide_v1_to_v2 PASSED
test_migration_guide_not_found PASSED
test_v2_cursor_pagination PASSED
test_v2_analytics PASSED
test_v2_structured_error PASSED
test_compat_shim_v1_shape PASSED
test_health PASSED
test_root_lists_versions PASSED
19 passed in 2.04s
Functional Verification
Run these curl commands in sequence to verify every behaviour described in this article.
# 1. v1 response — flat name field
curl http://localhost:8000/api/v1/users/1
# → {"id":1, "name":"Alice Johnson", "role":"admin", ...}
# 2. v2 response — split name, structured envelope
curl http://localhost:8000/api/v2/users/1
# → {"data": {"first_name":"Alice", "last_name":"Johnson", ...}}
# 3. Confirm deprecation headers on v1
curl -I http://localhost:8000/api/v1/users
# → Sunset: 2025-12-31
# → Deprecation: 2025-03-01
# → X-Deprecation-Notice: API v1 is deprecated...
# 4. Confirm no deprecation headers on v2
curl -I http://localhost:8000/api/v2/users
# → X-API-Version: v2 (no Sunset or Deprecation header)
# 5. Migration guide as JSON
curl http://localhost:8000/api/migration/v1/v2 | python3 -m json.tool
# 6. Backward compat shim serving v1 shape from v2 logic
curl http://localhost:8000/api/v2/compat/v1/users
# → {"users": [...], "page":1, "limit":10} ← v1 envelope from v2 data
# 7. All registered versions
curl http://localhost:8000/api/versions
Key Insights
Sunset Header Timing — Industry standard is 6–18 months between deprecation announcement and sunset. GitHub gave users 12 months for their v3 to v4 GraphQL migration. Stripe pins clients to their original version indefinitely for API keys created before the version change — they never break existing integrations.
Shims Are Temporary Scaffolding — Track which clients are still hitting the shim endpoint via the analytics dashboard. Plan to sunset the shim 3–6 months after the primary version is sunset. If you do not track this, shims live forever.
Serve Your Migration Guide as JSON — Do not document migration as a blog post. Serve it as a machine-readable endpoint. Your SDK can auto-check it on startup and warn developers in their terminal the moment they run your library. That is how you get 90% migration compliance before the deadline.
Success Criteria
By the end of this build, verify each of the following:
Call
/api/v1/usersand confirm thename,page, andlimitfields are presentCall
/api/v2/usersand confirmfirst_name,last_name, andnext_cursorare presentSee
SunsetandDeprecationheaders on every v1 responseSee no deprecation headers whatsoever on v2 responses
Call
/api/v2/compat/v1/usersand receive v1-shaped data produced by v2 business logicLoad
/api/migration/v1/v2and read the ordered steps and field mappingsOpen the React dashboard and see live version adoption charts, the API explorer with header diffs, and the migration checklist
The Big Picture
You are now building the foundation that makes every future API change safe. The version registry you built today does not just track lifecycle states — it is the control plane for everything that comes next.
Day 121 adds API rate limiting. In mature systems, rate limits are per-version: v1 clients get a lower quota to create economic pressure to migrate, while v2 clients get full allocation. The version detection middleware you wrote today runs before the rate limiter, making version-aware throttling a straightforward addition — not a rewrite.




