/api/v1/resource) for public APIs due to discoverability and caching; use header versioning for internal APIs where you control all clients./api/v{N}/resource (URL path) or Accept: application/vnd.api.v1+json (content negotiation)| Strategy | Format | URL Impact | Client Adoption | Caching | Routing Complexity | Best For |
|---|---|---|---|---|---|---|
| URL path | /api/v1/users | Version in URL | Trivial | Excellent | Low | Public APIs, most common |
| Query parameter | /api/users?v=1 | Param appended | Easy | Good | Low | Optional versioning, testing |
| Accept header | Accept: application/vnd.api.v1+json | None | Moderate | Poor | Medium | Internal APIs, RESTful purists |
| Custom header | X-API-Version: 1 | None | Moderate | Poor | Medium | Internal APIs, microservices |
| Content negotiation | application/vnd.company.resource.v1+json | None | Hard | Poor | High | Enterprise APIs with strict contracts |
| No versioning (evolution) | /api/users (additive only) | None | Trivial | Excellent | None | Internal APIs, rarely-changing APIs |
| Date-based | /api/2025-01-15/users | Date in URL | Moderate | Excellent | Medium | Frequent releases (Stripe model) |
| Semantic (major only) | /api/v2/users | Major version in URL | Trivial | Excellent | Low | Infrequent breaking changes |
START
├── API type is GraphQL?
│ ├── YES → Use schema evolution with @deprecated directives. Do NOT use URL versioning.
│ └── NO ↓
├── API type is gRPC?
│ ├── YES → Use protobuf package versioning (package api.v1;). Route at service level.
│ └── NO ↓
├── Is this a public API with third-party consumers?
│ ├── YES → URL path versioning (/api/v1/...). Maximum discoverability.
│ │ └── Breaking changes very frequent? → Consider date-based versioning (Stripe model)
│ └── NO ↓
├── Do you control all consumers (internal/microservices)?
│ ├── YES → Header versioning (Accept or custom header) keeps URLs clean.
│ │ └── Want zero versioning overhead? → Additive-only evolution + contract tests
│ └── NO ↓
├── Is this a partner API with limited known consumers?
│ ├── YES → URL path versioning + direct migration support per partner
│ └── NO ↓
└── DEFAULT → URL path versioning (/api/v1/...) — safest, most widely understood
Evaluate your API's audience, breaking change frequency, and infrastructure. Public APIs benefit from URL path versioning for maximum discoverability. Internal APIs can use header versioning for cleaner URLs. [src1]
Decision factors:
- Public API → URL path versioning (/api/v1/)
- Internal API, all clients controlled → Header versioning or evolution
- Frequent breaking changes → Date-based versioning (Stripe model)
- GraphQL → Schema evolution only
Verify: Document your choice in your API design spec. Ensure the team agrees before implementation.
Structure your application with version-prefixed route groups. Each version can share common logic through service layers while keeping route handlers separate. [src7]
// Express.js — version-prefixed routers
const express = require('express');
const app = express();
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Verify: curl http://localhost:3000/api/v1/users and curl http://localhost:3000/api/v2/users return version-appropriate responses.
When using header versioning, create middleware that extracts the version from headers and routes to the correct handler. [src4]
function versionRouter(req, res, next) {
const version = req.headers['accept-version']
|| req.headers['x-api-version']
|| '1'; // default to v1
req.apiVersion = parseInt(version, 10);
next();
}
Verify: curl -H "Accept-Version: 2" http://localhost:3000/api/users returns v2 format.
When deprecating an API version, add Sunset and Deprecation headers to every response. This gives clients programmatic notice of the retirement date. [src5]
function deprecateV1(req, res, next) {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
res.set('Link', '</api/v2/docs>; rel="successor-version"');
next();
}
app.use('/api/v1', deprecateV1);
Verify: curl -I http://localhost:3000/api/v1/users includes Deprecation: true and Sunset: headers.
Establish a deprecation timeline: announce 6 months before retirement, provide active migration support for 12 months, remove after 18-24 months. Track version usage to identify migration stragglers. [src2]
Timeline template:
T+0: Release v2, v1 still fully supported
T+6mo: Announce v1 deprecation, add Sunset headers
T+12mo: v1 returns Warning header, rate-limit v1 calls
T+18mo: v1 returns 410 Gone for new integrations
T+24mo: v1 fully removed
Verify: Monitor version usage in your API analytics dashboard. v1 traffic should trend toward zero before removal.
// Input: HTTP requests to /api/v1/users or /api/v2/users
// Output: Version-appropriate JSON responses
// routes/v1/users.js
const router = require('express').Router();
const UserService = require('../../services/userService');
router.get('/users', async (req, res) => {
const users = await UserService.list(req.query);
res.json(users); // v1: flat array response
});
// routes/v2/users.js
router.get('/users', async (req, res) => {
const users = await UserService.list(req.query);
res.json({
data: users,
meta: { total: users.length, page: 1 }
}); // v2: envelope with pagination
});
# Input: HTTP requests to /api/v1/users or /api/v2/users
# Output: Version-appropriate JSON responses
from fastapi import FastAPI, APIRouter
app = FastAPI()
v1 = APIRouter(prefix="/api/v1", tags=["v1"])
v2 = APIRouter(prefix="/api/v2", tags=["v2"])
@v1.get("/users")
async def get_users_v1():
users = await fetch_users()
return users # flat list
@v2.get("/users")
async def get_users_v2():
users = await fetch_users()
return {"data": users, "meta": {"total": len(users)}}
app.include_router(v1)
app.include_router(v2)
// Input: HTTP requests to /api/v1/users or /api/v2/users
// Output: Version-appropriate JSON responses
func usersV1(w http.ResponseWriter, r *http.Request) {
users := []User{{ID: 1, Name: "Alice"}}
json.NewEncoder(w).Encode(users) // flat array
}
func usersV2(w http.ResponseWriter, r *http.Request) {
users := []User{{ID: 1, Name: "Alice"}}
resp := map[string]interface{}{
"data": users,
"meta": map[string]int{"total": len(users)},
}
json.NewEncoder(w).Encode(resp) // envelope
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/users", usersV1)
mux.HandleFunc("/api/v2/users", usersV2)
http.ListenAndServe(":8080", mux)
}
// BAD — changing response shape in existing version
app.get('/api/v1/users', (req, res) => {
// Renamed "name" to "full_name" and wrapped in envelope
res.json({ data: [{ id: 1, full_name: "Alice" }], meta: {} });
// Every existing client just broke
});
// GOOD — breaking change goes in new version
app.get('/api/v1/users', (req, res) => {
res.json([{ id: 1, name: "Alice" }]); // unchanged
});
app.get('/api/v2/users', (req, res) => {
res.json({ data: [{ id: 1, full_name: "Alice" }], meta: {} });
});
// BAD — duplicating all code for each version
src/
v1/controllers/ models/ services/ # full copy
v2/controllers/ models/ services/ # full copy with one change
// GOOD — share business logic, version only the API layer
src/
services/ # shared business logic
routes/v1/ # thin route handlers calling services
routes/v2/ # thin route handlers calling services
// BAD — creating new versions for additive changes
// v1: { id, name }
// v2: { id, name, email } <-- could be additive
// v3: { id, name, email, phone } <-- could be additive
// Now maintaining 3 versions unnecessarily
// GOOD — adding optional fields is NOT a breaking change
app.get('/api/v1/users', (req, res) => {
res.json([{ id: 1, name: "Alice", email: "[email protected]", phone: null }]);
// Old clients safely ignore email and phone
});
400 Bad Request with a version hint. [src1]X-API-Version headers trigger CORS preflight. Fix: include the header in Access-Control-Allow-Headers and handle OPTIONS. [src4]Deprecation: true, Sunset: <date>, and Link: <successor-version> headers at least 6 months before removal. [src5]# Check which API version a response is using
curl -s -D - http://localhost:3000/api/v1/users | head -20
# Test header-based versioning
curl -s -H "Accept-Version: 2" http://localhost:3000/api/users | jq .
# Check for deprecation headers
curl -s -I http://localhost:3000/api/v1/users | grep -i -E "deprecation|sunset|link"
# Count API version distribution in access logs
awk -F'"' '{print $2}' access.log | grep -oP '/api/v\d+' | sort | uniq -c | sort -rn
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Public API with third-party consumers | Internal single-consumer microservice | Contract testing (Pact) + additive evolution |
| Breaking response shape changes needed | Adding new optional fields to responses | Additive changes — no version needed |
| Multiple client generations must coexist | GraphQL API | Schema evolution with @deprecated directives |
| Regulatory or compliance requires stability | gRPC service-to-service | Protobuf package versioning |
| API monetization with SLA commitments | Prototype or MVP stage | Ship unversioned, add versioning before public launch |
| Long-lived webhooks with fixed payloads | Rapidly iterating internal tool | Feature flags or gradual rollout |
/v1 with Stripe-Version: 2025-01-15) requires sophisticated API gateway infrastructure to maintain many date-pinned behaviors simultaneouslyAccept: application/vnd.api.v1+json) is the most RESTful approach but has the worst developer experience — hard to test in browsers, difficult to document, breaks simple curl commands