Skip to content
Ayhan Sipahi Ayhan Sipahi

API Versioning with AWS API Gateway and CDK: A Practical Guide

Path-based API versioning on AWS API Gateway with CDK: a lifecycle registry per version, per-version Lambda handlers, discovery, and deprecation signals.

Path-based versioning (/v1/users, /v2/users) backed by an explicit lifecycle record per version is the default worth reaching for on API Gateway. It costs more infrastructure than one continuously evolving endpoint, and it is the approach that survives clients who cannot update on your schedule.

API evolution creates a standing conflict: the contract has to improve while existing integrations keep working. That conflict sharpens with enterprise clients, whose release cadences run from weekly deploys to update windows measured in quarters, and whose integrations are often embedded in systems nobody wants to touch. The CDK shape of the default is a version registry, per-version Lambda handlers, a discovery endpoint, and the deprecation signals that make an eventual sunset possible.

Approaches That Break Down

Three versioning strategies look reasonable on a whiteboard and fail once clients arrive, each for a different reason.

No Versioning At All

This strategy assumes every client can be updated at once, which removes the need for versioning entirely. It holds exactly as long as you control every consumer.

Where it breaks:

  • Clients on air-gapped or tightly regulated networks update on a cycle you cannot compress
  • Security fixes have to be backported by hand to whatever each client is still running
  • Keeping an unofficial older behavior alive means maintaining a shadow API with no name and no tests
  • Every change needs a compatibility analysis before it can ship, so throughput drops

Versioning Every Axis Independently

The next strategy versions endpoints, headers, and response formats separately.

GET /v2/users?response_version=1.3
X-API-Version: 2.1
Accept: application/vnd.company.user.v4+json

Where it breaks:

  • Every response version has to be tested against every endpoint version, so the combinations multiply
  • No single value identifies what a client is actually running
  • Client integration gets harder, because the client now has to pick three values correctly
  • Documentation has to describe the product of all three axes, which no reference page survives

Fingerprint-Based Routing

The third strategy inspects the client (user agent, SDK header, IP range) and routes to a version automatically, usually with a Lambda@Edge or CloudFront Function in front.

Where it breaks:

  • The router sits in front of every version, so its failures are everyone’s failures
  • Fingerprints are guesses; a client that upgrades its HTTP library can silently change versions
  • The extra hop adds latency to every request, including requests that needed no routing at all
  • Debugging “which version did this client get” now requires reproducing the router’s state

Path-based versioning avoids all three failure modes for one reason: the version is in the URL, so it is explicit, loggable, and chosen by the client.

Path-Based Versioning with a Lifecycle Registry

The default combines path-based routing with a lifecycle record per version and automated deprecation warnings.

// lib/config/api-versions.ts
export interface ApiVersion {
  version: string;
  status: 'alpha' | 'beta' | 'stable' | 'deprecated' | 'sunset';
  launchedAt: Date;
  deprecatedAt?: Date;
  sunsetAt?: Date;
  monthlyActiveClients?: number;  // Track this!
  breakingChanges: string[];
  supportedFeatures: Set<string>;
}

export const API_VERSIONS: Record<string, ApiVersion> = {
  v1: {
    version: 'v1',
    status: 'deprecated',
    launchedAt: new Date('2022-01-15'),
    deprecatedAt: new Date('2024-01-15'),
    sunsetAt: new Date('2025-01-15'),
    monthlyActiveClients: 28,  // Legacy government clients
    breakingChanges: [],
    supportedFeatures: new Set(['basic-crud']),
  },
  v2: {
    version: 'v2',
    status: 'stable',
    launchedAt: new Date('2023-06-01'),
    monthlyActiveClients: 156,
    breakingChanges: [
      'Changed userId to user_id in all responses',
      'Removed XML support',
      'Made email field required',
    ],
    supportedFeatures: new Set(['basic-crud', 'pagination', 'filtering']),
  },
  v3: {
    version: 'v3',
    status: 'beta',
    launchedAt: new Date('2024-03-01'),
    monthlyActiveClients: 42,
    breakingChanges: [
      'Moved to JSON:API spec',
      'Changed all IDs to UUIDs',
      'Nested resources under data property',
    ],
    supportedFeatures: new Set([
      'basic-crud',
      'pagination',
      'filtering',
      'webhooks',
      'graphql',
      'batch-operations'
    ]),
  },
};

