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
- 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
- Bundle breaking changes - Collect related changes so that a quarter of small edits produces one version bump
- Automate migration tooling - Build the client migration script before the deprecation notice goes out
- Plan sunset timelines around the slowest client - Enterprise and public-sector consumers need migration windows measured in quarters
- Implement usage tracking from day one - Per-version usage is the only evidence that makes a sunset decision defensible
Recommended CDK Pattern
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
- Implement path-based API versioning by using custom domains in Amazon API Gateway - AWS Prescriptive Guidance pattern for routing requests to different API versions via custom domain path prefixes.
- Deploy REST APIs in API Gateway - Official API Gateway guide on deploying REST APIs to stages and managing version lifecycles.
- REL03-BP03 Provide service contracts per API - AWS Well-Architected Framework - Well-Architected guidance on contract versioning strategies that allow consumers to migrate at their own pace.
- AWS CDK versioning - AWS Cloud Development Kit (AWS CDK) v2 - Official CDK versioning semantics including alpha/beta construct lifecycle and backward compatibility guarantees.
- Best practices for developing and deploying cloud infrastructure with the AWS CDK - CDK best practices covering construct reuse, configuration patterns, and deployment strategies for production stacks.
- Best practices for using the AWS CDK in TypeScript to create IaC projects - AWS Prescriptive Guidance on TypeScript-specific CDK patterns including version control and release management for constructs.
- Deploying Lambda functions with AWS CDK - Official tutorial for deploying Lambda-backed APIs using AWS CDK with TypeScript.
Related posts
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.
Same-account, the resource policy and the caller's identity policy are an OR. Cross-account they become an AND, and silence denies. What that changes in the perimeter.