API versioning
Strategies for evolving APIs without breaking clients, including URI versioning, Stripe's date-based model, content negotiation, and the compatibility rules that let you ship changes safely.
TL;DR
- API versioning lets you evolve an API over time without forcing all clients to update simultaneously.
- Three main strategies: URI versioning (
/v1/,/v2/), header versioning (Stripe'sAPI-Version: 2024-01-01), and content negotiation (Accept: application/vnd.api+v2+json). - URI versioning is widely used in practice. Header versioning can fit date-based APIs with many incremental changes.
- Backward-compatible changes (add optional fields, add new endpoints) ship without a version bump. Breaking changes (rename/remove fields, change types) require a new version.
- The real challenge is not picking a strategy but managing the lifecycle: routing, deprecation, monitoring, and eventual sunset.
The Problem It Solves
Your team ships a REST API for a mobile app. Version 1.0 of the app hits GET /users/123 and expects a response with a name field (a single string). Six months later, product requirements change: you need to split name into firstName and lastName.
You deploy the change on a Friday afternoon. The new API returns { "firstName": "Jane", "lastName": "Doe" } and removes the old name field. Monday morning, your support queue explodes. Every user running an older version of the mobile app (which is 70% of your install base, because mobile update cycles are slow) sees a blank name field. The app was reading response.name, which is now undefined.
The root cause is a coordination problem: different clients deploy on different schedules. Mobile apps, third-party integrations, partner systems, and internal services cannot usually be updated in one synchronized release. API versioning creates a contract: "v1 clients should continue to receive v1 behavior while that version is supported."
What Is It?
API versioning is a strategy for maintaining multiple behavioral contracts for the same API during a migration, so supported old clients continue working while new clients use updated functionality.
Think of it like a restaurant menu. When the chef redesigns the menu (new dishes, reorganized categories, different prices), they don't yank the old menus out of customers' hands mid-meal. Customers who already ordered keep their existing menu. New customers get the new menu. And the kitchen can serve both, because each order references the menu version the customer was reading.
The same principle applies to APIs. Old clients continue calling the old contract. New clients opt into the new contract. The server knows which contract each request expects and responds accordingly.
How It Works
The following request shows one end-to-end approach: URI versioning with gateway routing.
Step 1. Client sends a request with the version identifier (in the URL, header, or media type).
Step 2. The API gateway inspects the version and routes to the correct backend. For URI versioning, this is simple path-based routing. For header versioning, the gateway inspects the API-Version header.
Step 3. The versioned backend reads from the same database but transforms the response into the format that version's contract promises. The v1 service concatenates first_name + last_name into a single name field. The v2 service returns them separately. The database migration must remain compatible with every active version.
Step 4. Client receives a response matching the contract it was written against.
// Gateway routing config (nginx example)
// /v1/* β upstream_v1
// /v2/* β upstream_v2
// v1 handler: maintains backward compatibility
app.get('/v1/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
name: `$\{user.first_name\} ${user.last_name}`, // v1 contract
email: user.email,
});
});
// v2 handler: new contract with split name fields
app.get('/v2/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
firstName: user.first_name, // v2 contract
lastName: user.last_name,
email: user.email,
});
});
Key Components
| Component | Role |
|---|---|
| Version identifier | The signal in each request that declares which API contract the client expects (URL prefix, header, media type). |
| API gateway / router | Inspects the version identifier and routes to the appropriate backend or transformer. |
| Versioned handler | The code that implements a specific version's contract, reading shared data but shaping responses differently. |
| Compatibility rules | The documented list of what constitutes a breaking vs. non-breaking change, used to decide when a new version is required. |
| Deprecation policy | Timeline and process for sunsetting old versions: announcement, sunset headers, usage monitoring, eventual removal. |
| Version usage metrics | Per-version, per-client request counts that inform deprecation decisions and migration priority. |
| Migration guide | Documentation that tells clients how to move from version N to version N+1, including field mappings and behavioral changes. |
Types / Variations
URI Versioning
GET /v1/users/123
GET /v2/users/123
Version in the URL path. A widely used approach: simple to route at the gateway, visible in logs and metrics, and requiring no special client configuration.
The downside: it duplicates resource paths and can encourage version proliferation without cleanup. A long-lived API should track which versions have active clients and retire old ones deliberately.
Used by: Many public REST APIs, including APIs that have historically used explicit path versions such as Twilio and Google Cloud services.
Header Versioning (Stripe Model)
GET /users/123
API-Version: 2024-01-01
Version specified in a request header. Stripe is a well-known example of the date-based variant: clients pin to the API behavior date they tested against. The provider maintains backward compatibility for pinned clients while allowing an explicit opt-in to newer behavior.
This is useful for APIs that evolve incrementally. Instead of major version jumps, each contract change can receive a date, and clients move forward at their own pace. The provider must still operate and test many historical behaviors.
The downside: versions are invisible in URLs, so you can't test in a browser by changing the URL, and log/metric enrichment requires explicit header extraction.
Used by: Stripe and some other APIs that use date-based or header-based contracts.
Content Negotiation
GET /users/123
Accept: application/vnd.myapi.v2+json
Content-Type: application/vnd.myapi.v2+json
Version embedded in the media type. This aligns closely with HTTP content negotiation: clients advertise the representation they want and servers respond with a supported representation.
In practice, fewer public APIs use this approach. The syntax can be cumbersome, client libraries may not support it directly, and developers may find it surprising. It is a reasonable fit when representation negotiation is already a strong organizational convention.
GraphQL's Approach: No Versions
GraphQL often takes a different stance: evolve the schema additively instead of using
coarse-grained API versions. Add new fields, mark old fields as @deprecated with a
reason, and let clients query only the fields they need. Adding a field then does not
break existing queries.
type User {
id: ID!
name: String @deprecated(reason: "Use firstName and lastName")
firstName: String!
lastName: String!
}
This works because GraphQL clients explicitly declare their data requirements. A client querying { user { name } } keeps working even after firstName and lastName are added. The server knows exactly which fields each client depends on.
The downside: removing a field still requires evidence that no supported client uses it.
@deprecated is a hint, not enforcement, so fields can accumulate without tooling that
detects unused fields and a deliberate deprecation policy.
Strategy Comparison
| Dimension | URI versioning | Header versioning | Content negotiation | GraphQL (no versions) |
|---|---|---|---|---|
| Visibility | Obvious in logs/URLs | Hidden in headers | Hidden in headers | N/A |
| Gateway routing | Simple path match | Header inspection | Media type inspection | Schema introspection |
| Client complexity | Change URL prefix | Add/change header | Set Accept header | Query only needed fields |
| REST purity | Low (duplicate URLs) | Medium | High | N/A (different paradigm) |
| Granularity | Major versions only | Per-change (date) | Per-representation | Per-field |
| Adoption | Widespread | Common for some public APIs | Less common | Widespread in GraphQL APIs |
In an interview, a concise choice is: "I would use URI versioning for a public REST API when simple routing and visibility matter. I would consider header or date-based versioning when the API has frequent incremental changes and sophisticated clients."
Backward-Compatible vs. Breaking Changes
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.
Related Articles
Learn what an API Gateway is, how it works, its trade-offs, and how to explain it in a system design interview.
Learn how microservices decompose monolithic applications into independently deployable services, when the operational overhead is worth it, and how to manage the distributed-systems failure modes that follow.