CDK Stack Implementation

The stack reads the registry and wires each live version to its own resource tree:

// lib/stacks/versioned-api-stack.ts
import { RestApi, MethodLoggingLevel, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Alarm, Metric } from 'aws-cdk-lib/aws-cloudwatch';
import { Construct } from 'constructs';

export class VersionedApiStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    const api = new RestApi(this, 'MultiVersionAPI', {
      restApiName: 'production-api',
      // Enable CloudWatch from the start; version-specific issues are invisible without it
      deployOptions: {
        loggingLevel: MethodLoggingLevel.INFO,
        dataTraceEnabled: true,  // Essential for debugging version-specific issues
        metricsEnabled: true,
        tracingEnabled: true,
      },
    });

    // Add the version check Lambda - this is crucial
    const versionCheckFn = new NodejsFunction(this, 'VersionCheck', {
      entry: 'src/middleware/version-check.ts',
      memorySize: 256,  // Don't need much
      timeout: Duration.seconds(3),
      environment: {
        VERSIONS: JSON.stringify(API_VERSIONS),
        SLACK_WEBHOOK: process.env.SLACK_WEBHOOK!,  // Alert on deprecated version usage
      },
    });

    // Set up each version
    Object.entries(API_VERSIONS).forEach(([version, config]) => {
      if (config.status === 'sunset') return;  // Don't deploy sunset versions

      const versionResource = api.root.addResource(version);
      this.setupVersionEndpoints(versionResource, config);
    });

    // Critical: version discovery endpoint
    this.addVersionDiscovery(api);

    // Traffic on a deprecated version is the signal that blocks a sunset
    new Alarm(this, 'DeprecatedVersionHighUsage', {
      metric: new Metric({
        namespace: 'API/Versions',
        metricName: 'DeprecatedVersionCalls',
        statistic: 'Sum',
      }),
      threshold: 1000,
      evaluationPeriods: 1,
    });
  }

  private setupVersionEndpoints(resource: IResource, config: ApiVersion) {
    // One function per endpoint per version: more functions, but no shared
    // code path where a v3 change can reach a v1 client

    const handlers = new Map<string, Function>();

    // User endpoints - the source of most breaking changes
    const usersResource = resource.addResource('users');

    const listUsersHandler = new NodejsFunction(this, `ListUsers-${config.version}`, {
      entry: `src/handlers/${config.version}/users/list.ts`,
      memorySize: config.version === 'v1' ? 512 : 1024,  // V1 is inefficient
      timeout: Duration.seconds(29),  // API Gateway maximum timeout (up to 29 seconds for REST APIs)
      environment: {
        TABLE_NAME: process.env.USERS_TABLE!,
        VERSION: config.version,
        FEATURES: [...config.supportedFeatures].join(','),
        // This saved debugging time countless times
        DEPLOYMENT_TIME: new Date().toISOString(),
      },
      bundling: {
        // Version-specific dependencies
        externalModules: [
          '@aws-sdk/client-dynamodb',  // AWS SDK v3 for Node.js 18+ runtime
          '@aws-sdk/client-cloudwatch',
          ...(config.version === 'v1' ? ['xmlbuilder'] : []),  // V1 XML support
        ],
      },
    });

    usersResource.addMethod('GET', new LambdaIntegration(listUsersHandler), {
      requestParameters: {
        'method.request.querystring.page': config.supportedFeatures.has('pagination'),
        'method.request.querystring.limit': config.supportedFeatures.has('pagination'),
        'method.request.querystring.filter': config.supportedFeatures.has('filtering'),
        // V3 specific parameters
        'method.request.querystring.include': config.version === 'v3',
        'method.request.querystring.fields': config.version === 'v3',
      },
    });

    // Track every version call - this metric is gold
    listUsersHandler.metricInvocations().createAlarm(this, `HighTraffic-${config.version}`, {
      threshold: 10000,
      evaluationPeriods: 1,
      alarmDescription: `High traffic on ${config.version} - check scaling`,
    });
  }
}

