Skip to content

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.

Ayhan Sipahi Ayhan Sipahi

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#

Yes

No

Yes

No

Yes

No

Yes

No

Choose API Versioning Strategy

Public API?

Many External Consumers?

Internal/Partner API

URL Path Versioning /api/v1/resource

Medium Scale API

Frequent Breaking Changes?

Header Versioning API-Version: 2

Same Team Controls Consumer & Provider?

Consider GraphQL Schema Evolution

Cache-friendly Easy to test Clear documentation

Flexible Clean URLs Granular control

Continuous evolution Field deprecation No versioning needed

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:

Jan 2024Apr 2024Jul 2024Oct 2024Jan 2025Apr 2025Jul 2025Oct 2025Jan 2026Apr 2026Jul 2026Oct 2026Active & Supported Development Beta Testing General Availability Migration Guide Published Deprecated but Functional Deprecation Notice Sent Final Reminder (3 months) Final Reminder (1 month) Sunset & Decommissioned V1 Shutdown V1 APIV2 APICommunicationAPI Version Deprecation Timeline

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:

DatabaseAPI V2API V1Load BalancerClientDatabaseAPI V2API V1Load BalancerClientWeek 1-2: 10% traffic to V2Week 3-4: 50% traffic to V2Week 5-6: 90% traffic to V2Week 7: 100% traffic to V2Decommission V1API Request90% trafficQueryDataResponse10% trafficQueryDataResponse

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#

Related posts