Rolling out API changes without breaking existing clients is one of the most challenging aspects of backend development. Whether you're adding new features, deprecating old endpoints, or making breaking changes, proper versioning strategies ensure smooth deployments. In this guide, you'll learn how to implement API versioning and safely deploy breaking changes using DeployHQ.
Why API Versioning Matters
APIs are contracts with your consumers. Breaking changes can:
- Crash mobile apps that haven't updated
- Break integrations with third-party services
- Frustrate developers who depend on your API
- Damage trust with API consumers
flowchart TD
subgraph "Without Versioning"
A[API Change] --> B[All Clients Break]
B --> C[Angry Users]
end
subgraph "With Versioning"
D[API Change v2] --> E[v1 Still Works]
E --> F[Clients Migrate Gradually]
F --> G[Happy Users]
end
Versioning Strategies
1. URL Path Versioning
The most common and visible approach:
GET /api/v1/users
GET /api/v2/users
// Express.js implementation
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);
Pros: Clear, cache-friendly, easy to understand Cons: Duplicates route structure, longer URLs
2. Header Versioning
Version specified in request headers:
GET /api/users
Accept: application/vnd.myapi.v2+json
// Express.js middleware
function versionMiddleware(req, res, next) {
const acceptHeader = req.headers['accept'] || '';
const versionMatch = acceptHeader.match(/vnd\.myapi\.v(\d+)/);
req.apiVersion = versionMatch ? parseInt(versionMatch[1]) : 1;
next();
}
app.use(versionMiddleware);
app.get('/api/users', (req, res) => {
if (req.apiVersion >= 2) {
return getUsersV2(req, res);
}
return getUsersV1(req, res);
});
Pros: Clean URLs, follows HTTP standards Cons: Hidden versioning, harder to test
3. Query Parameter Versioning
Version in query string:
GET /api/users?version=2
Pros: Simple implementation Cons: Can be cached incorrectly, feels hacky
4. Custom Header Versioning
Most flexible approach:
GET /api/users
X-API-Version: 2
function versionMiddleware(req, res, next) {
req.apiVersion = parseInt(req.headers['x-api-version']) || 1;
next();
}
Implementing Backward Compatibility
Strategy 1: Additive Changes Only
Safe changes that never break clients:
// v1 response
{
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}
// v2 response (additive - safe)
{
"id": 123,
"name": "John Doe",
"email": "john@example.com",
"profile": { // New field
"avatar": "...",
"bio": "..."
}
}
Strategy 2: Response Transformation
Transform responses based on version:
// services/userTransformer.js
const transformers = {
v1: (user) => ({
id: user.id,
name: user.fullName,
email: user.email,
}),
v2: (user) => ({
id: user.id,
name: user.fullName,
email: user.email,
profile: {
avatar: user.avatarUrl,
bio: user.biography,
joinedAt: user.createdAt,
},
}),
};
function transformUser(user, version) {
const transformer = transformers[`v${version}`] || transformers.v1;
return transformer(user);
}
Strategy 3: Dual-Write Pattern
Support both old and new fields during migration:
// Accept both formats during transition
app.post('/api/users', (req, res) => {
const { name, fullName, firstName, lastName } = req.body;
// Support legacy 'name' field and new 'firstName/lastName'
const user = {
fullName: fullName || name || `${firstName} ${lastName}`,
firstName: firstName || name?.split(' ')[0],
lastName: lastName || name?.split(' ').slice(1).join(' '),
};
// Save user...
});
Deployment Workflow
sequenceDiagram
participant Dev as Development
participant Stage as Staging
participant Prod as Production
participant Clients as API Clients
Dev->>Stage: Deploy v2 alongside v1
Stage->>Stage: Test both versions
Stage->>Prod: Deploy v2 (v1 still active)
Note over Prod,Clients: Transition Period
Prod->>Clients: Announce v2 availability
Clients->>Prod: Migrate to v2
Prod->>Clients: Deprecation warning for v1
Note over Prod: After migration deadline
Prod->>Prod: Remove v1
DeployHQ Deployment Strategy
Side-by-Side Version Deployment
#!/bin/bash
# deploy.sh - Deploy new version alongside existing
VERSION=$1
DEPLOY_PATH="/var/www/api"
echo "Deploying API version $VERSION"
# Deploy new version code
rsync -avz --delete \
--exclude 'node_modules' \
--exclude '.env' \
./ "$DEPLOY_PATH/"
# Install dependencies
cd "$DEPLOY_PATH"
npm ci --production
# Run migrations (if needed)
npm run migrate
# Restart application
pm2 reload api
# Verify both versions work
echo "Testing v1..."
curl -sf http://localhost:3000/api/v1/health || exit 1
echo "Testing v2..."
curl -sf http://localhost:3000/api/v2/health || exit 1
echo "Deployment complete!"
Feature Flag for Gradual Rollout
// middleware/featureFlags.js
const flags = {
useNewUserEndpoint: {
enabled: process.env.NEW_USER_ENDPOINT === 'true',
percentage: parseInt(process.env.NEW_USER_ENDPOINT_PERCENTAGE) || 0,
},
};
function shouldUseFeature(flagName, userId) {
const flag = flags[flagName];
if (!flag || !flag.enabled) return false;
// Percentage rollout based on user ID
const hash = hashUserId(userId);
return hash % 100 < flag.percentage;
}
// Usage in route
app.get('/api/users/:id', (req, res) => {
if (shouldUseFeature('useNewUserEndpoint', req.params.id)) {
return newGetUser(req, res);
}
return legacyGetUser(req, res);
});
For more on feature flags, see what are feature flags.
Deprecation Workflow
Deprecation Headers
// middleware/deprecation.js
const deprecatedEndpoints = {
'GET /api/v1/users': {
deprecatedAt: '2024-01-01',
sunsetAt: '2024-06-01',
replacement: 'GET /api/v2/users',
},
};
function deprecationMiddleware(req, res, next) {
const key = `${req.method} ${req.path}`;
const deprecation = deprecatedEndpoints[key];
if (deprecation) {
res.set('Deprecation', `date="${deprecation.deprecatedAt}"`);
res.set('Sunset', deprecation.sunsetAt);
res.set('Link', `<${deprecation.replacement}>; rel="successor-version"`);
// Log deprecation usage for monitoring
console.warn(`Deprecated endpoint accessed: ${key}`, {
clientIp: req.ip,
userAgent: req.headers['user-agent'],
});
}
next();
}
Consumer Communication
// Track API version usage
const versionUsage = {};
app.use((req, res, next) => {
const version = req.apiVersion || 1;
const client = req.headers['x-client-id'] || 'unknown';
versionUsage[client] = versionUsage[client] || {};
versionUsage[client][version] = (versionUsage[client][version] || 0) + 1;
next();
});
// Endpoint to check usage
app.get('/internal/api-usage', (req, res) => {
res.json(versionUsage);
});
Contract Testing
Ensure backward compatibility with contract tests:
// tests/contracts/v1.test.js
const { expect } = require('chai');
const request = require('supertest');
const app = require('../../app');
describe('API v1 Contract', () => {
describe('GET /api/v1/users/:id', () => {
it('returns expected v1 schema', async () => {
const response = await request(app)
.get('/api/v1/users/1')
.expect(200);
// v1 contract: must have these fields
expect(response.body).to.have.property('id');
expect(response.body).to.have.property('name');
expect(response.body).to.have.property('email');
// v1 contract: must NOT have v2 fields
expect(response.body).to.not.have.property('profile');
});
});
});
describe('API v2 Contract', () => {
describe('GET /api/v2/users/:id', () => {
it('returns expected v2 schema', async () => {
const response = await request(app)
.get('/api/v2/users/1')
.expect(200);
// v2 contract: must have all fields
expect(response.body).to.have.property('id');
expect(response.body).to.have.property('name');
expect(response.body).to.have.property('email');
expect(response.body).to.have.property('profile');
expect(response.body.profile).to.have.property('avatar');
});
});
});
DeployHQ Build Commands with Contract Tests
# Install dependencies
npm ci
# Run unit tests
npm test
# Run contract tests (critical for versioning)
npm run test:contracts
# Build
npm run build
OpenAPI Documentation
Keep documentation in sync with versions:
# openapi/v1.yaml
openapi: 3.0.0
info:
title: My API
version: 1.0.0
paths:
/users/{id}:
get:
summary: Get user by ID (v1)
deprecated: true
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/UserV1'
# openapi/v2.yaml
openapi: 3.0.0
info:
title: My API
version: 2.0.0
paths:
/users/{id}:
get:
summary: Get user by ID (v2)
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/UserV2'
Version Sunset Checklist
flowchart TD
A[Announce Deprecation] --> B[Add Deprecation Headers]
B --> C[Monitor v1 Usage]
C --> D{Usage Below Threshold?}
D -->|No| E[Reach Out to Heavy Users]
E --> C
D -->|Yes| F[Send Final Warning]
F --> G[Remove v1]
G --> H[Update Documentation]
Automated Monitoring
// Monitor version usage and alert
async function checkVersionUsage() {
const stats = await getVersionStats();
// Alert if v1 usage is still high before sunset
if (stats.v1.percentage > 5 && isWithinSunsetWindow()) {
await sendAlert({
type: 'version-sunset-warning',
message: `v1 still has ${stats.v1.percentage}% traffic`,
topClients: stats.v1.topClients,
});
}
}
// Run daily
setInterval(checkVersionUsage, 24 * 60 * 60 * 1000);
Best Practices Summary
- Choose a versioning strategy and stick with it
- Make additive changes whenever possible
- Never remove fields without versioning
- Add deprecation headers before removing endpoints
- Run contract tests on every deployment
- Monitor version usage to plan migrations
- Communicate changes clearly to consumers
- Provide migration guides for breaking changes
- Set clear sunset dates and enforce them
- Keep documentation updated for all versions
Getting Started
Ready to implement API versioning? Here's your checklist:
- Choose your versioning strategy (URL path recommended)
- Set up version routing
- Implement response transformers
- Add contract tests to your pipeline
- Set up deprecation monitoring
For zero-downtime deployment strategies, see our guide on zero-downtime deployments.
Questions about API versioning? Contact support@deployhq.com or follow @deployhq.