Version Handler Code

Each version gets its own handler. The transformation at the top of each one is where the version contract actually lives:

// src/handlers/v1/users/list.ts
// Legacy v1 implementation, kept frozen apart from security fixes
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  console.log('V1 handler called', {
    path: event.path,
    clientIp: event.requestContext.identity.sourceIp,
    userAgent: event.headers['User-Agent'],
  });

  try {
    // V1 doesn't support pagination, returns everything
    // V1 design limitation - maintained for compatibility
    const users = await getAllUsers();  // Returns all users - pagination added in v2

    // Field renames are the usual source of breaking changes
    const transformedUsers = users.map(u => ({
      userId: u.user_id,  // V1 uses camelCase
      userName: u.name,
      userEmail: u.email,
      createdDate: u.created_at,  // V1 name kept so the contract holds
    }));

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'X-API-Version': 'v1',
        'X-API-Deprecated': 'true',
        'X-API-Sunset': '2025-01-15',
        'Warning': '299 - "API v1 is deprecated. Please migrate to v2. Guides: https://docs.api.com/migration"',
        // Required by financial industry clients
        'X-Total-Count': transformedUsers.length.toString(),
      },
      body: JSON.stringify(transformedUsers),
    };
  } catch (error) {
    // Comprehensive error logging for troubleshooting
    console.error('V1 handler error', {
      error,
      stack: error.stack,
      event: JSON.stringify(event),
    });

    return {
      statusCode: 500,
      body: JSON.stringify({
        error: 'Internal Server Error',
        // V1 clients expect this exact format
        errorCode: 'INTERNAL_ERROR',
        timestamp: new Date().toISOString(),
      }),
    };
  }
};

// src/handlers/v2/users/list.ts
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  // V2 adds the pagination V1 never had
  const page = parseInt(event.queryStringParameters?.page || '1');
  const limit = Math.min(
    parseInt(event.queryStringParameters?.limit || '20'),
    100  // Maximum page size for performance
  );

  const metrics = {
    version: 'v2',
    page,
    limit,
    clientIp: event.requestContext.identity.sourceIp,
  };

  // Track deprecated version usage
  if (event.headers['User-Agent']?.includes('OldSDK/1.')) {
    await cloudwatch.send(new PutMetricDataCommand({
      Namespace: 'API/Clients',
      MetricData: [{
        MetricName: 'OutdatedSDKUsage',
        Value: 1,
        Dimensions: [{ Name: 'Version', Value: 'v2' }],
      }],
    }));
  }

  try {
    const { users, total } = await getUsersPaginated({ page, limit });

    // V2 response format with pagination
    const response = {
      data: users.map(u => ({
        id: u.user_id,  // Changed from userId
        name: u.name,
        email: u.email,
        status: u.status || 'active',  // New required field
        created_at: u.created_at,  // Snake case everywhere
        updated_at: u.updated_at,
      })),
      pagination: {
        page,
        limit,
        total,
        total_pages: Math.ceil(total / limit),
        has_next: page < Math.ceil(total / limit),
        has_prev: page > 1,
      },
      // HATEOAS links for client navigation
      _links: {
        self: `/v2/users?page=${page}&limit=${limit}`,
        next: page < Math.ceil(total / limit) ? `/v2/users?page=${page + 1}&limit=${limit}` : null,
        prev: page > 1 ? `/v2/users?page=${page - 1}&limit=${limit}` : null,
      },
    };

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'X-API-Version': 'v2',
        'X-RateLimit-Limit': '500',
        'X-RateLimit-Remaining': await getRateLimitRemaining(event),
        'Cache-Control': 'private, max-age=60',  // Prevent unintended caching
      },
      body: JSON.stringify(response),
    };
  } catch (error) {
    logger.error('V2 handler error', { error, metrics });
    throw error;  // Let API Gateway handle it
  }
};

