Skip to content

API Versioning Strategies in Practice: From First Release to Sunset

A comprehensive guide to API versioning strategies covering URL vs header approaches, breaking changes, deprecation with Sunset headers, AWS API Gateway patterns, GraphQL evolution, and consumer-driven 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, and the strategy behind it (URL path, header, content negotiation, or a versioned resource graph) determines how expensive client migrations are, how long deprecated surface stays deployed, and how much infrastructure gets duplicated during transitions. Most API versioning rewrites are not about choosing /v2/ over v2.; they are about realizing the team has been communicating a contract implicitly and needs to make it explicit.

Abstract

API versioning requires balancing backward compatibility with forward progress. This guide covers practical implementation strategies including URL path versus header versioning, managing breaking changes with OpenAPI diff tools, implementing RFC 8594 deprecation headers, versioning patterns in AWS API Gateway with Lambda aliases, GraphQL schema evolution without traditional versioning, consumer-driven contract testing with Pact, and coordinated migration patterns. The approach emphasizes gradual rollouts, comprehensive monitoring, and clear communication to minimize disruption while enabling continuous API improvement.

The Technical Challenge

API evolution creates several interconnected problems:

Breaking Change Management: When you rename a field from name to fullName, existing clients expecting name will fail. The question isn't whether to make breaking changes; it's how to do it without causing production incidents.

Version Proliferation: I've seen teams supporting six concurrent API versions because they lacked a sunset policy. Each version multiplies your testing matrix, security patch burden, and infrastructure costs. The 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 main approaches exist, each with specific trade-offs:

URL Path Versioning

typescript
// Version embedded in URL pathapp.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

typescript
// Version determined by request headerapp.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

The choice depends on your specific context. For a public REST API with external partners, URL path versioning provides clarity. For internal microservices, header versioning offers flexibility. For teams with tight consumer-provider coupling, GraphQL's evolution model eliminates versioning complexity.

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:

typescript
// Original API (v1) - stays unchangedinterface UserV1 {  id: string;  name: string;  email: string;}
// Evolved API (v2) - additive changes onlyinterface 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 neededfunction toV1Format(user: UserV2): UserV1 {  return {    id: user.id,    name: user.name,    email: user.email  };}

Automated detection prevents mistakes. In CI/CD pipelines:

typescript
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 isn't an event; it's a process. Here's a realistic timeline:

Implement RFC 8594 deprecation headers to communicate timeline programmatically:

typescript
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();});

Client SDKs should detect and warn about deprecation:

typescript
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:

typescript
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

typescript
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.

Header-Based Routing with CloudFront

For header versioning, Lambda@Edge routes requests:

typescript
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;};

This approach keeps URLs clean while supporting header-based version selection.

GraphQL Schema Evolution

GraphQL's philosophy differs from REST versioning. Instead of versioning the entire API, you evolve the schema continuously using field deprecation:

graphql
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:

typescript
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:

typescript
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:

javascript
// 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: '[email protected]',            phone: '+1234567890'          }        }      }    });
    const user = await getUserById(123);    expect(user.fullName).toBe('John Doe');  });});

Backend team verifies all consumer contracts:

javascript
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:

typescript
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:

typescript
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');  }}

This validates V2 behavior under production load without risk.

Adapter Pattern for Backward Compatibility

Unified endpoint supporting both versions:

typescript
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:

typescript
interface VersionMetrics {  version: string;  totalRequests: number;  uniqueClients: number;  errorRate: number;  avgLatency: number;}
// CloudWatch custom metricsconst 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:

typescript
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.

Key Takeaways

  1. Choose versioning based on audience: URL path for public APIs, headers for internal, GraphQL evolution for rapid iteration.

  2. Automate breaking change detection: OpenAPI diff tools in CI/CD prevent accidental breaking changes.

  3. Deprecation requires 12+ months: Multi-channel communication (headers, email, docs), usage monitoring, gradual throttling instead of hard shutdown.

  4. Limit concurrent versions: Maximum 3 active versions prevents technical debt explosion.

  5. Use gradual rollouts: 10% → 25% → 50% → 100% traffic splitting with feature flags reduces migration risk.

  6. Contract testing prevents breaks: Pact catches incompatibilities between consumers and providers before production.

  7. GraphQL eliminates versioning complexity: Field-level deprecation provides smooth migration without version explosion.

  8. Monitor version usage continuously: Track which clients use deprecated versions, identify stragglers early.

The versioning strategy that works depends on your specific context; public vs internal API, number of consumers, change frequency. Start with the simplest approach that meets your requirements, then add complexity only when needed.

References

Related Posts