API versioning allows you to evolve your API while maintaining backwards compatibility. This guide covers versioning strategies, implementation patterns, and deprecation best practices.
Versioning Strategy Comparison
┌─────────────────────────────────────────────────────────────────────────────┐
│ API VERSIONING STRATEGIES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ URL PATH VERSIONING │
│ ──────────────────── │
│ GET /api/v1/users │
│ GET /api/v2/users │
│ │
│ ✓ Easy to implement and understand │
│ ✓ Visible in logs, easy to debug │
│ ✓ Browser-testable, cacheable │
│ ✗ "Impure" REST (version isn't a resource) │
│ ✗ URL changes when version changes │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ HEADER VERSIONING │
│ ───────────────── │
│ GET /api/users │
│ Accept: application/vnd.myapi.v2+json │
│ - OR - │
│ Api-Version: 2 │
│ │
│ ✓ Clean URLs │
│ ✓ "Pure" REST (content negotiation) │
│ ✗ Harder to test (need headers) │
│ ✗ Caching requires Vary header │
│ ✗ Less visible in logs │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ QUERY PARAMETER VERSIONING │
│ ─────────────────────────── │
│ GET /api/users?version=2 │
│ │
│ ✓ Simple to implement │
│ ✓ Easy to test │
│ ✗ Can conflict with other params │
│ ✗ Often ignored in caching │
│ ✗ Looks like optional parameter │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Breaking vs Non-Breaking Changes
| Change Type | Breaking? | Action |
|---|---|---|
| Remove field from response | ✅ Yes | New version |
| Remove endpoint | ✅ Yes | New version |
| Rename field | ✅ Yes | New version |
| Change field type (string→number) | ✅ Yes | New version |
| Change authentication method | ✅ Yes | New version |
| Change error format | ✅ Yes | New version |
| Add new optional field | ❌ No | Current version |
| Add new endpoint | ❌ No | Current version |
| Add new optional parameter | ❌ No | Current version |
| Bug fix (same behavior) | ❌ No | Current version |
| Performance improvement | ❌ No | Current version |
URL Path Versioning
Express.js Implementation
import express from 'express';
const app = express();
// Version 1 routes
const v1Router = express.Router();
v1Router.get('/users', (req, res) => {
res.json({
users: [
{ id: 1, name: 'John', email: '[email protected]' }
]
});
});
// Version 2 routes (different response structure)
const v2Router = express.Router();
v2Router.get('/users', (req, res) => {
res.json({
data: [
{
id: 1,
attributes: {
name: 'John',
email: '[email protected]'
}
}
],
meta: { total: 1 }
});
});
// Mount versioned routers
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Redirect unversioned to latest stable
app.use('/api/users', (req, res) => {
res.redirect(308, `/api/v2${req.path}`);
});
Shared Code Between Versions
// services/users.service.ts - Shared business logic
export class UsersService {
async getUsers(filters: UserFilters): Promise<User[]> {
return await prisma.user.findMany({ where: filters });
}
}
// v1/users.controller.ts
import { UsersService } from '../services/users.service';
const usersService = new UsersService();
export async function getUsers(req: Request, res: Response) {
const users = await usersService.getUsers(req.query);
// V1 response format
res.json({ users });
}
// v2/users.controller.ts
import { UsersService } from '../services/users.service';
const usersService = new UsersService();
export async function getUsers(req: Request, res: Response) {
const users = await usersService.getUsers(req.query);
// V2 response format (JSON:API style)
res.json({
data: users.map(u => ({
id: u.id,
type: 'user',
attributes: { name: u.name, email: u.email }
})),
meta: { total: users.length }
});
}
Header Versioning
Custom Header Implementation
// Middleware to extract version from header
function versionMiddleware(req: Request, res: Response, next: NextFunction) {
const version = req.headers['api-version'] || req.headers['x-api-version'];
if (!version) {
// Default to latest stable
req.apiVersion = 2;
} else {
const parsed = parseInt(version as string, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 2) {
return res.status(400).json({
error: 'Invalid API version',
supported: [1, 2],
});
}
req.apiVersion = parsed;
}
// Add version to response
res.setHeader('Api-Version', req.apiVersion);
next();
}
// Route handler using version
app.get('/api/users', versionMiddleware, async (req, res) => {
const users = await usersService.getUsers(req.query);
if (req.apiVersion === 1) {
return res.json({ users });
}
// Version 2
return res.json({
data: users.map(formatUserV2),
meta: { total: users.length }
});
});
Accept Header (Content Negotiation)
// Using media type versioning
// Accept: application/vnd.myapi.v2+json
function parseAcceptVersion(accept: string | undefined): number {
if (!accept) return 2; // Default
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
return match ? parseInt(match[1], 10) : 2;
}
app.get('/api/users', (req, res) => {
const version = parseAcceptVersion(req.headers.accept);
// Set appropriate content type
res.setHeader(
'Content-Type',
`application/vnd.myapi.v${version}+json`
);
// Version-specific response
// ...
});
API Gateway Routing
Kong Configuration
# kong.yml
services:
- name: api-v1
url: http://api-v1-service:8080
routes:
- name: api-v1-route
paths:
- /api/v1
- name: api-v2
url: http://api-v2-service:8080
routes:
- name: api-v2-route
paths:
- /api/v2
# Header-based routing
- name: api-v1-header
url: http://api-v1-service:8080
routes:
- name: api-v1-header-route
paths:
- /api
headers:
api-version:
- "1"
- name: api-v2-header
url: http://api-v2-service:8080
routes:
- name: api-v2-header-route
paths:
- /api
headers:
api-version:
- "2"
AWS API Gateway
# SAM template
Resources:
ApiV1:
Type: AWS::Serverless::Api
Properties:
StageName: v1
DefinitionUri: ./openapi-v1.yaml
ApiV2:
Type: AWS::Serverless::Api
Properties:
StageName: v2
DefinitionUri: ./openapi-v2.yaml
# Custom domain with base path mappings
ApiDomainMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Properties:
DomainName: api.example.com
ApiId: !Ref ApiV2
Stage: v2
ApiMappingKey: v2
ApiDomainMappingV1:
Type: AWS::ApiGatewayV2::ApiMapping
Properties:
DomainName: api.example.com
ApiId: !Ref ApiV1
Stage: v1
ApiMappingKey: v1
Deprecation Strategy
Deprecation Headers
// Middleware to add deprecation headers
function deprecationMiddleware(
deprecationDate: string,
sunsetDate: string,
link: string
) {
return (req: Request, res: Response, next: NextFunction) => {
// RFC 8594 Deprecation header
res.setHeader('Deprecation', deprecationDate);
// RFC 8594 Sunset header
res.setHeader('Sunset', sunsetDate);
// Link to migration guide
res.setHeader('Link', `<${link}>; rel="deprecation"`);
// Optional warning header
res.setHeader(
'Warning',
'299 - "This API version is deprecated. Please migrate to v2."'
);
next();
};
}
// Apply to v1 routes
app.use(
'/api/v1',
deprecationMiddleware(
'Sun, 01 Jan 2025 00:00:00 GMT', // Deprecated since
'Mon, 01 Jul 2025 00:00:00 GMT', // Will be removed
'https://docs.example.com/migration-guide'
),
v1Router
);
Version Lifecycle
┌─────────────────────────────────────────────────────────────────────────────┐
│ API VERSION LIFECYCLE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Timeline Status Actions │
│ ────────────────────────────────────────────────────────────────────────── │
│ │
│ T+0 CURRENT - Active development │
│ (v2) - All new features added here │
│ - Full support │
│ │
│ T+0 DEPRECATED - Security fixes only │
│ (v1) - Deprecation headers added │
│ - Migration guide published │
│ - Email notification sent │
│ │
│ T+6mo DEPRECATED - Monitor usage metrics │
│ (v1) - Contact high-volume users │
│ - Reminder emails │
│ │
│ T+12mo SUNSET WARNING - Final notice emails │
│ (v1) - Dashboard warnings │
│ - Rate limits may be reduced │
│ │
│ T+18mo SUNSET - Version removed │
│ (v1) - Returns 410 Gone │
│ - Redirect to migration docs │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Sunset Response
// After sunset date, return 410 Gone
app.use('/api/v1', (req, res) => {
const sunsetDate = new Date('2025-07-01');
if (new Date() > sunsetDate) {
return res.status(410).json({
error: 'VERSION_SUNSET',
message: 'API v1 has been sunset. Please migrate to v2.',
migrationGuide: 'https://docs.example.com/migration-v1-v2',
currentVersion: 'https://api.example.com/v2',
});
}
// Still active, continue with deprecation warnings
next();
});
Version Migration Guide Template
# Migrating from API v1 to v2
## Overview
API v2 introduces a new response format following JSON:API specification,
improved error handling, and new endpoints for batch operations.
## Timeline
- **Deprecation Date**: January 1, 2025
- **Sunset Date**: July 1, 2025
- **Support Email**: [email protected]
## Breaking Changes
### Response Format
**v1:**
```json
{
"users": [{"id": 1, "name": "John"}]
}
v2:
{
"data": [{"id": 1, "type": "user", "attributes": {"name": "John"}}],
"meta": {"total": 1}
}
Authentication
- v1: API key in query parameter (
?api_key=xxx) - v2: API key in header (
Authorization: Bearer xxx)
Removed Endpoints
GET /users/search→ UseGET /users?filter[name]=xxxPOST /users/bulk→ UsePOST /userswith array body
Migration Steps
- Update authentication to use header
- Update response parsing for new format
- Update any deprecated endpoint calls
- Test thoroughly in staging
- Deploy and monitor errors
Code Examples
[SDK examples for major languages]
Support
Contact [email protected] for migration assistance.
## Security Considerations
```typescript
// Ensure auth/authz applies to ALL versions
function authMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Validate token (same logic for all versions)
try {
req.user = verifyToken(token);
next();
} catch (e) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Apply BEFORE version routing
app.use('/api', authMiddleware);
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Rate limiting across versions
const rateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
keyGenerator: (req) => {
// Same key regardless of version
return req.user?.id || req.ip;
},
});
app.use('/api', rateLimiter);
Monitoring Versions
// Track version usage for deprecation decisions
app.use('/api/:version', (req, res, next) => {
const version = req.params.version;
const clientId = req.user?.id || 'anonymous';
// Log to analytics
analytics.track({
event: 'api_request',
properties: {
version,
clientId,
endpoint: req.path,
method: req.method,
},
});
// Prometheus metric
apiRequestsTotal.inc({
version,
endpoint: req.path,
method: req.method,
});
next();
});
Best Practices
- Choose one strategy - Consistency across your API
- Version early - Start with /v1 even if no v2 planned
- Global versioning - All endpoints at same version
- Semantic meaning - Major version for breaking changes
- Long deprecation periods - 12-24 months minimum
- Clear documentation - Migration guides for each version
- Monitor usage - Data-driven sunset decisions
- Security parity - Same security controls across versions
- Deprecation headers - Machine-readable warnings
- No silent changes - Even non-breaking changes need docs
Next Steps
- API Security Complete Guide - Comprehensive security overview
- API Documentation Security - Secure your API docs
- REST Status Codes - Proper response codes
- API Gateway Security - Gateway-level routing