// src/handlers/v3/users/list.ts
// V3: JSON:API specification implementation
export const handler = middy(async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  // JSON:API compliance for enterprise integration
  const params = parseJsonApiParams(event.queryStringParameters);

  // Feature flags for gradual rollout
  const features = await getFeatureFlags('v3', event.headers['X-Client-Id']);

  const { users, total, included } = await getUsersWithRelationships({
    ...params,
    includeRelationships: params.include,
    sparseFields: params.fields,
    experimentalFeatures: features,
  });

  // JSON:API format - love it or hate it
  const response = {
    data: users.map(u => ({
      type: 'users',
      id: u.id,  // UUID format for consistency
      attributes: {
        name: u.name,
        email: u.email,
        status: u.status,
        created_at: u.created_at,
        updated_at: u.updated_at,
      },
      relationships: {
        organization: {
          data: { type: 'organizations', id: u.organization_id },
        },
        roles: {
          data: u.role_ids.map(id => ({ type: 'roles', id })),
        },
      },
      links: {
        self: `/v3/users/${u.id}`,
      },
    })),
    included: included,  // Related resources
    meta: {
      pagination: {
        page: params.page.number,
        pages: Math.ceil(total / params.page.size),
        count: users.length,
        total: total,
      },
      api_version: 'v3',
      generated_at: new Date().toISOString(),
      experimental_features: [...features],
    },
    links: generateJsonApiLinks(params, total),
  };

  return {
    statusCode: 200,
    headers: {
      'Content-Type': 'application/vnd.api+json',  // JSON:API requirement
      'X-API-Version': 'v3',
      'X-RateLimit-Limit': '1000',
      'X-RateLimit-Remaining': await getRateLimitRemaining(event),
      'Vary': 'Accept, X-Client-Id',  // Important for caching
    },
    body: JSON.stringify(response),
  };
})
  .use(jsonBodyParser())
  .use(httpErrorHandler())
  .use(correlationIds())
  .use(logTimeout())
  .use(warmup());

Migration Pain Points and Solutions

Renaming a Key Field Without Downtime

Moving from V1 to V2 renames userId (string) to user_id (UUID). The safe shape is two passes: write the new field alongside the old one, then remove the old field only once every client has moved off V1.

// migrations/v1-to-v2-user-ids.ts
export const migrateUserIds = async () => {
  const BATCH_SIZE = 100;
  let lastEvaluatedKey: any = undefined;
  let migrated = 0;
  let failed = 0;

  // First pass: Add new field
  do {
    const { Items, LastEvaluatedKey } = await docClient.send(new ScanCommand({
      TableName: process.env.USERS_TABLE!,
      Limit: BATCH_SIZE,
      ExclusiveStartKey: lastEvaluatedKey,
    }));

    const batch = Items?.map(item => ({
      PutRequest: {
        Item: {
          ...item,
          user_id: item.userId || generateUUID(),  // New field
          _migration: 'v1-to-v2-phase1',
          _migrated_at: new Date().toISOString(),
        },
      },
    })) || [];

    if (batch.length > 0) {
      try {
        await docClient.send(new BatchWriteCommand({
          RequestItems: { [process.env.USERS_TABLE!]: batch },
        }));
        migrated += batch.length;
      } catch (error) {
        // Log but don't stop - we'll retry failed items
        console.error('Batch failed', { error, batch: batch.map(b => b.PutRequest.Item.userId) });
        failed += batch.length;
      }
    }

    lastEvaluatedKey = LastEvaluatedKey;

    // Throttle to avoid hot partitions
    await new Promise(resolve => setTimeout(resolve, 100));

  } while (lastEvaluatedKey);

  console.log(`Migration complete: ${migrated} succeeded, ${failed} failed`);

  // Second pass: remove the old field, only after V1 usage reaches zero
};

