API Versioning: Complete Implementation Guide for Production APIs
TL;DR
API versioning prevents breaking existing clients when you ship changes. This guide covers 4 production-ready strategies (URI path, query parameter, header, content negotiation), semantic versioning, real-world examples from Stripe/GitHub/AWS, breaking vs non-breaking change classification, deprecation timelines, migration patterns, and comprehensive testing approaches.
API Versioning: Complete Implementation Guide
API versioning is how you evolve your API without breaking existing integrations. Get it wrong, and you'll wake up to angry customers and broken applications. Get it right, and you can ship improvements confidently while maintaining backwards compatibility.
This guide covers production-ready versioning strategies, real-world patterns from Stripe, GitHub, and AWS, and comprehensive implementation patterns with TypeScript/Node.js.
Why API Versioning Matters
The cost of breaking changes:
- Stripe processes $1 trillion annually — one breaking change could cost millions
- GitHub serves 100M+ developers — breaking their CI/CD pipelines creates chaos
- Your API might power critical business operations for customers
Without versioning:
- Every change risks breaking existing clients
- You're locked into bad design decisions forever
- Innovation becomes impossible
- Customer trust erodes with each unexpected break
With versioning:
- Ship improvements confidently
- Maintain backwards compatibility
- Communicate changes clearly
- Give clients time to migrate
- Preserve customer relationships
Versioning Strategies Comparison
1. URI Path Versioning (Most Common)
Pattern: /v1/users, /v2/users
Pros:
- Extremely visible and explicit
- Easy to route in API gateways
- Simple for clients to understand
- Cache-friendly (different URLs)
- Easy to test multiple versions side-by-side
Cons:
- Creates URL pollution
- Violates REST principles (resource identity changes)
- Can't version individual fields
Best for: Public APIs, major version increments
Used by: Stripe, Twilio, Twitter API
// Express routing for URI versioning
import express from 'express';
const app = express();
// Version 1 routes
app.get('/v1/users/:id', async (req, res) => {
const user = await getUserV1(req.params.id);
res.json({
id: user.id,
name: user.full_name, // V1 used "full_name"
created: user.created_at
});
});
// Version 2 routes (breaking changes)
app.get('/v2/users/:id', async (req, res) => {
const user = await getUserV2(req.params.id);
res.json({
id: user.id,
firstName: user.first_name, // V2 split name
lastName: user.last_name,
email: user.email, // V2 added email
createdAt: user.created_at, // V2 uses camelCase
profile: user.profile // V2 added nested object
});
});
2. Query Parameter Versioning
Pattern: /users?version=2 or /users?api-version=2026-03-11
Pros:
- Keeps resource URLs clean
- Easy to implement
- Optional (can default to latest)
- Works with existing routing
Cons:
- Less visible than URI versioning
- Can break caching (query params ignored by some caches)
- Easy to forget in requests
- Harder to document
Best for: Internal APIs, gradual migrations
Used by: Azure, some Microsoft Graph endpoints
// Query parameter versioning middleware
app.get('/users/:id', async (req, res) => {
const version = req.query.version || req.query['api-version'] || '1';
if (version === '2') {
return handleUserV2(req, res);
}
// Default to V1
return handleUserV1(req, res);
});
3. Header-Based Versioning (GitHub's Approach)
Pattern: Accept: application/vnd.myapi.v2+json
Pros:
- URLs stay clean and RESTful
- Resource identity doesn't change
- Follows HTTP standards (content negotiation)
- Can version individual representations
- Professional and standards-compliant
Cons:
- Less visible to developers
- Harder to test in browsers
- More complex client configuration
- Not cache-friendly by default
Best for: RESTful purists, APIs with multiple response formats
Used by: GitHub API, Salesforce
// Header-based versioning middleware
function versionMiddleware(req, res, next) {
const acceptHeader = req.headers.accept || '';
// Parse version from Accept header
// Accept: application/vnd.myapi.v2+json
const versionMatch = acceptHeader.match(/vnd\.myapi\.v(\d+)/);
req.apiVersion = versionMatch ? versionMatch[1] : '1';
next();
}
app.use(versionMiddleware);
app.get('/users/:id', async (req, res) => {
if (req.apiVersion === '2') {
return handleUserV2(req, res);
}
return handleUserV1(req, res);
});
GitHub's actual pattern:
# GitHub uses custom media types
curl -H "Accept: application/vnd.github.v3+json" \
https://api.github.com/users/octocat
4. Content Negotiation (Stripe's Date-Based Versioning)
Pattern: Stripe-Version: 2026-03-11
Pros:
- URLs stay clean
- Granular version control (date-based)
- Can specify version per-request
- Works well for gradual deprecation
- Clients control upgrade timing
Cons:
- Requires custom header support
- More complex to implement
- Date versioning can be confusing
- Requires good documentation
Best for: SaaS platforms with many versions in production simultaneously
Used by: Stripe
// Stripe-style date versioning
const API_VERSIONS = {
'2026-03-11': handleV20260311,
'2025-12-01': handleV20251201,
'2025-01-15': handleV20250115,
// Default to oldest for safety
'default': handleV20250115
};
app.use((req, res, next) => {
const requestedVersion = req.headers['stripe-version'] || 'default';
req.apiVersion = API_VERSIONS[requestedVersion] || API_VERSIONS.default;
next();
});
app.post('/charges', async (req, res) => {
// Version-specific handler
return req.apiVersion(req, res);
});
Stripe's actual versioning:
curl https://api.stripe.com/v1/charges \
-H "Authorization: Bearer sk_test_..." \
-H "Stripe-Version: 2026-03-11"
Semantic Versioning for APIs
Use semantic versioning (MAJOR.MINOR.PATCH) to communicate the impact of changes.
Format: v{MAJOR}.{MINOR}.{PATCH}
Examples:
v1.0.0— Initial releasev1.1.0— Added optional fields (backwards compatible)v1.1.1— Bug fix (no API changes)v2.0.0— Breaking changes (removed/renamed fields)
When to increment:
| Version | Change Type | Examples |
|---|---|---|
| MAJOR | Breaking changes | Remove field, rename field, change type, change validation |
| MINOR | Backwards-compatible additions | Add optional field, new endpoint, new enum value |
| PATCH | Bug fixes, no API change | Fix typo in response, improve performance, security patch |
Important: Only increment MAJOR version in production URLs (/v1/, /v2/). Use MINOR/PATCH for internal tracking and changelog.
Breaking vs Non-Breaking Changes
✅ Non-Breaking Changes (Safe)
Can ship without version increment:
- Adding new optional fields to request
- Adding new fields to response
- Adding new endpoints
- Adding new query parameters (optional)
- Adding new enum values (if client handles unknowns)
- Making required fields optional
- Relaxing validation rules
- Bug fixes that don't change behavior
Example (safe):
// V1 response
{
"id": "user_123",
"name": "John Doe"
}
// V1.1 response (backwards compatible)
{
"id": "user_123",
"name": "John Doe",
"email": "john@example.com", // NEW optional field (safe!)
"created_at": "2026-03-11T10:00:00Z" // NEW field (safe!)
}
❌ Breaking Changes (Require Major Version)
Require new version (e.g., v1 → v2):
- Removing fields from response
- Renaming fields
- Changing field types (
string→number) - Changing response structure (flat → nested)
- Removing endpoints
- Making optional fields required
- Changing authentication method
- Stricter validation rules
- Changing error response format
- Changing URL structure
Example (breaking):
// V1 response
{
"id": "user_123",
"name": "John Doe", // Single field
"created": "2026-03-11" // Different format
}
// V2 response (BREAKING!)
{
"id": "user_123",
"first_name": "John", // BREAKING: name split
"last_name": "Doe",
"created_at": "2026-03-11T10:00:00Z", // BREAKING: field renamed
"profile": { // BREAKING: new structure
"bio": "Developer"
}
}
Real-World Examples
Stripe's Versioning Strategy
Stripe uses date-based versioning with a custom Stripe-Version header.
Why it works:
- 100+ versions in production simultaneously
- Clients upgrade on their timeline
- No forced migrations
- Account-level version pinning
- Gradual deprecation (12+ month notice)
Backwards compatibility guarantee:
- Old versions supported indefinitely for existing integrations
- Breaking changes only in new versions
- Changelog published for every version
Migration example:
// Old version (2025-01-15)
const stripe = require('stripe')('sk_test_...', {
apiVersion: '2025-01-15' // Pin to specific version
});
// Upgrade to new version (2026-03-11)
const stripe = require('stripe')('sk_test_...', {
apiVersion: '2026-03-11' // Test new version in dev
});
GitHub's API Versioning
GitHub uses header-based versioning with custom media types.
Current approach (v3 REST API):
# Specify version in Accept header
curl -H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/octocat/hello-world
Why it works:
- RESTful URLs stay clean
- Version embedded in content type
- Easy to version individual endpoints
- Default version if header omitted
Breaking change example:
- GitHub API v3 deprecated username/password auth (breaking)
- Required migration to personal access tokens
- 12-month deprecation notice
- Migration guide + timeline published
- Old auth method returned 410 Gone after sunset
AWS API Versioning
AWS uses service-specific versioning with date-based identifiers.
Pattern: Action-based with Version parameter
# AWS S3 API call with version
aws s3api list-buckets --version 2006-03-01
Why it works:
- Service-level versioning (S3, EC2, Lambda separate)
- Date-based versions (e.g.,
2006-03-01) - API actions can evolve independently
- SDKs abstract version complexity
Implementation: Complete Versioning System
Directory Structure
src/
api/
v1/
routes/
users.ts
posts.ts
controllers/
userController.ts
v2/
routes/
users.ts
posts.ts
controllers/
userController.ts
middleware/
versioning.ts
app.ts
Versioning Middleware
// src/api/middleware/versioning.ts
import { Request, Response, NextFunction } from 'express';
export type ApiVersion = '1' | '2';
declare global {
namespace Express {
interface Request {
apiVersion: ApiVersion;
}
}
}
export function versioningMiddleware(req: Request, res: Response, next: NextFunction) {
// Strategy 1: URI path (primary)
const pathMatch = req.path.match(/^\/v(\d+)\//);
if (pathMatch) {
req.apiVersion = pathMatch[1] as ApiVersion;
return next();
}
// Strategy 2: Query parameter (fallback)
if (req.query.version) {
req.apiVersion = req.query.version as ApiVersion;
return next();
}
// Strategy 3: Custom header (fallback)
const headerVersion = req.headers['api-version'];
if (headerVersion) {
req.apiVersion = headerVersion as ApiVersion;
return next();
}
// Strategy 4: Accept header (content negotiation)
const acceptHeader = req.headers.accept || '';
const acceptMatch = acceptHeader.match(/vnd\.myapi\.v(\d+)/);
if (acceptMatch) {
req.apiVersion = acceptMatch[1] as ApiVersion;
return next();
}
// Default to v1
req.apiVersion = '1';
next();
}
// Version validation middleware
export function requireVersion(...versions: ApiVersion[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!versions.includes(req.apiVersion)) {
return res.status(400).json({
error: 'invalid_api_version',
message: `API version ${req.apiVersion} not supported for this endpoint`,
supportedVersions: versions
});
}
next();
};
}
Version-Specific Routes
// src/api/app.ts
import express from 'express';
import { versioningMiddleware } from './middleware/versioning';
import v1Routes from './v1/routes';
import v2Routes from './v2/routes';
const app = express();
app.use(express.json());
app.use(versioningMiddleware);
// Mount versioned routes
app.use('/v1', v1Routes);
app.use('/v2', v2Routes);
// Fallback: route based on detected version
app.use((req, res, next) => {
if (req.apiVersion === '2') {
return v2Routes(req, res, next);
}
return v1Routes(req, res, next);
});
export default app;
Version-Specific Controllers
// src/api/v1/controllers/userController.ts
export async function getUser(req: Request, res: Response) {
const user = await prisma.user.findUnique({
where: { id: req.params.id }
});
if (!user) {
return res.status(404).json({ error: 'user_not_found' });
}
// V1 response format
res.json({
id: user.id,
name: user.full_name, // V1 uses full_name
created: user.created_at
});
}
// src/api/v2/controllers/userController.ts
export async function getUser(req: Request, res: Response) {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
include: { profile: true } // V2 includes profile
});
if (!user) {
return res.status(404).json({
error: {
code: 'user_not_found',
message: 'No user found with this ID'
}
});
}
// V2 response format (breaking changes)
res.json({
id: user.id,
firstName: user.first_name, // BREAKING: split name
lastName: user.last_name,
email: user.email, // NEW field
createdAt: user.created_at, // BREAKING: camelCase
profile: user.profile ? { // NEW nested object
bio: user.profile.bio,
avatar: user.profile.avatar_url
} : null
});
}
Deprecation Strategy
Timeline Template
Stripe's deprecation timeline (recommended):
T+0 (Today): Announce deprecation
- Publish changelog entry
- Email affected customers
- Add deprecation warning to docs
- Return deprecation headers on API responses
T+6 months: Sunset warning
- Increase email frequency
- Add warning banners to dashboard
- Log deprecation warnings in customer accounts
T+12 months: Sunset date
- Old version returns 410 Gone
- Migration guide prominently featured
- Support team prepared for questions
Deprecation Headers
// Add deprecation warnings to responses
app.use((req, res, next) => {
if (req.apiVersion === '1') {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 11 Sep 2027 23:59:59 GMT');
res.set('Link', '</docs/migration-v2>; rel="deprecation"');
}
next();
});
Sunset Response
// After sunset date, return 410 Gone
app.use((req, res, next) => {
const sunsetDate = new Date('2027-09-11');
if (req.apiVersion === '1' && new Date() > sunsetDate) {
return res.status(410).json({
error: 'api_version_sunset',
message: 'API v1 was sunset on September 11, 2027',
migration_guide: 'https://docs.example.com/migration-v2',
current_version: 'v2'
});
}
next();
});
Migration Guide Template
What to include in migration docs:
1. Summary of Changes
# Migrating from v1 to v2
## Breaking Changes
- **User name field split:** `name` → `firstName` + `lastName`
- **Date format:** `created` → `createdAt` (ISO 8601)
- **New required field:** `email` (all users must have email)
- **Profile nested:** User profile moved to `profile` object
- **Error format:** Errors now return structured object
## New Features
- Email field now included
- Profile data available in user response
- Better error messages with error codes
2. Code Examples
## Before (v1)
\`\`\`javascript
const response = await fetch('/v1/users/123');
const user = await response.json();
console.log(user.name); // "John Doe"
console.log(user.created); // "2026-03-11"
\`\`\`
## After (v2)
\`\`\`javascript
const response = await fetch('/v2/users/123');
const user = await response.json();
console.log(\`$\{user.firstName} $\{user.lastName}\`); // "John Doe"
console.log(user.createdAt); // "2026-03-11T10:00:00Z"
console.log(user.email); // "john@example.com"
\`\`\`
3. Migration Checklist
## Migration Checklist
- [ ] Update API base URL from `/v1` to `/v2`
- [ ] Update name parsing (split `name` → `firstName`/`lastName`)
- [ ] Update date parsing (handle ISO 8601 format)
- [ ] Add email field handling
- [ ] Update error handling (new error format)
- [ ] Test in development environment
- [ ] Update production gradually (percentage rollout)
Testing Versioned APIs
Test Multiple Versions
// tests/users.test.ts
import request from 'supertest';
import app from '../src/api/app';
describe('User API Versioning', () => {
// Test V1
describe('GET /v1/users/:id', () => {
it('returns V1 format', async () => {
const res = await request(app)
.get('/v1/users/123')
.expect(200);
expect(res.body).toMatchObject({
id: expect.any(String),
name: expect.any(String), // V1 uses single name field
created: expect.any(String)
});
expect(res.body.firstName).toBeUndefined(); // V1 doesn't have firstName
});
});
// Test V2
describe('GET /v2/users/:id', () => {
it('returns V2 format with breaking changes', async () => {
const res = await request(app)
.get('/v2/users/123')
.expect(200);
expect(res.body).toMatchObject({
id: expect.any(String),
firstName: expect.any(String), // V2 split name
lastName: expect.any(String),
email: expect.any(String), // V2 added email
createdAt: expect.any(String), // V2 renamed field
profile: expect.any(Object) // V2 added profile
});
expect(res.body.name).toBeUndefined(); // V2 doesn't have name field
});
});
// Test header-based versioning
describe('Header-based versioning', () => {
it('accepts version via header', async () => {
const res = await request(app)
.get('/users/123')
.set('API-Version', '2')
.expect(200);
expect(res.body.firstName).toBeDefined(); // Should use V2
});
});
// Test deprecation warnings
describe('Deprecation headers', () => {
it('returns deprecation warning for V1', async () => {
const res = await request(app)
.get('/v1/users/123')
.expect(200);
expect(res.headers.deprecation).toBe('true');
expect(res.headers.sunset).toBeDefined();
});
});
});
Contract Testing
// Ensure V1 and V2 can coexist
describe('Version contract tests', () => {
it('V1 and V2 serve same data with different formats', async () => {
const v1Res = await request(app).get('/v1/users/123');
const v2Res = await request(app).get('/v2/users/123');
// Same user ID
expect(v1Res.body.id).toBe(v2Res.body.id);
// V1 name = V2 firstName + lastName
const v2FullName = `${v2Res.body.firstName} ${v2Res.body.lastName}`;
expect(v1Res.body.name).toBe(v2FullName);
});
});
Common Mistakes
❌ Mistake 1: Not Planning for Versioning from Day One
Problem: Retrofit versioning into production API is painful
Solution: Start with /v1/ even for initial release
// Bad: No versioning
app.get('/users', handler); // Can't version later without breaking clients
// Good: Versioned from start
app.get('/v1/users', handler); // Can add v2 later
❌ Mistake 2: Using Versioning as a Bandaid for Bad Design
Problem: Shipping v2, v3, v4 every 6 months due to poor initial design
Solution: Design API thoughtfully upfront
- Accept optional fields from day one
- Use extensible formats (don't hardcode field order)
- Plan for growth (nested objects better than flat)
- Get feedback before locking design
❌ Mistake 3: Breaking Backwards Compatibility Without Major Version
Problem: Shipping breaking changes in minor versions (v1.1, v1.2)
Solution: Use semantic versioning strictly
- Removing field = major version (v2.0)
- Renaming field = major version (v2.0)
- Adding optional field = minor version (v1.1)
- Bug fix = patch version (v1.0.1)
❌ Mistake 4: Not Communicating Deprecation Timelines
Problem: Customers surprised when API stops working
Solution: Clear, aggressive communication
- Changelog entries
- Email campaigns
- Dashboard banners
- Deprecation headers in API responses
- 12+ month notice for major versions
❌ Mistake 5: Maintaining Too Many Versions
Problem: Supporting v1, v2, v3, v4 simultaneously creates technical debt
Solution: Aggressive deprecation schedule
- Maintain at most 2 major versions simultaneously
- 12-month sunset timeline
- Force migration with 410 Gone after sunset
❌ Mistake 6: Versioning Individual Fields
Problem: /users?fields=v2.name,v1.email creates complexity
Solution: Version entire response format, not individual fields
- If you need field-level control, use GraphQL instead
- Keep versioning granularity at endpoint or API level
❌ Mistake 7: No Testing Across Versions
Problem: V1 breaks when you add V2
Solution: Test all active versions in CI
- Contract tests ensuring V1/V2 compatibility
- Integration tests for each version
- Monitoring alerts per version
Production Checklist
Before shipping a new API version:
- Changelog published with breaking changes highlighted
- Migration guide with code examples created
- Deprecation timeline announced (12+ months)
- Tests cover all versions (V1 and V2)
- Monitoring separated by version (track V1/V2 usage)
- Documentation updated (V1 marked deprecated, V2 featured)
- SDK libraries updated (if applicable)
- Customer emails sent to affected users
- Deprecation headers added to old version responses
- Support team trained on migration questions
- Rollback plan ready if V2 has issues
- Sunset date set in calendar (12 months out)
Monitoring metrics:
- Track requests per version (
v1_requests,v2_requests) - Alert on V1 usage spike (customers not migrating)
- Track 410 Gone responses (customers hitting sunset API)
- Monitor error rates per version separately
Tools and Libraries
Versioning Libraries
Node.js/Express:
express-api-versioning— URI, header, query param supportrestify-versioning— Semantic versioning for Restify
Validation:
joi— Schema validation per versionzod— TypeScript-first validation
API Gateways:
- AWS API Gateway — Built-in versioning support
- Kong — Version-based routing plugin
- Cloudflare Workers — Custom versioning logic
Monitoring
Track version adoption and deprecation progress:
- Datadog APM — Track requests by API version tag
- New Relic — Custom attributes for version tracking
- Sentry — Error tracking per version
- PostHog — Version adoption analytics
Conclusion
API versioning isn't optional — it's how you ship improvements without breaking customer trust.
Key takeaways:
- Choose a strategy early: URI path (Stripe/Twilio), headers (GitHub), or date-based (AWS)
- Plan for versioning from day one: Start with
/v1/even for initial release - Communicate aggressively: 12+ month deprecation timelines with clear migration guides
- Test all versions: Don't break V1 when you ship V2
- Sunset old versions: Don't maintain 5+ versions forever
Real-world examples:
- Stripe maintains 100+ versions simultaneously (date-based)
- GitHub uses header-based versioning (clean URLs)
- AWS versions per service (granular control)
- Twilio uses URI versioning (maximum visibility)
Choose the approach that fits your API's needs, communicate clearly, and give customers time to migrate. Your future self (and your customers) will thank you.
Related Resources
- API Error Handling Guide
- API Authentication & Security
- API Documentation Best Practices
- API Testing Complete Guide
- Webhook Implementation Guide
- Monitor API Status — Track uptime for 160+ APIs
Having trouble with API versioning decisions? Check API Status Check to see how major platforms handle versioning and monitor their API health in real-time.
API Status Check
Stop checking API status pages manually
Get instant email alerts when OpenAI, Stripe, AWS, and 100+ APIs go down. Know before your users do.
Free dashboard available · 14-day trial on paid plans · Cancel anytime
Browse Free Dashboard →