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 the most common in practice. Header versioning is ideal for Stripe-style 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.
I've watched this exact scenario play out at three different companies. The root cause is always the same: the API has multiple clients that deploy on their own schedule. You cannot coordinate a synchronized update across mobile apps, third-party integrations, partner systems, and internal services. API versioning creates a contract: "v1 clients will always get v1 responses until you choose to migrate."
What Is It?
API versioning is a strategy for maintaining multiple behavioral contracts for the same API simultaneously, so 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
Let me walk through a single request to show how versioning works end-to-end, using the most common 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.
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. The most widely adopted approach. Simple to route at the gateway, visible in logs and metrics, and requires no special client configuration.
The downside: it violates REST semantics (the same resource shouldn't exist at two URLs), and it encourages version proliferation without cleanup. I've seen codebases with /v1/ through /v7/ where nobody remembers which versions have active clients.
Used by: GitHub REST API, Twilio, Google Cloud APIs.
Header Versioning (Stripe Model)
GET /users/123
API-Version: 2024-01-01
Version specified in a request header. Stripe pioneered the date-based variant: clients pin to the API behavior date they tested against. Stripe maintains backward compatibility by default but lets clients opt into newer behavior by setting a later date.
This is elegant for APIs that evolve incrementally. Instead of major version jumps, each API change gets a date, and clients move forward at their own pace. Stripe maintains over 100 dated API versions simultaneously.
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, Shopify (date-based), Microsoft Azure (some services).
Content Negotiation
GET /users/123
Accept: application/vnd.myapi.v2+json
Content-Type: application/vnd.myapi.v2+json
Version embedded in the media type. The most REST-pure approach: clients advertise the representation format they want, servers respond with what they support.
In practice, very few public APIs use this. The syntax is cumbersome, client libraries don't support it natively, and developers find it surprising. I'd recommend it only for internal APIs in organizations that enforce strict content type contracts.
GraphQL's Approach: No Versions
GraphQL takes a fundamentally different stance: don't version at all. Instead, evolve the schema additively. Add new fields freely. Mark old fields as @deprecated with a reason. Clients query only the fields they need, so adding new fields never breaks 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: you can never remove a field. @deprecated is a hint, not enforcement. Fields accumulate forever unless you have tooling to detect unused fields and aggressive deprecation policies.
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 | Very high | Growing (Stripe effect) | Rare | Growing |
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.