Client SDK Backwards Compatibility

An SDK that spans versions has to normalize three response shapes into one type. The switch is verbose, and it keeps the version detail out of application code:

// sdk/src/client.ts
export class ApiClient {
  private version: string;
  private warned = new Set<string>();

  constructor(options: ClientOptions = {}) {
    this.version = options.version || 'v2';  // Default to stable

    if (this.version === 'v1' && !this.warned.has('deprecation')) {
      console.warn(
        '\x1b[33m%s\x1b[0m',  // Yellow text
        '[DEPRECATION] API v1 will be sunset on 2025-01-15. ' +
        'Migration guide: https://docs.api.com/migration'
      );
      this.warned.add('deprecation');

      // Track SDK version usage
      this.trackEvent('sdk_deprecation_warning', { version: 'v1' });
    }
  }

  async getUsers(options?: GetUsersOptions) {
    const url = this.buildUrl('users', options);
    const response = await this.request(url);

    // Normalize responses across versions
    return this.normalizeUserResponse(response);
  }

  private normalizeUserResponse(response: any): User[] {
    switch (this.version) {
      case 'v1':
        // V1 returns flat array
        return response.map((u: any) => ({
          id: u.userId,
          name: u.userName,
          email: u.userEmail,
          createdAt: new Date(u.createdDate),
          // V1 doesn't have these
          status: 'active',
          updatedAt: new Date(u.createdDate),
        }));

      case 'v2':
        // V2 returns paginated response
        return response.data.map((u: any) => ({
          id: u.id,
          name: u.name,
          email: u.email,
          status: u.status,
          createdAt: new Date(u.created_at),
          updatedAt: new Date(u.updated_at),
        }));

      case 'v3':
        // V3 returns JSON:API format
        return response.data.map((u: any) => ({
          id: u.id,
          name: u.attributes.name,
          email: u.attributes.email,
          status: u.attributes.status,
          createdAt: new Date(u.attributes.created_at),
          updatedAt: new Date(u.attributes.updated_at),
          // V3 includes relationships
          organizationId: u.relationships?.organization?.data?.id,
          roleIds: u.relationships?.roles?.data?.map((r: any) => r.id) || [],
        }));

      default:
        throw new Error(`Unknown API version: ${this.version}`);
    }
  }
}

Monitoring and Alerting

The monitoring system provides visibility into version usage patterns and performance:

// lib/constructs/api-monitoring.ts
export class ApiMonitoring extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Dashboard that actually gets looked at
    const dashboard = new Dashboard(this, 'ApiDashboard', {
      dashboardName: 'api-versions-prod',
      defaultInterval: Duration.hours(3),  // Recent enough to be useful
    });

    // Version distribution is the number that decides when a sunset can happen
    dashboard.addWidgets(
      new GraphWidget({
        title: 'API Version Distribution (% of requests)',
        left: [v1Percentage, v2Percentage, v3Percentage],
        leftYAxis: { max: 100, min: 0 },
        period: Duration.minutes(5),
        statistic: 'Average',
        // Minimum usage threshold for sunset decisions
        leftAnnotations: [{
          label: 'Min safe threshold',
          value: 5,
          color: Color.RED,
        }],
      })
    );

    // The metric that matters: client errors by version
    dashboard.addWidgets(
      new GraphWidget({
        title: '4xx Errors by Version',
        left: [
          new MathExpression({
            expression: 'RATE(m1)',
            usingMetrics: {
              m1: v1Errors,
            },
            label: 'V1 Error Rate',
            color: Color.RED,
          }),
          // Similar for v2, v3
        ],
      })
    );

    // Deprecation warning effectiveness
    const deprecationAlarm = new Alarm(this, 'V1StillHighUsage', {
      metric: v1Percentage,
      threshold: 10,
      evaluationPeriods: 3,
      comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
      alarmDescription: 'V1 still above 10% - delay sunset?',
      treatMissingData: TreatMissingData.NOT_BREACHING,
    });

    deprecationAlarm.addAlarmAction(
      new SnsAction(Topic.fromTopicArn(this, 'AlertTopic', process.env.ALERT_TOPIC_ARN!))
    );
  }
}

