API Versioning Strategies in Practice: From First Release to Sunset
A practical guide to API versioning: URL vs header approaches, breaking changes, Sunset-header deprecation, AWS API Gateway, GraphQL, and contract testing.
API versioning is not a URL convention; it is a contract-management problem. A version is a promise to a specific set of clients about a specific set of breaking changes. The strategy behind that promise (URL path, header, content negotiation, or a versioned resource graph) sets the price of every client migration, the time deprecated surface stays deployed, and the amount of infrastructure duplicated during a transition. Most versioning rewrites start the moment a team notices it has been communicating the contract implicitly and now has to make it explicit.
The default worth starting from: put the version in the URL path for anything with external consumers, keep changes additive inside a major version, and announce a sunset date months ahead. Header versioning, GraphQL schema evolution, and adapter layers are the deliberate exceptions to that default, each with a narrower set of conditions that justifies the extra machinery.
Four Problems API Evolution Creates#
The four arrive together, and a versioning strategy has to answer all of them:
Breaking Change Management: When you rename a field from name to fullName, existing clients expecting name will fail. Breaking changes are unavoidable in a long-lived API; the engineering work is making them without a production incident.
Version Proliferation: Teams without a sunset policy end up supporting six or more concurrent API versions. Each version multiplies the testing matrix, security patch burden, and infrastructure costs. Engineering time compounds quickly.
Migration Coordination: When 50 different clients depend on your API, coordinating zero-downtime migrations becomes complex. Some clients update immediately, others take months. You need a strategy that accommodates both.
Documentation Synchronization: Maintaining OpenAPI specs, SDK versions, and documentation across multiple API versions is where many versioning strategies fail. The docs drift from reality, causing integration confusion.
Choosing Your Versioning Strategy#
Three approaches dominate, each with specific trade-offs: the version in the URL path, the version in a request header, or (with GraphQL) no version at all and a schema that evolves continuously.
URL Path Versioning#
// Version embedded in URL path
app.get('/api/v1/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
email: user.email
});
});
app.get('/api/v2/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
fullName: user.name, // Field renamed
contactInfo: {
email: user.email,
phone: user.phone
}
});
});
Advantages: Explicit versioning visible in URLs, straightforward to test in browsers, excellent CDN cache efficiency (different URLs = separate cache keys).
Disadvantages: URL changes break bookmarks and hardcoded clients, requires routing configuration for each version.
Best for: Public APIs with multiple major versions where clarity matters more than URL aesthetics. Twitter, Stripe, and GitHub (historically) use this approach.
Header-Based Versioning#
// Version determined by request header
app.use((req, res, next) => {
const apiVersion = req.headers['api-version'] ||
req.headers['accept-version'] ||
'1'; // default version
req.apiVersion = apiVersion;
res.setHeader('API-Version', apiVersion);
next();
});
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
if (req.apiVersion === '2') {
return res.json(transformToV2(user));
}
res.json(transformToV1(user));
});
Advantages: Clean URLs that don’t change, granular version control, supports content negotiation patterns.
Disadvantages: Harder to test without API clients, version information invisible in logs unless you specifically log headers, cache configuration requires Vary header setup.
Best for: Internal APIs, APIs with frequent minor updates, systems where URL stability matters. GitHub (current approach) and Microsoft Graph API use this pattern.
Decision Framework#
Default to URL path versioning whenever consumers sit outside your organization; the clarity is worth the extra routing configuration. Header versioning earns its place on internal microservices, where the URL is itself a stable contract between services you already control. When one team owns both the consumer and the provider, GraphQL’s evolution model takes the versioning question off the table.
Breaking vs Non-Breaking Changes#
Understanding what constitutes a breaking change prevents accidental production incidents:
Non-Breaking (Safe) Changes:
- Adding new endpoints
- Adding optional request parameters
- Adding new fields to responses (existing clients ignore them)
- Adding new response status codes while keeping existing codes valid
- Relaxing validation rules (accepting more input formats)
Breaking Changes (Require New Version):
- Removing or renaming endpoints
- Removing or renaming request/response fields
- Changing field data types (string to number)
- Adding required request parameters
- Changing authentication mechanisms
- Modifying error response structures
- Changing HTTP methods (GET to POST)
Here’s how evolution without breaking looks in practice:
// Original API (v1) - stays unchanged
interface UserV1 {
id: string;
name: string;
email: string;
}
// Evolved API (v2) - additive changes only
interface UserV2 {
id: string;
name: string; // kept for compatibility
email: string; // kept for compatibility
// New optional fields
phoneNumber?: string;
avatar?: string;
preferences?: UserPreferences;
}
// Transform v2 data to v1 format when needed
function toV1Format(user: UserV2): UserV1 {
return {
id: user.id,
name: user.name,
email: user.email
};
}
Automated detection prevents mistakes. In CI/CD pipelines:
import { diff } from 'openapi-diff';
async function detectBreakingChanges(
oldSpecPath: string,
newSpecPath: string
): Promise<void> {
const result = await diff(oldSpecPath, newSpecPath);
if (result.breakingDifferencesFound) {
console.error('Breaking changes detected:');
result.breakingDifferences.forEach(change => {
console.error(`- ${change.type}: ${change.action}`);
console.error(` Path: ${change.path}`);
});
process.exit(1); // Fail build
}
}
Implementing Deprecation Properly#
Deprecation runs for months. A realistic timeline looks like this:
Communicate that timeline programmatically with the Sunset header (RFC 8594) and the Deprecation header (RFC 9745):
interface DeprecationConfig {
version: string;
deprecationDate: Date;
sunsetDate: Date;
migrationGuideUrl: string;
}
const v1Config: DeprecationConfig = {
version: '1',
deprecationDate: new Date('2025-07-01'),
sunsetDate: new Date('2026-01-01'),
migrationGuideUrl: 'https://docs.example.com/api/v1-to-v2-migration'
};
function addDeprecationHeaders(
res: Response,
config: DeprecationConfig
): void {
const now = new Date();
// RFC 9745 Deprecation header
if (now >= config.deprecationDate) {
res.setHeader('Deprecation', '@' + Math.floor(config.deprecationDate.getTime() / 1000));
}
// RFC 8594 Sunset header
res.setHeader('Sunset', config.sunsetDate.toUTCString());
// Link to migration documentation
res.setHeader('Link',
`<${config.migrationGuideUrl}>; rel="deprecation"; type="text/html"`
);
// Warning header with days remaining
const daysUntilSunset = Math.floor(
(config.sunsetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
res.setHeader('Warning',
`299 - "API version ${config.version} will be sunset in ${daysUntilSunset} days. ` +
`Please migrate to v2. See ${config.migrationGuideUrl}"`
);
}
app.use('/api/v1/*', (req, res, next) => {
addDeprecationHeaders(res, v1Config);
next();
});
One caveat on that snippet: RFC 9111 obsoleted the Warning field, so treat Sunset and Link as the machine-readable signal and Warning as a courtesy for clients that still parse it.
Client SDKs should detect and warn about deprecation:
class ApiClient {
private checkDeprecationHeaders(response: Response): void {
const deprecation = response.headers.get('Deprecation');
const sunset = response.headers.get('Sunset');
if (deprecation) {
const sunsetDate = sunset ? new Date(sunset) : null;
const daysUntilSunset = sunsetDate
? Math.floor((sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null;
console.warn(
`[API Deprecation Warning] This endpoint is deprecated.`,
sunsetDate ? `Sunset: ${sunsetDate.toISOString()}` : '',
daysUntilSunset !== null ? `Days remaining: ${daysUntilSunset}` : ''
);
// Report to monitoring
this.reportDeprecationMetric({
endpoint: response.url,
daysUntilSunset
});
}
}
}
Gradual throttling instead of hard shutdown reduces migration panic:
function getVersionThrottleLimit(version: string, sunsetDate: Date): number {
const daysUntilSunset = Math.floor(
(sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
if (daysUntilSunset > 30) {
return 10000; // Normal rate limit
} else if (daysUntilSunset > 7) {
return 1000; // Reduced rate
} else if (daysUntilSunset > 0) {
return 100; // Severe throttling
} else {
return 0; // Sunset passed
}
}
AWS API Gateway Versioning Patterns#
AWS API Gateway offers several versioning approaches. Here’s what works in production:
Custom Domain with Base Path Mapping#
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as cdk from 'aws-cdk-lib';
export class ApiVersioningStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Lambda function with versioning
const userServiceFn = new lambda.Function(this, 'UserService', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda'),
});
// V1 alias pointing to version 1
const v1Alias = new lambda.Alias(this, 'UserServiceV1', {
aliasName: 'v1',
version: userServiceFn.currentVersion,
});
// V2 alias pointing to version 2
const v2Alias = new lambda.Alias(this, 'UserServiceV2', {
aliasName: 'v2',
version: userServiceFn.currentVersion,
});
// V1 API Gateway
const apiV1 = new apigateway.RestApi(this, 'UserApiV1', {
restApiName: 'User Service V1',
deployOptions: { stageName: 'prod' },
});
const v1Users = apiV1.root.addResource('users');
const v1User = v1Users.addResource('{id}');
v1User.addMethod('GET', new apigateway.LambdaIntegration(v1Alias));
// V2 API Gateway
const apiV2 = new apigateway.RestApi(this, 'UserApiV2', {
restApiName: 'User Service V2',
deployOptions: { stageName: 'prod' },
});
const v2Users = apiV2.root.addResource('users');
const v2User = v2Users.addResource('{id}');
v2User.addMethod('GET', new apigateway.LambdaIntegration(v2Alias));
// Custom domain with path mappings
const domain = new apigateway.DomainName(this, 'CustomDomain', {
domainName: 'api.example.com',
certificate: acm.Certificate.fromCertificateArn(
this,
'Certificate',
'arn:aws:acm:us-east-1:123456789012:certificate/abc123'
),
});
// Map /v1/* to V1 API, /v2/* to V2 API
new apigateway.BasePathMapping(this, 'V1Mapping', {
domainName: domain,
restApi: apiV1,
basePath: 'v1',
});
new apigateway.BasePathMapping(this, 'V2Mapping', {
domainName: domain,
restApi: apiV2,
basePath: 'v2',
});
}
}
This creates clean URLs like https://api.example.com/v1/users/123 and https://api.example.com/v2/users/123, with complete isolation between versions. Both aliases point at currentVersion here to keep the snippet short; in a real deploy, v1 stays pinned to the published version that shipped the v1 contract while v2 moves forward.
Header-Based Routing with CloudFront#
For header versioning, Lambda@Edge routes requests:
import { CloudFrontRequestEvent } from 'aws-lambda';
export const handler = async (event: CloudFrontRequestEvent) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
const apiVersion = headers['api-version']?.[0]?.value || '1';
// Route to appropriate origin based on version
if (apiVersion === '2') {
request.origin = {
custom: {
domainName: 'api-v2.internal.example.com',
port: 443,
protocol: 'https',
path: '',
sslProtocols: ['TLSv1.2'],
readTimeout: 30,
keepaliveTimeout: 5,
customHeaders: {}
}
};
} else {
request.origin = {
custom: {
domainName: 'api-v1.internal.example.com',
port: 443,
protocol: 'https',
path: '',
sslProtocols: ['TLSv1.2'],
readTimeout: 30,
keepaliveTimeout: 5,
customHeaders: {}
}
};
}
return request;
};
URLs stay clean, and version selection happens at the edge before the request reaches either origin.
GraphQL Schema Evolution#
GraphQL’s philosophy differs from REST versioning. Instead of versioning the entire API, you evolve the schema continuously using field deprecation:
type User {
id: ID!
# Original field - never removed, but deprecated
name: String! @deprecated(reason: "Use firstName and lastName instead")
email: String!
# New fields added without breaking existing queries
firstName: String
lastName: String
# Deprecated field with clear migration path
phone: String @deprecated(reason: "Use contactInfo.phoneNumber instead")
# New structured contact information
contactInfo: ContactInfo
}
type ContactInfo {
email: String!
phoneNumber: String
address: Address
}
Clients that query name and phone continue working. New clients query firstName, lastName, and contactInfo. The GraphQL introspection API shows deprecation warnings.
For field-level version tracking, custom directives help:
directive @version(
added: String!
deprecated: String
removed: String
) on FIELD_DEFINITION
type User {
id: ID!
name: String! @version(added: "1.0")
email: String! @version(added: "1.0")
phoneNumber: String @version(added: "2.0")
# Field deprecated in 3.0, removed in 4.0
legacyAddress: String @version(
added: "1.0"
deprecated: "3.0"
removed: "4.0"
)
address: Address @version(added: "3.0")
}
Resolvers can track usage of deprecated fields:
const resolvers = {
User: {
legacyAddress: (parent, args, context) => {
const clientVersion = context.apiVersion || '1.0';
if (semver.gte(clientVersion, '3.0')) {
context.metrics.incrementDeprecatedFieldUsage('User.legacyAddress');
}
return parent.address?.fullAddress || '';
}
}
};
Consumer-Driven Contract Testing#
Contract testing ensures version compatibility between consumers and providers. Pact is the most established tool:
// Consumer test (Frontend team)
// Note: Using Pact V2 API. For V3+, use `PactV3` and different lifecycle methods.
const { Pact } = require('@pact-foundation/pact');
describe('User Service V2', () => {
const provider = new Pact({
consumer: 'UserWebApp',
provider: 'UserServiceV2',
port: 8080
});
beforeAll(() => provider.setup());
afterEach(() => provider.verify());
afterAll(() => provider.finalize());
it('should get user by id', async () => {
await provider.addInteraction({
state: 'user exists',
uponReceiving: 'a request for user with id 123',
withRequest: {
method: 'GET',
path: '/api/v2/users/123',
headers: { 'Accept': 'application/json' }
},
willRespondWith: {
status: 200,
headers: {
'Content-Type': 'application/json',
'API-Version': '2'
},
body: {
id: 123,
fullName: 'John Doe',
contactInfo: {
email: 'john@example.com',
phone: '+1234567890'
}
}
}
});
const user = await getUserById(123);
expect(user.fullName).toBe('John Doe');
});
});
Backend team verifies all consumer contracts:
const { Verifier } = require('@pact-foundation/pact');
describe('User Service Provider', () => {
it('should validate all consumer contracts', async () => {
await new Verifier({
provider: 'UserServiceV2',
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.example.com',
publishVerificationResult: true,
providerVersion: '2.0.0',
providerVersionTags: ['prod']
}).verifyProvider();
});
});
This catches breaking changes before they reach production. When the frontend expects fullName but the backend returns name, the contract test fails during provider verification.
Migration Patterns#
Parallel Run with Traffic Splitting#
Gradual rollout reduces risk:
Implement with feature flags:
import LaunchDarkly from 'launchdarkly-node-server-sdk';
const ldClient = LaunchDarkly.init(process.env.LAUNCHDARKLY_SDK_KEY);
app.get('/api/users/:id', async (req, res) => {
const user = {
key: req.user?.id || 'anonymous',
email: req.user?.email,
custom: {
apiClient: req.headers['user-agent']
}
};
const useV2 = await ldClient.variation('api-v2-rollout', user, false);
if (useV2) {
return handleGetUserV2(req, res);
} else {
return handleGetUserV1(req, res);
}
});
LaunchDarkly’s dashboard lets you increase percentage gradually: 10% → 25% → 50% → 75% → 100% over several weeks.
Shadow Mode Testing#
Test new version without affecting responses:
app.get('/api/users/:id', async (req, res) => {
// Primary request to V1 (production)
const v1Promise = handleGetUserV1(req);
// Shadow request to V2 (testing)
const v2Promise = handleGetUserV2(req).catch(err => {
logger.error('V2 shadow request failed', { error: err });
return null;
});
// Wait for V1 response
const v1Result = await v1Promise;
// Compare results asynchronously
v2Promise.then(v2Result => {
if (v2Result) {
compareResponses(v1Result, v2Result, req.params.id);
}
});
// Return V1 response
res.json(v1Result);
});
function compareResponses(v1: any, v2: any, userId: string): void {
const differences = deepDiff(v1, v2);
if (differences.length > 0) {
logger.warn('V1/V2 response mismatch', {
userId,
differences
});
metrics.increment('api.v2.response_mismatch');
}
}
V2 gets exercised under production load without touching the response the client sees.
Adapter Pattern for Backward Compatibility#
Unified endpoint supporting both versions:
interface UserV1Response {
id: string;
name: string;
email: string;
}
interface UserV2Response {
id: string;
fullName: string;
contactInfo: {
email: string;
phoneNumber?: string;
};
}
class UserResponseAdapter {
static toV1(v2User: UserV2Response): UserV1Response {
return {
id: v2User.id,
name: v2User.fullName,
email: v2User.contactInfo.email
};
}
}
app.get('/api/users/:id', async (req, res) => {
const requestedVersion = req.headers['api-version'] || '1';
// Always fetch full V2 data
const user = await db.users.findById(req.params.id);
if (requestedVersion === '1') {
res.setHeader('API-Version', '1');
res.setHeader('Deprecation', 'true');
return res.json(UserResponseAdapter.toV1(user));
}
res.setHeader('API-Version', '2');
return res.json(user);
});
Monitoring Version Usage#
Track which clients use which versions:
interface VersionMetrics {
version: string;
totalRequests: number;
uniqueClients: number;
errorRate: number;
avgLatency: number;
}
// CloudWatch custom metrics
const cloudwatch = new AWS.CloudWatch();
function trackVersionUsage(version: string, clientId: string): void {
cloudwatch.putMetricData({
Namespace: 'API/Versioning',
MetricData: [{
MetricName: 'RequestCount',
Dimensions: [
{ Name: 'Version', Value: version },
{ Name: 'ClientId', Value: clientId }
],
Value: 1,
Unit: 'Count',
Timestamp: new Date()
}]
});
}
Generate migration progress reports:
class MigrationTracker {
async getClientMigrationStatus(): Promise<ClientMigrationReport[]> {
const clients = await this.getAllClients();
return clients.map(client => ({
clientId: client.id,
clientName: client.name,
currentVersion: client.apiVersion,
targetVersion: '2',
lastRequestDate: client.lastSeen,
requestCount7d: client.requests7d,
migrationStatus: this.getMigrationStatus(client)
}));
}
private getMigrationStatus(client: Client): string {
if (client.apiVersion === '2') return 'Completed';
if (client.lastSeen < subDays(new Date(), 30)) return 'Inactive';
if (client.requests7d > 1000) return 'High Priority';
return 'Pending';
}
}
Common Pitfalls#
Insufficient Deprecation Notice: Announcing sunset only 3 months before shutdown causes client scrambles. Minimum 12 months for public APIs works better.
Breaking Changes in Minor Versions: Adding a required field and calling it version 1.3.0 instead of 2.0.0 breaks semantic versioning expectations. Use automated OpenAPI diff in CI/CD to catch this.
Version Proliferation: Supporting 6+ concurrent versions multiplies engineering costs. Strict sunset policy helps: maximum 3 versions (current + previous + deprecated).
Inconsistent SDK Versioning: SDK version 2.3.0 working with API version 1 confuses developers. Align SDK major version with API major version.
Missing Contract Tests: Backend changes break frontend because nobody tested V1 adapter compatibility. Pact prevents this.
Unversioned Error Responses: Only versioning success responses while error format changes breaks client error handling. Version errors consistently.
No Deprecation Monitoring: Shutting down V1 without knowing major clients still use it causes revenue-impacting outages. Track usage before sunset.
The Default and Its Exceptions#
URL path versioning, additive-only changes inside a major version, and a sunset date announced months ahead cover most public and partner APIs. That default holds while consumers sit outside your control and breaking changes are rare enough to batch into a major bump. Override it when the same team owns both sides of the call; header versioning or GraphQL schema evolution then avoids URL churn nobody outside that team sees. Override it when changes ship faster than clients can absorb them: date-based versioning, the model GitHub and Stripe use, moves the cost into a compatibility layer you maintain. And when a single consumer dominates traffic, a contract test with that consumer buys more safety than a new version number.
Two things pay for themselves before any of these decisions: OpenAPI diff in CI, and per-version usage metrics on a dashboard someone reads well before the sunset date.
References#
- Microsoft REST API Guidelines (opens in new tab) - Authoritative REST API versioning guidelines including URL path and query-string versioning
- GitHub REST API: API Versions (opens in new tab) - GitHub’s date-based header versioning approach with 24-month support windows
- Stripe API Versioning (opens in new tab) - Stripe’s date-based versioning model with backward-compatible changes
- Stripe API Upgrades (opens in new tab) - Detailed changelog of breaking vs additive changes across Stripe API versions
- Microsoft Azure REST API Guidelines (opens in new tab) - Azure-specific guidance on versioning, breaking changes, and deprecation
- RFC 8594: The Sunset HTTP Header Field (opens in new tab) - Specification for the
Sunsetheader that announces when a resource stops responding - RFC 9745: The Deprecation HTTP Response Header Field (opens in new tab) - Standards-track definition of the
Deprecationfield, including the@unix-timestampvalue syntax - RFC 9111: HTTP Caching (opens in new tab) - Current HTTP caching specification, which obsoletes the
Warningheader field
Related posts
Lambda Web Functions run a Hono app as a plain HTTP server. What moves off API Gateway, what stays in the app, and when a Hono Lambdalith is still the better fit.
lambda · serverless · api-gateway +3
Why RBAC passes and users still read each other's records: OWASP API1 BOLA, four ways to close the gap, a recommended default, and a cross-tenant test for CI.
authorization · security · api-design +3
Move single-purpose Lambdas behind API Gateway into a Hono Lambdalith one route at a time, with a per-route rule for auth scopes, validation, throttling, and metrics.
lambda · serverless · api-gateway +3
Rushing feels fast but creates rework, bugs, and firefighting. Why pausing for refactoring, tests, and CI upkeep is an investment in speed, not lost speed.
technical-debt · testing · ci-cd +2
Committing Bruno .bru files to the repo keeps the API contract in the same PR and history as the code. The only real tax is a deliberate secrets boundary.
testing · ci-cd · developer-experience +1