Installs into .claude/skills of the current project.
Are you the author of Api Versioning Strategies?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/paulpas-api-versioning-strategies)
---
name: api-versioning-strategies
description: Implements API versioning strategies (URL path, Accept header, query
parameter, media type) to manage backward compatibility, deprecation timelines,
and migration paths while maintaining stable contracts for consumers.
license: MIT
compatibility: opencode
metadata:
version: "1.0.0"
domain: coding
triggers: api versioning, url path versioning, accept header, breaking changes, api deprecation, sunset header, backward compatibility, api migration api deprecation
archetypes:
- tactical
- generation
anti_triggers:
- brainstorming
- vague ideation
- code golf
- over-engineering
response_profile:
verbosity: low
directive_strength: high
abstraction_level: operational
role: implementation
scope: implementation
output-format: code
related-skills: api-design, input-validation, code-review, security-review
---
# API Versioning Strategies
Manages the full API versioning lifecycle — choosing a versioning strategy, classifying breaking vs. compatible changes, deprecating old versions gracefully, and guiding consumers through migrations with zero downtime.
## TL;DR Checklist
- [ ] Choose URL path versioning for public APIs (most discoverable); Accept header for internal/B2B APIs
- [ ] Classify every change as BREAKING or COMPATIBLE before release
- [ ] Always include `Deprecation` and `Sunset` headers when serving deprecated versions
- [ ] Support dual-version operation during migration windows (minimum 90 days)
- [ ] Never remove a field from responses — mark it deprecated, keep returning it for the sunset period
- [ ] Never make an optional field required in an existing endpoint
---
## When to Use
Use this skill when:
- Designing versioning strategy for a new public or internal API
- Planning to introduce breaking changes to an existing API
- Deprecating old API versions and migrating consumers
- Classifying whether a proposed API change is breaking or compatible
- Building migration guides for API consumers
- Auditing an existing API for proper versioning and deprecation practices
## When NOT to Use
Avoid this skill for:
- Internal microservice communication (use gRPC schema evolution instead)
- GraphQL APIs (they have their own evolution model — see `coding-graphql-schema-design`)
- UI/frontend versioning (separate concern from API contracts)
---
## Core Workflow
1. **Select Versioning Strategy** — Match the strategy to your audience and constraints. URL path for public discoverability; Accept header for clean URLs and B2B clients; media type negotiation when you already use content negotiation. **Checkpoint:** The chosen strategy must support all consumers without requiring SDK changes for compatible updates.
2. **Classify the Change** — Determine whether each proposed change is BREAKING or COMPATIBLE using the classification rules below. **Checkpoint:** All breaking changes MUST go into a new version; compatible changes MAY be added to any version.
3. **Implement Versioned Endpoints** — Code the version-specific logic with typed signatures, docstrings, and consistent error handling. **Checkpoint:** Each version returns exactly what its contract promises — nothing more, nothing less.
4. **Add Deprecation Signals** — Serve deprecated versions with `Deprecation: true` and `Sunset` headers. Provide migration guidance in response bodies. **Checkpoint:** Clients receive at least 90 days' notice before a version is retired.
5. **Support Migration** — Run both old and new versions simultaneously during the migration window. Track adoption via analytics. **Checkpoint:** No consumer is broken by the sunset date.
---
## Implementation Patterns / Reference Guide
### Strategy 1: URL Path Versioning (`/v1/`, `/v2/`)
Most common for public APIs. The version is part of the route, making it explicit, cacheable, and trivially discoverable.
**How it works:** Every endpoint is prefixed with a version segment. `GET /api/v1/users` and `GET /api/v2/users` are entirely separate routes.
**Pros:**
- Trivially discoverable — clients see the version in the URL
- Perfect cache isolation per version (CDN caches don't collide)
- No client SDK changes needed for compatible updates within a version
- Easy to A/B test different versions
- Every HTTP method, header, and query parameter works naturally
**Cons:**
- Version exposed in every URL (some consider it ugly)
- Route duplication if versions share logic
- Harder to enforce "only one version active"
**When to use:** Public APIs where discoverability matters most. Used by Stripe (`/v1/customers`), Google Cloud, many REST APIs.
**Real-world example — Stripe:**
```
GET https://api.stripe.com/v1/customers
GET https://api.stripe.com/v1/charges
POST https://api.stripe.com/v1/payment_intents
```
```python
from typing import Any
from fastapi import FastAPI, APIRouter, Request
from pydantic import BaseModel, Field
# --- V1: Legacy flat response structure ---
class UserResponseV1(BaseModel):
"""Legacy user model — flat structure returned by /v1 endpoints."""
id: str
name: str
email: str
created_at: str # ISO 8601 string
# --- V2: Structured response with metadata (JSON:API inspired) ---
class UserAttributes(BaseModel):
"""Structured user attributes used in V2 responses."""
name: str = Field(..., description="Full display name")
email: str = Field(..., description="Primary email address")
created_at: str = Field(..., description="Account creation timestamp in ISO 8601")
class UserLinks(BaseModel):
"""Hypermedia links for a user resource."""
self: str = Field(..., description="Link to this resource")
orders: str | None = Field(None, description="Link to user's order history")
class UserData(BaseModel):
"""V2 user response envelope with data and metadata."""
data: UserAttributes
links: UserLinks
app = FastAPI(title="Versioned API Example")
router_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
router_v2 = APIRouter(prefix="/api/v2", tags=["v2"])
@router_v1.get("/users/{user_id}", response_model=UserResponseV1)
async def get_user_v1(user_id: str) -> UserResponseV1:
"""Retrieve a user by ID using the legacy flat structure.
This endpoint returns a flat JSON object for backward compatibility
with clients that expect the V1 response shape.
Args:
user_id: The unique identifier of the user.
Returns:
UserResponseV1 with id, name, email, created_at fields.
Raises:
HTTPException 404 if user not found.
"""
# Simulated database lookup
user = {"id": user_id, "name": "Alice Smith", "email": "alice@example.com", "created_at": "2024-01-15T08:30:00Z"}
if not user:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
return UserResponseV1(**user)
@router_v2.get("/users/{user_id}", response_model=UserData)
async def get_user_v2(user_id: str, request: Request) -> UserData:
"""Retrieve a user by ID using the structured V2 envelope.
V2 wraps the response in a data/metadata envelope with hypermedia
links, following JSON:API conventions for better discoverability.
Args:
user_id: The unique identifier of the user.
request: FastAPI request object for generating self-link URLs.
Returns:
UserData with attributes (name, email, created_at) and links.
Raises:
HTTPException 404 if user not found.
"""
from fastapi import HTTPException
base_url = str(request.base_url).rstrip("/")
user = {"id": user_id, "name": "Alice Smith", "email": "alice@example.com", "created_at": "2024-01-15T08:30:00Z"}
if not user:
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
return UserData(
data=UserAttributes(
name=user["name"],
email=user["email"],
created_at=user["created_at"],
),
links=UserLinks(
self=f"{base_url}/api/v2/users/{user_id}",
orders=f"{base_url}/api/v2/users/{user_id}/orders",
),
)
app.include_router(router_v1)
app.include_router(router_v2)
```
### Strategy 2: Accept Header Versioning (`Accept: application/vnd.myapi.v2+json`)
Uses the HTTP `Accept` header to negotiate which version of the response format to return. The URL stays clean and versionless.
**How it works:** The client specifies the desired API version in the `Accept` header. The server routes to the appropriate handler based on the vendor-specific media type.
**Pros:**
- Clean, stable URLs — no `/v1/` or `/v2/` prefixes
- Content negotiation is a well-understood HTTP mechanism (RFC 7231)
- Same endpoint can return different representations
**Cons:**
- Harder to debug — version not visible in the URL
- Caching complications: `Vary: Accept` means the CDN must cache multiple variants per URL
- Less discoverable — developers can't guess the endpoint structure from a browser visit
- Not all HTTP clients/proxies handle vendor media types well
**When to use:** Internal APIs, B2B partnerships where clean URLs matter, or when you already do content negotiation. Used by GitHub (`Accept: application/vnd.github.v3+json`).
**Real-world example — GitHub:**
```bash
# GitHub uses Accept header with date-based versioning
curl -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/users/octocat
# Response includes: x-github-api-version-selected: 2022-11-28
```
```python
from typing import Any
from fastapi import FastAPI, APIRouter, Request, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI(title="Accept Header Versioning Example")
class UserV1(BaseModel):
"""Legacy flat user representation for Accept: application/vnd.myapi.v1+json."""
id: str
name: str
email: str
class UserV2(BaseModel):
"""Structured user representation with metadata for Accept: application/vnd.myapi.v2+json."""
data: dict[str, Any]
meta: dict[str, str] = {
"api_version": "2.0",
"documentation": "https://api.example.com/docs/v2",
}
SUPPORTED_VERSIONS = {
"application/vnd.myapi.v1+json": UserV1,
"application/vnd.myapi.v2+json": UserV2,
}
router_users = APIRouter(tags=["users"])
@router_users.get("/users/{user_id}")
async def get_user_with_accept_version(
user_id: str,
accept_header: str = Header(..., alias="Accept"),
):
"""Route to the correct version based on Accept header content negotiation.
Clients specify the desired API version via the Accept header using
vendor-specific media types. The server returns the matching schema.
Supported versions:
- application/vnd.myapi.v1+json (legacy flat structure)
- application/vnd.myapi.v2+json (structured with metadata)
Args:
user_id: The unique identifier of the user.
accept_header: The Accept header value from the request.
Returns:
JSONResponse with the matched version's schema.
"""
# Simulated user data
user_data = {
"id": user_id,
"name": "Alice Smith",
"email": "alice@example.com",
"created_at": "2024-01-15T08:30:00Z",
}
# Match Accept header to supported version
for media_type, model in SUPPORTED_VERSIONS.items():
if media_type in accept_header or "application/json" in accept_header and media_type == list(SUPPORTED_VERSIONS.keys())[-1]:
if issubclass(model, UserV2):
return JSONResponse(
content={
"data": user_data,
"meta": {"api_version": "2.0", "documentation": "https://api.example.com/docs/v2"},
}
)
else:
return JSONResponse(
content={"id": user_data["id"], "name": user_data["name"], "email": user_data["email"]}
)
# No matching version — fall back to v1 as default
return JSONResponse(
content={
"error": "Unsupported media type. Supported: application/vnd.myapi.v1+json, application/vnd.myapi.v2+json",
"supported_versions": list(SUPPORTED_VERSIONS.keys()),
},
status_code=406,
)
app.include_router(router_users)
```
### Strategy 3: Query Parameter Versioning (`?version=1`)
The version is passed as a query parameter. Simple but considered the least elegant approach.
**How it works:** `GET /api/users?version=1` vs `GET /api/users?version=2`. A single route handles all versions.
**Pros:**
- Extremely simple to implement — one route, version branching inside the handler
- No URL path duplication
- Easy to test different versions in a browser
**Cons:**
- Pollutes query parameters
- Same `Vary` problems as Accept header versioning for caching
- Not RESTful — version is not a resource attribute
- Harder to document (Swagger/OpenAPI struggles with dynamic routes)
- Most widely criticized approach by API designers
**When to use:** Internal tools, quick prototypes, or when you absolutely cannot change URLs. Rarely recommended for production public APIs.
### Strategy 4: Media Type / Content Negotiation (`Accept: application/json;version=1`)
Embeds version information in the media type itself, without vendor-specific subtypes.
**How it works:** `Accept: application/json; version=1` or `Accept: application/vnd.api+json;version=1`. Similar to Accept header versioning but uses the standard media type parameters.
**Pros:**
- Uses standard HTTP content negotiation (RFC 7231)
- Cleaner than query params
- Compatible with existing caching infrastructure if `Vary` is set properly
**Cons:**
- Media type parameters are rarely used in practice — most tools don't support them well
- OpenAPI/Swagger documentation struggles with media type parameters
- Harder to test and debug in browsers
**When to use:** When you want standard HTTP semantics and your tooling supports media type parameters. Less common than vendor-specific subtypes.
### Strategy 5: Date-Based Versioning (Stripe/GitHub approach)
Uses a date string as the version identifier rather than a number. `Stripe-Version: 2024-12-18.acacia` or `X-GitHub-Api-Version: 2022-11-28`.
**How it works:** The API endpoint itself is not versioned. Instead, the client sends a date-based header indicating which API "snapshot" to use. The server maps dates to API implementations internally.
**Pros:**
- URLs are completely clean — no `/v1/`, no vendor subtypes in Accept
- Backwards compatible by default — old dates keep working forever
- Natural migration path: upgrade the date header when you're ready
- Version is human-readable (you know exactly which era of the API it represents)
**Cons:**
- Requires SDK/client-side changes to update the version header
- Less discoverable — consumers must read docs to know which version string to use
- Harder to communicate "what changed between dates" without good changelogs
- The server must maintain backward compatibility indefinitely for old date headers
**When to use:** Mature, high-volume APIs where URL cleanliness matters and you have SDK support. Used by Stripe (`Stripe-Version` header) and GitHub (`X-GitHub-Api-Version` header).
**Real-world example — Stripe (2024-2025):**
```bash
# Stripe uses date-based versioning via custom header
curl -u sk_test_...: \
-H "Stripe-Version: 2024-12-18.acacia" \
https://api.stripe.com/v1/customers
# The /v1/ path is stable (never changes); the Stripe-Version header
# controls which feature set is active. This means the URL never changes,
# only the header does when you want new features.
```
**Real-world example — GitHub:**
```bash
# GitHub uses date-based versioning via X-GitHub-Api-Version header
curl -H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/users/octocat
# Response confirms which version was selected:
# x-github-api-version-selected: 2022-11-28
```
---
## Breaking vs. Non-Breaking Changes
### Classification Rules
A **breaking change** is any modification that causes existing clients to fail — either by returning unexpected data shapes, changing semantics, or removing functionality. A **compatible (non-breaking) change** is one that existing clients will continue to process correctly without modification.
### Breaking Changes (MUST go into a new version)
| Change | Why It Breaks | Example |
|--------|---------------|---------|
| Removing an endpoint | Clients calling the URL get 404 | Deleting `/api/v1/legacy/reports` |
| Removing a response field | Parsers that read `.data.field` get `undefined` / `KeyError` | Removing `email` from user response |
| Renaming a field | Same as removing + adding — old names return null | `user_name` → `name` in response JSON |
| Changing a field type | Type coercion may fail or lose data | `age: int` → `age: string` |
| Making an optional field required | Existing requests without the field get 422 | Adding `phone_number: required` to POST /users |
| Changing error codes/messages (for coded errors) | Clients matching on `error.code` break | `INVALID_EMAIL` → `VALIDATION_EMAIL_INVALID` |
| Removing an enum value | Clients sending that value get 422 | Removing `"archived"` from `status` enum |
| Changing the base URL or domain | DNS/client config breaks entirely | Moving from `api.example.com` to `v2-api.example.com` |
| Changing HTTP method semantics | PUT clients calling POST endpoints fail | Converting `GET /users/{id}` to `DELETE` |
### Compatible Changes (MAY be added to any version)
| Change | Why It's Safe | Example |
|--------|---------------|---------|
| Adding a new endpoint | Existing routes unaffected | Adding `/api/v1/teams` when only `/api/v1/users` exists |
| Adding an optional request parameter | Existing requests without the param still work | Adding `?include_metadata=false` to list endpoints |
| Adding a response field | Parsers that ignore unknown fields are fine (JSON spec) | Adding `avatar_url` to user response |
| Adding an enum value | Existing clients don't send the new value | Adding `"suspended"` to `status` enum |
| Relaxing validation rules | Stricter→looser is always safe | Accepting both `"us"` and `"USA"` for country codes |
| Changing default sort order | Clients explicitly sorting are unaffected | Default from alphabetical → by creation date |
| Adding pagination parameters | Existing requests without pagination still return full results | Adding `?limit=100&cursor=...` to list endpoints |
| Returning more data in a response array | Array consumers iterate over all items | Adding new fields to each item in `/users` list response |
### Classification Decision Tree
```
Is the change to an existing endpoint's response shape?
├── YES → Is it adding a field (not required)?
│ ├── YES → COMPATIBLE ✓
│ └── NO → Is it removing or renaming a field?
│ ├── YES → BREAKING ✗
│ └── NO → Is it changing a field type?
│ ├── YES → BREAKING ✗
│ └── NO → COMPATIBLE ✓
Is the change to an existing endpoint's request handling?
├── YES → Is it adding an optional parameter?
│ ├── YES → COMPATIBLE ✓
│ └── NO → Is it making a required field?
│ ├── YES → BREAKING ✗
│ └── NO → Is it removing validation constraints?
│ ├── YES → COMPATIBLE ✓ (stricter→looser)
│ └── NO → BREAKING ✗
Is the change adding a new endpoint?
└── YES → COMPATIBLE ✓
```
### Real-World Examples of Breaking Changes
```python
# ❌ BREAKING — Removing a field from response
class UserResponseOLD(BaseModel):
id: str
name: str
email: str # ← REMOVED in V2
# Existing client code breaks:
# user["email"] → KeyError / undefined
# ✅ COMPATIBLE — Adding a new field to response
class UserResponseNew(BaseModel):
id: str
name: str
email: str
phone: str | None = None # ← NEW optional field
# Existing client code still works — it just ignores the new field.
# ❌ BREAKING — Changing a field type
class OrderV1(BaseModel):
total_cents: int # ← integer, e.g., 1999
class OrderV2(BaseModel):
total_cents: str # ← STRING, e.g., "1999"
# Existing client that does: price = order["total_cents"] * 2
# → TypeError: can't multiply sequence by int
# ✅ COMPATIBLE — Adding an optional request parameter
@router.post("/users")
async def create_user(
body: CreateUserRequest, # existing required fields
notify_admin: bool = False, # ← NEW optional parameter
):
"""Adding this parameter doesn't break any existing client."""
...
```
---
## Deprecation and Migration Patterns
### Standard HTTP Headers for Deprecation
The HTTP specification defines three headers specifically for API deprecation. Every deprecated endpoint should include these in its responses:
| Header | Purpose | Example Value |
|--------|---------|---------------|
| `Deprecation` | Signals the resource is deprecated | `true` or a timestamp |
| `Sunset` | Date after which the version will be unavailable | `Sat, 01 Jun 2027 00:00:00 GMT` |
| `Link` | Link to migration documentation or successor version | `<https://docs.example.com/migration/v1-to-v2>; rel="successor-version"` |
```python
from typing import Any
from datetime import datetime, timezone
def add_deprecation_headers(
response: Any,
sunset_date: datetime = datetime(2027, 6, 1, tzinfo=timezone.utc),
migration_url: str = "https://docs.example.com/migration/v1-to-v2",
) -> None:
"""Attach standard deprecation headers to an API response.
These headers inform HTTP clients that the served version is deprecated
and provide a sunset date and migration guidance. Standard HTTP clients
(curl, Postman, SDKs) should surface these warnings to developers.
Args:
response: The FastAPI Response object to modify in-place.
sunset_date: The date after which this API version will return 410 Gone.
migration_url: URL to the migration guide for consumers.
"""
from fastapi import Response as FastAPIResponse
if isinstance(response, FastAPIResponse):
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = sunset_date.strftime("%a, %d %b %Y %H:%M:%S GMT")
response.headers["Link"] = f'<{migration_url}>; rel="successor-version"'
# Usage in a FastAPI middleware or endpoint
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
DEPRECATION_CONFIG = {
"sunset_date": datetime(2027, 6, 1, tzinfo=timezone.utc),
"migration_url": "https://docs.example.com/migration/v1-to-v2",
}
class DeprecationMiddleware(BaseHTTPMiddleware):
"""Automatically adds deprecation headers to v1 API responses."""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if "/api/v1/" in request.url.path:
add_deprecation_headers(
response,
sunset_date=DEPRECATION_CONFIG["sunset_date"],
migration_url=DEPRECATION_CONFIG["migration_url"],
)
return response
app = FastAPI()
app.add_middleware(DeprecationMiddleware)
```
### Sunset Timeline Best Practices
| Phase | Timeline | Action | Client Behavior |
|-------|----------|--------|-----------------|
| **Announcement** | Day 0 | Add `Deprecation` and `Sunset` headers; publish changelog | Clients see warnings in response headers |
| **Grace period** | Days 1–90 | Serve both old and new versions; log version usage | Clients have time to migrate without pressure |
| **Hard warning** | Day +60 | Add `Warning: 999` header with deprecation message | Clients get HTTP Warning header (RFC 7234) |
| **Sunset** | Day +90 | Return `410 Gone` for deprecated endpoints; remove from docs | Clients must migrate or update SDKs |
```python
from datetime import datetime, timezone
def check_sunset_status(
sunset_date: datetime = datetime(2027, 6, 1, tzinfo=timezone.utc),
) -> dict[str, Any]:
"""Determine the current deprecation phase based on sunset date.
Returns a status dict that can be used to conditionally add headers
or modify responses based on how close the sunset date is.
Args:
sunset_date: The date after which the API version will return 410 Gone.
Returns:
Dict with 'phase', 'days_remaining', 'warning_header' keys.
"""
now = datetime.now(timezone.utc)
days_remaining = (sunset_date - now).days
if days_remaining > 90:
return {
"phase": "deprecation_announced",
"days_remaining": days_remaining,
"warning_header": None,
"headers": {"Deprecation": "true"},
}
elif days_remaining > 30:
return {
"phase": "migration_window",
"days_remaining": days_remaining,
"warning_header": f"999; url=\"https://docs.example.com/migration\"; msg=\"API v1 deprecated; sunset in {days_remaining} days\"",
"headers": {"Deprecation": "true", "Warning": f"999 sunset-{sunset_date.strftime('%Y%m%d')} \"API v1 will be unavailable after {sunset_date.strftime('%b %d, %Y')}\""},
}
elif days_remaining > 0:
return {
"phase": "final_warning",
"days_remaining": days_remaining,
"warning_header": f"999; url=\"https://docs.example.com/migration\"; msg=\"URGENT: API v1 sunsetting in {days_remaining} days\"",
"headers": {"Deprecation": "true", "Warning": f"999 sunset-{sunset_date.strftime('%Y%m%d')} \"CRITICAL: API v1 will be unavailable after {sunset_date.strftime('%b %d, %Y')}\""},
}
else:
return {
"phase": "sunsetting",
"days_remaining": days_remaining,
"warning_header": None,
"headers": {}, # Return 410 Gone instead
}
# Usage in endpoint to conditionally respond
async def get_user_v1_with_sunset(user_id: str):
"""V1 endpoint with dynamic sunset behavior."""
status = check_sunset_status()
if status["phase"] == "sunsetting":
from fastapi import HTTPException
raise HTTPException(
status_code=410,
detail={
"error": {
"code": "API_VERSION_SUNSET",
"message": f"API v1 is no longer available. Please migrate to v2.",
"migration_url": "https://docs.example.com/migration/v1-to-v2",
"sunset_date": "2027-06-01T00:00:00Z",
}
},
)
# ... return user data with deprecation headers ...
response = JSONResponse(content={"id": user_id, "name": "Alice"})
add_deprecation_headers(response)
if status.get("warning_header"):
response.headers["Warning"] = status["warning_header"]
return response
```
### Dual-Version Support During Migration
Run both versions simultaneously. This is the only safe migration path for public APIs.
```python
from typing import Any
from fastapi import FastAPI, APIRouter, Request
from pydantic import BaseModel
app = FastAPI(title="Dual-Version Migration Example")
class CreateUserRequest(BaseModel):
"""Shared request model — same contract across versions."""
name: str
email: str
class UserResponseV1(BaseModel):
id: str
name: str
email: str
created_at: str
class UserResponseV2(BaseModel):
data: dict[str, Any]
metadata: dict[str, str] = {"api_version": "2.0"}
router_v1 = APIRouter(prefix="/api/v1")
router_v2 = APIRouter(prefix="/api/v2")
# Shared business logic — both versions call the same service layer
async def create_user_service(data: CreateUserRequest) -> dict[str, Any]:
"""Core user creation logic shared across all API versions.
This isolates business logic from version-specific response formatting.
Both V1 and V2 routes call this function and format the result differently.
Args:
data: Validated create user request.
Returns:
Dictionary with id, name, email, created_at fields.
"""
# Database insert would go here
return {
"id": "usr_abc123",
"name": data.name,
"email": data.email,
"created_at": "2024-06-15T10:00:00Z",
}
@router_v1.post("/users", response_model=UserResponseV1)
async def create_user_v1(body: CreateUserRequest) -> UserResponseV1:
"""V1: Returns flat response structure for backward compatibility."""
user = await create_user_service(body)
return UserResponseV1(**user)
@router_v2.post("/users", response_model=UserResponseV2)
async def create_user_v2(body: CreateUserRequest, request: Request) -> UserResponseV2:
"""V2: Returns structured envelope with metadata."""
user = await create_user_service(body)
return UserResponseV2(
data=user,
metadata={
"api_version": "2.0",
"documentation": str(request.base_url).rstrip("/") + "/docs",
"migration_guide": "https://docs.example.com/migration/v1-to-v2",
},
)
app.include_router(router_v1)
app.include_router(router_v2)
```
### Migration Guide Template
Every deprecated version must have a corresponding migration guide.
```markdown
# Migration Guide: API v1 → v2
## What Changed
### Response Structure
- V1 returns flat objects; V2 wraps in `data` envelope with `metadata`
- All dates now use ISO 8601 format consistently (`2024-01-15T08:30:00Z`)
### Breaking Changes
- **None** — this migration is fully compatible at the data level
- The only change is response envelope shape; field values are identical
## Code Migration
### Before (v1)
```python
import requests
response = requests.get("https://api.example.com/api/v1/users/abc123")
user = response.json() # {"id": "abc123", "name": "Alice", ...}
print(user["email"])
```
### After (v2)
```python
import requests
response = requests.get("https://api.example.com/api/v2/users/abc123")
user = response.json() # {"data": {...}, "metadata": {...}}
print(user["data"]["email"])
```
## Timeline
| Date | Event |
|------|-------|
| 2025-06-01 | v1 deprecated — `Deprecation: true` header added |
| 2026-12-01 | v1 sunset — returns 410 Gone |
## Need Help?
- [Full API Reference](https://docs.example.com/api/v2)
- [SDK Migration Scripts](https://github.com/example/migration-tools)
"""
```
---
## BAD vs GOOD Examples
### ❌ BAD — No versioning, breaking changes deployed directly
```python
# ❌ BAD: Modifying existing endpoints without versioning
@app.route("/api/users/<user_id>", methods=["GET"])
def get_user_bad(user_id):
"""This endpoint works fine until you change the response format."""
# Developer decides to add a new field...
user = User.get(user_id)
return jsonify({
"id": user.id,
"name": user.name,
"email": user.email,
"avatar_url": user.avatar.url, # ← NEW: existing clients don't expect this
"profile": { # ← NEW NESTED OBJECT: breaks flat parsers
"bio": user.bio,
"location": user.location,
}
})
# Developer later decides to rename a field...
return jsonify({
"user_id": user.id, # ← RENAMED from "id" — existing clients break
"full_name": user.name, # ← RENAMED from "name" — existing clients break
"email_address": user.email, # ← RENAMED from "email" — existing clients break
})
# Developer removes a field...
return jsonify({
"user_id": user.id,
"full_name": user.name,
# "email_address" removed — clients that read this get undefined/KeyError
})
```
### ✅ GOOD — Versioned endpoints with clean separation
```python
# ✅ GOOD: Each version has its own route and response model
from fastapi import FastAPI, APIRouter
from pydantic import BaseModel
app = FastAPI()
class UserFlat(BaseModel):
"""V1 response model — flat structure. Stable contract, never changes."""
id: str
name: str
email: str
class UserNested(BaseModel):
"""V2 response model — structured with nested profile data."""
id: str
attributes: dict[str, str]
links: dict[str, str]
class Config:
json_schema_extra = {
"example": {
"id": "usr_123",
"attributes": {"name": "Alice", "email": "alice@example.com"},
"links": {"self": "/api/v2/users/usr_123", "profile": "/api/v2/users/usr_123/profile"},
}
}
v1_router = APIRouter(prefix="/api/v1")
v2_router = APIRouter(prefix="/api/v2")
@v1_router.get("/users/{user_id}", response_model=UserFlat)
async def get_user_v1(user_id: str):
"""V1 endpoint — flat structure. Never add/remove/renamed fields here."""
user = User.get(user_id)
return UserFlat(id=user.id, name=user.name, email=user.email)
@v2_router.get("/users/{user_id}", response_model=UserNested)
async def get_user_v2(user_id: str):
"""V2 endpoint — structured with nested data and hypermedia links."""
user = User.get(user_id)
return UserNested(
id=user.id,
attributes={"name": user.name, "email": user.email},
links={
"self": f"/api/v2/users/{user_id}",
"profile": f"/api/v2/users/{user_id}/profile",
},
)
app.include_router(v1_router)
app.include_router(v2_router)
```
### ❌ BAD — Deprecation without migration support
```python
# ❌ BAD: Adding a deprecation header but no sunset date or migration path
@app.after_request
def bad_deprecation(response):
"""Just adding 'Deprecation: true' is not enough."""
if "/api/v1/" in request.path:
response.headers["Deprecation"] = "true"
# ← No Sunset header — clients don't know when it goes away
# ← No Link to migration docs — consumers are stranded
return response
```
### ✅ GOOD — Complete deprecation with actionable headers
```python
# ✅ GOOD: Full deprecation signal with sunset date and migration link
from datetime import datetime, timezone
@app.after_request
def good_deprecation(response):
"""Complete deprecation signal per HTTP best practices."""
if "/api/v1/" in request.path:
response.headers["Deprecation"] = "true"
# Sunset date: give consumers 18 months to migrate
sunset = datetime(2027, 6, 1, tzinfo=timezone.utc)
response.headers["Sunset"] = sunset.strftime("%a, %d %b %Y %H:%M:%S GMT")
# Link header pointing to migration documentation
response.headers["Link"] = (
'<https://docs.example.com/migration/v1-to-v2>; '
'rel="successor-version"'
)
# Warning header for HTTP clients that parse RFC 7234 warnings
days_left = (sunset - datetime.now(timezone.utc)).days
response.headers["Warning"] = (
f'999 sunset-20270601 "API v1 will be unavailable after June 1, 2027"'
)
return response
```
---
## Industry Practices (2024–2025)
### Stripe — Date-Based Versioning + Stable URLs
Stripe uses URL path (`/v1/`) for the stable API surface combined with a `Stripe-Version` header for feature control. The `/v1/` prefix never changes; only the date-based version header controls which features are active.
```bash
# The /v1/ path is permanent. Only the Stripe-Version header changes.
curl -H "Stripe-Version: 2024-12-18.acacia" \
https://api.stripe.com/v1/customers
# Stripe's approach means:
# 1. URL structure never breaks (stable /v1/ prefix)
# 2. New features are opt-in via the version header
# 3. Old feature sets remain available indefinitely
# 4. SDKs manage version headers automatically
```
### GitHub — Accept Header with Date-Based Versions
GitHub uses `X-GitHub-Api-Version` header with date strings for API versioning. The response includes `x-github-api-version-selected` to confirm which version was active.
```bash
# Request with explicit API version
curl -H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/owner/repo
# Response confirms the selected version
# x-github-api-version-selected: 2022-11-28
```
GitHub also uses `Deprecation` and `Sunset` headers for deprecated endpoints:
```bash
curl -I "https://api.github.com/repos/owner/repo/commits" 2>&1 | grep -iE "deprecat|sunset|warning"
# Deprecation: true
# Sunset: Sat, 05 Jul 2019 00:00:00 GMT
# Link: <https://docs.github.com/overview/changelog>; rel="successor-version"
```
### Twilio — Date-Based URL Versioning
Twilio embeds the date directly in the URL path. The API base is `https://api.twilio.com/{API_VERSION}/` where `{API_VERSION}` is a date like `2010-04-01`.
```bash
# Twilio's version IS part of the URL path
curl -u ACxxxx:auth_token \
https://api.twilio.com/2010-04-01/Accounts/ACxxx/Messages.json
# This means the API_VERSION segment can be changed to migrate
# Old versions remain available but deprecated; new dates provide new features
```
### General Industry Consensus (2024–2025)
1. **URL path versioning** remains the most common for public REST APIs — it's the most discoverable and simplest to reason about
2. **Date-based versioning** is growing among mature APIs (Stripe, GitHub, Twilio) where SDKs handle version selection
3. **Accept header versioning** is standard in enterprise/B2B APIs where clean URLs matter
4. **Query parameter versioning** is increasingly avoided — most API designers consider it the least professional approach
5. **90-day minimum sunset periods** are now standard; many mature APIs offer 180–365 days
6. **No breaking changes after v1** is the dominant philosophy — most stable public APIs treat their initial version as "final" and add rather than change
---
## Constraints
### MUST DO
- Classify every API change as BREAKING or COMPATIBLE before release — never assume
- Use URL path versioning (`/api/vN/`) for public-facing APIs; Accept header for B2B/internal
- Include `Deprecation: true`, `Sunset` (RFC 1123 date), and `Link` headers on all deprecated endpoints
- Support at least two active versions simultaneously during migration windows
- Maintain a minimum 90-day sunset notice period before retiring any version
- Write OpenAPI/Swagger documentation for each API version separately — never maintain docs per-version in one file
- Use typed request/response models (Pydantic, zod, TypeScript interfaces) — never return untyped dicts
- Return consistent error envelopes across all versions: `{"error": {"code": "...", "message": "..."}}`
- Document breaking changes in a public changelog before each new version release
- Test deprecated endpoints through their entire sunset period — monitor that no consumer is broken on the sunset date
### MUST NOT DO
- Remove response fields without keeping them (nullable) until the version reaches 410 Gone
- Make optional request parameters required in an existing version
- Change enum values to different meanings — add new values, deprecate old ones
- Use query parameter versioning for production public APIs
- Deploy breaking changes to a currently-documented API version without creating a new version
- Set sunset dates less than 90 days out — this breaks consumer migration plans
- Return different error formats between versions — keep the error envelope consistent
- Remove deprecated endpoints before their Sunset date has passed — return 410 Gone instead
- Share response model classes between versions that have different fields — each version needs its own schema
---
## Output Template
When implementing or reviewing API versioning, produce:
1. **Versioning Strategy Recommendation** — Chosen approach with rationale (URL path / Accept header / date-based), compared against alternatives
2. **Change Classification Matrix** — Table of proposed changes classified as BREAKING or COMPATIBLE with rationale
3. **Versioned Endpoint Specifications** — HTTP method, path, request/response schemas, and status codes for each version
4. **Deprecation Plan** — Sunset timeline, header signals, and migration guide links
5. **Migration Guide** — Before/after code examples, SDK changes required, data mapping notes
6. **Cache Strategy** — How caching (CDN, browser, proxy) differs per version with `Vary` header recommendations
---
## Related Skills
| Skill | Purpose |
|-------|---------|
| `api-design` | General REST API design patterns — resource modeling, pagination, rate limiting |
| `input-validation` | Request validation strategies for typed APIs (Pydantic, zod, JSON Schema) |
| `code-review` | Review versioned API implementations for correctness and backward compatibility |
| `security-review` | Audit versioned endpoints for authZ/authN consistency across versions |
---
## References
- [RFC 7231 — Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2)
- [Stripe API Reference — Versioning](https://stripe.com/docs/api/versioning)
- [GitHub Changelog — API Versioning](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api)
- [Google Cloud REST API Versioning](https://cloud.google.com/apis/design/versioning)
- [Microsoft REST API Guidelines — Versioning](https://github.com/Microsoft/api-guidelines/blob/vNext/Guidelines.md#712-versioning)
- [RFC 8043 — IANA Considerations for Media Type Registration](https://datatracker.ietf.org/doc/html/rfc8043)