Multi-Version Constraints

Sunset Is Harder Than Launch

A deprecated version keeps traffic long after the deprecation notice, and the reasons are outside your control:

  • Public-sector and regulated clients plan deployments a quarter or more ahead
  • IoT and embedded devices ship URLs in firmware, so the endpoint outlives the release process
  • Legacy systems hard-code integrations that nobody on the client side owns anymore

Budget the deprecated version’s maintenance as a standing line item that runs until its usage actually reaches zero.

The Test Matrix Multiplies

Breaking changes multiply testing requirements instead of adding to them. Three live API versions, three SDK generations, and four response formats already produce 36 combinations to cover, and each new version scales the entire product. The practical limit on how many versions you keep alive is usually pipeline time.

Documentation Drift Creates Hidden Dependencies

When the documentation for an older version stops being updated, clients start depending on behavior nobody wrote down. That produces:

  • Reliance on undocumented responses that a “bug fix” then breaks
  • Feature flags added after the fact to restore the old behavior
  • Development overhead for legacy semantics that never appear in the spec

Version Discovery Is Critical

// One endpoint that tells clients what exists, what is stable, and what is going away
app.get('/api', (req, res) => {
  res.json({
    versions: {
      v1: {
        status: 'deprecated',
        sunset_date: '2025-01-15',
        docs: 'https://docs.api.com/v1',
        migration_guide: 'https://docs.api.com/v1-to-v2',
      },
      v2: {
        status: 'stable',
        docs: 'https://docs.api.com/v2',
      },
      v3: {
        status: 'beta',
        docs: 'https://docs.api.com/v3',
        breaking_changes: 'https://docs.api.com/v3-breaking-changes',
      },
    },
    current_stable: 'v2',
    recommended: 'v2',
    your_version: detectVersion(req),  // What the client is using
  });
});

Operational Considerations

Each live version carries a recurring cost, and the costs are not all in the infrastructure bill:

  • Infrastructure: one Lambda set and one API Gateway resource tree per live version
  • Development: a cross-version feature is implemented and reviewed once per version
  • Testing: the pipeline runs the full version matrix before every release
  • Documentation: each version needs its own reference plus a migration guide to the next one
  • Support: version confusion is a standing ticket category, which a discovery endpoint absorbs most of

Implementation Recommendations

  1. Design for versioning from the first release - Retrofitting a version segment after clients depend on the contract is far more expensive than reserving it up front
  2. Bundle breaking changes - Collect related changes so that a quarter of small edits produces one version bump
  3. Automate migration tooling - Build the client migration script before the deprecation notice goes out
  4. Plan sunset timelines around the slowest client - Enterprise and public-sector consumers need migration windows measured in quarters
  5. Implement usage tracking from day one - Per-version usage is the only evidence that makes a sunset decision defensible

If you’re starting fresh, use this structure:

/api
  /v1
    /users
    /orders
    /internal/health
  /v2
    /users
    /orders
    /internal/health
  /versions (discovery endpoint)
  /health (version-agnostic)

Keep your Lambda code organized by version:

/src
  /handlers
    /v1
      /users
      /orders
    /v2
      /users
      /orders
  /shared
    /database
    /auth
    /utils

Conclusion

Path-based versioning with a lifecycle registry holds when consumers deploy on cadences you do not control and the contract will keep changing. It is the wrong default for an internal API whose only consumers ship from the same pipeline you do; there, a single evolving endpoint plus consumer-driven contract tests is cheaper and catches breakage earlier. It is also the wrong default for additive changes: an optional new field does not need a version, and shipping one anyway trains clients to ignore your version numbers.

Two decisions determine everything downstream: where the version lives in the URL, and what the registry records about each version. The handlers, alarms, and migration scripts all follow from those two, so make them before the first version ships.

References

Related posts