- Published on
API Versioning Strategies — URL, Header, and How Netflix Does It
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Every API eventually changes. The question isn't whether to version, but how. Should /v1/ and /v2/ exist in the URL? Should clients specify version in headers? Should you avoid versioning entirely with graceful evolution? Each approach has tradeoffs. Netflix and Stripe prove that smart versioning is possible—and so is avoiding it through good design.
- URL Versioning (/v1/ /v2/): Pros and Cons
- Accept Header Versioning
- Custom Version Header
- Sunset and Deprecation Headers
- Maintaining Multiple Versions: Shared vs Forked Handlers
- API Changelog and Communication
- Semantic Versioning for APIs
- Feature Flags as Alternative to Versioning
- Automated Breaking Change Detection
- Versioning Complexity Matrix
- Checklist
- Conclusion
URL Versioning (/v1/ /v2/): Pros and Cons
URL versioning puts the version in the path. It's explicit, discoverable, and easy to deploy different implementations:
// URL versioning: version in path
// GET /v1/orders/123
// GET /v2/orders/123
app.get('/v1/orders/:id', async (req, res) => {
// Old endpoint behavior
const order = await ordersService.getOrder(req.params.id)
res.json({
id: order.id,
status: order.status,
total: order.total,
// v1 shape only
})
})
app.get('/v2/orders/:id', async (req, res) => {
// New endpoint behavior
const order = await ordersService.getOrder(req.params.id)
const shipping = await shippingService.getTracking(req.params.id)
res.json({
id: order.id,
status: order.status,
total: order.total,
// v2 adds new fields
currency: order.currency,
shippingInfo: shipping,
estimatedDelivery: shipping?.eta
})
})
// Pros:
// - Explicit versioning in URL
// - Each version can have completely different implementation
// - Clients see version clearly
// - Easy to deprecate (set sunset header)
// Cons:
// - URL proliferation (endpoint duplication)
// - Business logic duplication (DRY violation)
// - Clients must change URL to upgrade
// - Hard to coordinate: does v1 client need v2 fix?
// When to use:
// - Very large schema changes
// - Long-term support needed (v1 stays running for 5+ years)
// - Different teams managing different versions
Accept Header Versioning
Clients specify version in Accept or Accept-Version header. The API returns appropriate version without URL changes:
// Header versioning: Same URL, different responses based on header
// GET /orders/123 with Accept-Version: 1
// GET /orders/123 with Accept-Version: 2
app.get('/orders/:id', async (req, res) => {
const version = req.headers['accept-version'] || '1'
const order = await ordersService.getOrder(req.params.id)
if (version === '1') {
res.json({
id: order.id,
status: order.status,
total: order.total
})
} else if (version === '2') {
const shipping = await shippingService.getTracking(req.params.id)
res.json({
id: order.id,
status: order.status,
total: order.total,
currency: order.currency,
shippingInfo: shipping,
estimatedDelivery: shipping?.eta
})
} else {
res.status(400).json({ error: 'Unsupported version' })
}
})
// Alternative: Custom version header
app.get('/orders/:id', async (req, res) => {
const version = req.headers['x-api-version'] || '1'
// Same logic
})
// Pros:
// - Single URL to maintain
// - Business logic can be shared
// - Cleaner URL structure
// - Version in header feels RESTful
// Cons:
// - Hidden versioning (not obvious from URL)
// - Hard to test different versions (need header tools)
// - Browser can't access different versions without tools
// - Still requires version branching in code
Custom Version Header
A custom header like X-API-Version provides explicit control:
// src/middleware/version-selector.ts
export function selectApiVersion(req: Request, res: Response, next: NextFunction) {
const version = req.headers['x-api-version'] || req.headers['accept-version'] || '1.0.0'
// Parse semantic version
const [major, minor, patch] = version.split('.').map(Number)
req.apiVersion = { major, minor, patch, raw: version }
// Set in response so client sees what version was used
res.set('X-API-Version', version)
next()
}
app.use(selectApiVersion)
app.get('/orders/:id', async (req, res) => {
const order = await ordersService.getOrder(req.params.id)
// Respond based on version
const response: any = {
id: order.id,
status: order.status,
total: order.total
}
// v2.0+ adds currency
if (req.apiVersion.major >= 2) {
response.currency = order.currency
}
// v2.1+ adds shipping
if (req.apiVersion.major > 2 || (req.apiVersion.major === 2 && req.apiVersion.minor >= 1)) {
response.shippingInfo = await shippingService.getTracking(req.params.id)
}
res.json(response)
})
Sunset and Deprecation Headers
Communicate version lifecycle to clients via headers:
// src/middleware/deprecation-headers.ts
export function addDeprecationHeaders(req: Request, res: Response, next: NextFunction) {
const version = req.apiVersion.raw
// Indicate version is deprecated
if (version === '1.0.0') {
res.set('Deprecation', 'true')
res.set('Sunset', new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toUTCString())
// 90 days until shutdown
res.set('Link', '</orders?x-api-version=2.0.0; rel="successor-version">')
// Link to newer version
}
// Warning header with sunset date
if (version === '2.0.0') {
res.set('Warning', '299 - "This version will be sunset on 2026-06-15"')
}
next()
}
app.use(addDeprecationHeaders)
// Example response headers:
// Deprecation: true
// Sunset: Fri, 15 Jun 2026 00:00:00 GMT
// Link: </orders?x-api-version=2.0.0; rel="successor-version">
// Warning: 299 - "This version will be sunset on 2026-06-15"
Maintaining Multiple Versions: Shared vs Forked Handlers
Share logic between versions or fork completely. Choose based on changes:
// SHARED HANDLERS: For minor additions
// Most logic shared, small differences
const getOrderHandler = async (req: Request, res: Response) => {
const version = req.apiVersion
const order = await ordersService.getOrder(req.params.id)
// Shared response
const response: any = {
id: order.id,
status: order.status,
total: order.total
}
// Only add v2 fields if requested
if (version.major >= 2) {
const shipping = await shippingService.getTracking(req.params.id)
response.currency = order.currency
response.shippingInfo = shipping
}
if (version.major >= 3) {
// v3 includes v2 fields plus more
response.estimatedDelivery = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
response.trackingUrl = `https://track.example.com/${order.id}`
}
res.json(response)
}
// FORKED HANDLERS: For large differences
// Separate implementations for major versions
app.get('/v1/orders/:id', async (req, res) => {
const order = await ordersServiceV1.getOrder(req.params.id)
res.json({
orderId: order.id, // v1 field names
orderStatus: order.status,
orderTotal: order.total
})
})
app.get('/v2/orders/:id', async (req, res) => {
const order = await ordersServiceV2.getOrder(req.params.id)
res.json({
id: order.id, // v2 changed field names
status: order.status,
total: order.total
})
})
// Use forked handlers when:
// - Field names change (not just additions)
// - Behavior changes significantly
// - Schema incompatible
// Use shared handlers when:
// - Only new optional fields
// - Behavior compatible
// - Minor version bumps
API Changelog and Communication
Document changes clearly for your clients:
// docs/CHANGELOG.md
# API Changelog
## [2.1.0] - 2026-03-15
### Added
- `shippingInfo` object with tracking details
- `trackingUrl` field for direct shipment tracking
### Changed
- Improved error messages with actionable guidance
### Deprecated
- Nothing new
## [2.0.0] - 2026-01-01
### Added
- `currency` field to Order response
- Accept-Version and X-API-Version header support
### Changed
- Field `statusCode` renamed to `status`
### Deprecated
- v1.x endpoints (sunset date: 2026-06-15)
## [1.0.0] - 2025-01-01
### Added
- Initial API release
// docs/UPGRADE_GUIDE.md
# Upgrading from v1 to v2
## Breaking Changes
None - v2 is backwards compatible
## New Features
### Currency Support
v2 adds a `currency` field to all monetary amounts:
\`\`\`json
{
"id": "123",
"total": 99.99,
"currency": "USD"
}
\`\`\`
### Shipping Information
Orders now include shipping status:
\`\`\`json
{
"shippingInfo": {
"status": "shipped",
"trackingNumber": "1Z123ABC",
"estimatedDelivery": "2026-03-20T00:00:00Z"
}
}
\`\`\`
## Migration Path
1. Update your client to accept new fields (no required changes for read operations)
2. (Optional) Use new `currency` field for multi-currency support
3. (Optional) Use new `shippingInfo` for shipment tracking
Semantic Versioning for APIs
Use Semantic Versioning for API versions consistently:
// MAJOR.MINOR.PATCH semantic versioning
// MAJOR version bump: Breaking changes
// - Field removed
// - Type changed
// - Required parameter added
// - Required field in response removed
// Examples: 1.0.0 → 2.0.0
// MINOR version bump: Backwards-compatible changes
// - New optional field in response
// - New optional parameter
// - New endpoint
// - Field added to response
// Examples: 1.0.0 → 1.1.0
// PATCH version bump: Bug fixes, non-breaking improvements
// - Documentation clarification
// - Error message change
// - Performance improvement
// Examples: 1.0.0 → 1.0.1
export interface ApiVersion {
major: number
minor: number
patch: number
}
export function isBackwardsCompatible(
currentVersion: ApiVersion,
newVersion: ApiVersion
): boolean {
// Same major version = backwards compatible
return currentVersion.major === newVersion.major
}
// Versioning strategy
const v1_0_0 = { major: 1, minor: 0, patch: 0 } // Initial
const v1_1_0 = { major: 1, minor: 1, patch: 0 } // New field added
const v1_1_1 = { major: 1, minor: 1, patch: 1 } // Bug fix
const v2_0_0 = { major: 2, minor: 0, patch: 0 } // Breaking change
// Clients on v1.0 can use v1.1 (backwards compatible)
// Clients on v1.0 cannot use v2.0 (breaking change)
Feature Flags as Alternative to Versioning
Instead of versioning, use feature flags to control behavior:
// src/middleware/feature-flags.ts
export async function getFeatures(userId: string): Promise<Record<string, boolean>> {
return {
'shipping_info': await flagService.isEnabled('shipping_info', userId),
'currency_field': await flagService.isEnabled('currency_field', userId),
'new_tracking_api': await flagService.isEnabled('new_tracking_api', userId)
}
}
app.get('/orders/:id', async (req, res) => {
const features = await getFeatures(req.user.id)
const order = await ordersService.getOrder(req.params.id)
const response: any = {
id: order.id,
status: order.status,
total: order.total
}
// Feature flags control response shape
if (features.currency_field) {
response.currency = order.currency
}
if (features.shipping_info) {
response.shippingInfo = await shippingService.getTracking(req.params.id)
}
res.json(response)
})
// Pros:
// - No versioning URLs or headers
// - Gradual rollout per user/customer
// - Easy A/B testing
// - Single codebase
// Cons:
// - Complex feature flag management
// - Code cruft from old flags (cleanup required)
// - Harder to understand flow (many branches)
// Netflix approach: Heavy feature flag usage
// - Versions exist only internally
// - Clients always use /api/orders (no v1, v2 in URL)
// - Server decides what features each client gets
// - Seamless upgrades (clients don't know version)
Automated Breaking Change Detection
In CI, detect when your changes break backwards compatibility:
# .github/workflows/api-breaking-changes.yml
name: Check for Breaking Changes
on:
pull_request:
paths:
- 'src/api/**'
jobs:
check-breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Compare API specs
run: |
# Get base spec from main
git show origin/main:openapi.yaml > openapi.base.yaml
# Compare with PR spec
npx openapi-diff \
openapi.base.yaml \
openapi.yaml \
--fail-on-breaking-changes
- name: List safe changes
run: |
npx openapi-diff \
openapi.base.yaml \
openapi.yaml \
--list-safe-changes
- name: Comment on PR
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ Breaking API changes detected. Review and update version.'
})
Versioning Complexity Matrix
| Approach | URL Duplication | Code Duplication | Client Complexity | Deprecation Difficulty |
|---|---|---|---|---|
| URL versioning | High | Medium | Low | Medium |
| Header versioning | None | Medium | Medium | Hard |
| Feature flags | None | Low | None | Easy |
| Proper design | None | None | None | Minimal |
Checklist
- API versioning strategy documented clearly
- Semantic versioning used consistently
- Deprecation timeline communicated via headers
- Changelog maintained and published
- Upgrade guide provided for major versions
- Backwards compatibility tested in CI
- Feature flags used for gradual rollout when appropriate
- Breaking changes detected in CI pipeline
- Old versions have documented sunset date
- Sunset date communicated 6+ months in advance
- Clients able to specify version preference
- Monitoring tracks version usage per client
Conclusion
Versioning isn't about creating v1, v2, v3 forever. It's about managing change gracefully. The best APIs avoid breaking changes through thoughtful evolution: new optional fields, new endpoints, careful schema design. When breaking changes are unavoidable, communicate clearly and provide a migration path. Netflix proves you don't need URL versions—just good feature flags and design. Stripe shows URL versions work when supported seriously. The worst is half-hearted versioning: unclear semantics, poor communication, no sunset dates.