Why API Versioning Matters
APIs are contracts between your service and its consumers. Once published, breaking changes — renaming fields, changing types, removing endpoints — break existing clients. API versioning lets you evolve your API without breaking existing integrations. Stripe’s API versioning is the gold standard: clients pin to a version; breaking changes are released as new versions; old versions are supported for years. Understanding the trade-offs between URL versioning, header versioning, and date-based versioning is a common system design topic.
Versioning Strategies
URL versioning — version in the path: /v1/users, /v2/users
GET /v1/users/123 → {"id": 123, "full_name": "John Doe"}
GET /v2/users/123 → {"id": "usr_123", "name": {"first": "John", "last": "Doe"}}
Pros: explicit, cacheable, easy to route. Cons: URL pollution; clients must update URLs when migrating.
Header versioning: Accept: application/vnd.myapi.v2+json or custom header X-API-Version: 2
Pros: clean URLs. Cons: harder to test in browser, not cached differently by CDN unless Vary header is set.
Date-based versioning (Stripe’s approach): Stripe-Version: 2023-10-16
# Client sends their pinned version date Stripe-Version: 2023-10-16 # Server applies the schema/behavior for that date # New customers default to the latest version; existing customers keep their pinned version
Pros: granular; clients see exactly what they integrated against. Cons: complex to maintain many date-versions.
Data Model for Version Management
ApiVersion(
version VARCHAR PRIMARY KEY, -- 'v1', 'v2', '2023-10-16'
status ENUM(ACTIVE, DEPRECATED, SUNSET),
released_at TIMESTAMPTZ,
deprecated_at TIMESTAMPTZ,
sunset_at TIMESTAMPTZ, -- date after which requests return 410 Gone
changelog TEXT
)
ClientApiVersion(
client_id UUID,
api_key VARCHAR,
pinned_version VARCHAR,
updated_at TIMESTAMPTZ
)
Request Routing by Version
# Middleware: extract version from URL or header, attach to request context
@app.middleware
def version_middleware(request, call_next):
# URL versioning: /v2/users → version='v2'
version = extract_from_path(request.url.path)
# Header versioning fallback
if not version:
version = request.headers.get('X-API-Version')
# Default to latest stable version
if not version:
version = get_latest_stable_version()
# Check sunset
api_version = db.get(ApiVersion, version)
if api_version.status == 'SUNSET':
return Response(410, 'This API version has been sunset. Please upgrade.')
# Deprecation warning header
if api_version.status == 'DEPRECATED':
request.state.deprecation_warning = (
f'API version {version} is deprecated and will be sunset on '
f'{api_version.sunset_at}. Please upgrade.'
)
request.state.api_version = version
response = call_next(request)
if hasattr(request.state, 'deprecation_warning'):
response.headers['Deprecation'] = request.state.deprecation_warning
response.headers['Sunset'] = api_version.sunset_at.isoformat()
return response
Version-Specific Response Transformation
# Handler returns internal model; serializer applies version-specific transformation
def get_user(user_id, api_version):
user = db.get_user(user_id)
if api_version == 'v1':
return {
'id': user.id, # integer in v1
'full_name': user.name # flat name in v1
}
elif api_version >= 'v2':
return {
'id': f'usr_{user.id}', # prefixed string ID in v2
'name': { # structured name in v2
'first': user.first_name,
'last': user.last_name
},
'created_at': user.created_at.isoformat()
}
Deprecation and Migration Strategy
- Release v2 alongside v1 — both serve traffic simultaneously
- Announce deprecation of v1: add Deprecation and Sunset response headers
- Email clients using v1 with migration guide and sunset date (minimum 6-12 months)
- Monitor v1 traffic — track which client_ids still use the old version
- On sunset date: return 410 Gone with a message pointing to the migration guide
- After sunset: remove v1 code after another 3-6 months (keep routing to return 410)
Key Design Decisions
- URL versioning for public APIs — explicit and easy to understand for third-party developers
- Date-based for partner APIs — enables per-client pinned versions, fine-grained changelog
- Sunset headers (RFC 8594) — machine-readable deprecation enables automated client alerts
- Internal model separate from response schema — versioned serializers transform one internal model to multiple response shapes
- Never break without versioning — backwards-compatible changes (adding fields) don’t require a new version
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”What are the main API versioning strategies and their trade-offs?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”URL versioning (/v1/users, /v2/users): explicit, easy to route, cacheable, recommended for public APIs. Header versioning (Accept: application/vnd.api.v2+json): clean URLs, but harder to test in browser. Date-based versioning (Stripe-Version: 2023-10-16): clients pin to a specific date; any change after that date doesn’t affect them; very stable but complex to maintain. URI versioning is the most widely adopted for third-party APIs; date-based is used by Stripe and Twilio for very stable partner integrations.”}},{“@type”:”Question”,”name”:”How do you handle backwards-compatible vs breaking API changes?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Backwards-compatible changes don’t require a new version: adding new optional fields to a response, adding new optional request parameters, adding new endpoints. Breaking changes require a new version: removing or renaming fields, changing field types, changing HTTP status codes, removing endpoints, changing authentication schemes. The rule: existing client code must continue to work without modification. If a change could break existing clients, it’s a breaking change and needs versioning.”}},{“@type”:”Question”,”name”:”How does Stripe implement date-based API versioning?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Every Stripe API change that would break existing clients is assigned a version date. When a client first creates an API key, their default version is set to the current date. They can pin to that version forever. When Stripe releases a breaking change (e.g., 2023-10-16: "invoice.lines is now a list object"), clients on older versions see the old behavior; new clients see the new behavior. Clients control when to migrate by upgrading their pinned version date in the dashboard after testing against the new behavior.”}},{“@type”:”Question”,”name”:”How do you deprecate and sunset an API version?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Follow a formal deprecation lifecycle: (1) Release the new version alongside the old. (2) Announce deprecation with a sunset date (minimum 6 months for external APIs, 12+ months for widely-used ones). (3) Add Deprecation and Sunset response headers to all requests on the deprecated version. (4) Email API consumers who are still using the deprecated version. (5) On the sunset date, return 410 Gone. (6) Remove the deprecated code 3-6 months after sunset while keeping the routing to return 410 Gone.”}},{“@type”:”Question”,”name”:”How do you implement version-specific response transformation?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a serializer/transformer layer that converts your internal domain model to a version-specific response schema. The handler returns a domain object; middleware selects the correct serializer based on the API version in the request context. Keep serializers small and focused: v1_user_serializer, v2_user_serializer. Avoid version conditionals scattered throughout your handlers — centralizing transformation in serializers keeps the business logic clean and version handling explicit.”}}]}
API versioning and backward compatibility design is discussed in Stripe system design interview questions.
API versioning and deprecation strategy is covered in Shopify system design interview preparation.
API versioning and public API design is discussed in Atlassian system design interview guide.
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering