Skip to content
Ayhan Sipahi Ayhan Sipahi

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

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: '[email protected]',
            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