API Versioning Strategies: URL vs Header vs Content Negotiation
1. Prologue — "I changed the API and everything blew up"
Classic startup scene.
A backend dev improves the response structure. Instead of { "user_name": "kim" }, they ship { "user": { "name": "kim", "id": 123 } }. Cleaner, more extensible. They deploy.
That afternoon: iOS app crashes. The mobile dev was accessing response.user_name directly. Android crashes. A partner integration breaks. Slack explodes.
That's exactly why API versioning exists.
An API is a contract. Once you publish an API, you can't freely change it — clients depend on it. But services need to evolve. Versioning resolves the tension between those two requirements.
2. When Do You Actually Need to Version?
Not every change requires a version bump.
Backward-Compatible Changes (no version bump needed)
- Adding new fields
- Adding new endpoints
- Adding optional parameters
- Changing error message text
Breaking Changes (version bump required)
- Renaming or removing existing fields
- Changing response structure (nested → flat)
- Changing field types (
string → number)
- Changing endpoint paths
- Changing authentication methods
- Making previously optional parameters required
Rule of thumb: if existing clients continue working without code changes, keep the version. If they can't, you need a new version.
3. The Four Versioning Strategies
Strategy 1: URL Path Versioning
The most intuitive and widely used approach.
GET /api/v1/users
GET /api/v2/users
POST /api/v1/orders
POST /api/v2/orders
Real example — GitHub REST API:
# GitHub API v3 (current)
curl https://api.github.com/repos/facebook/react
# GitHub currently runs REST v3 + GraphQL side by side
Pros:
- Instantly visible. The version is right there in the URL.
- Browser bookmarks and history separate cleanly by version.
- Server routing is simple — split routers by version prefix.
- Debugging is easy — version shows up in every log line.
- CDN/proxy-level routing by version is straightforward.
Cons:
- URIs are supposed to identify resources. Putting a version in the URI makes "two representations of the same resource" look like different resources. REST purists hate this.
- Hard to migrate existing clients — they keep using v1 URLs.
- Paths get longer.
Implementation (Express):
// src/routes/v1/users.ts
const v1Router = express.Router();
v1Router.get('/users', async (req, res) => {
const users = await getUsersV1();
res.json(users); // { user_name: string, user_email: string }
});
// src/routes/v2/users.ts
const v2Router = express.Router();
v2Router.get('/users', async (req, res) => {
const users = await getUsersV2();
res.json(users); // { user: { name: string, email: string, id: number } }
});
// app.ts
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Implementation (Next.js App Router):
src/app/api/
├── v1/
│ ├── users/route.ts
│ └── orders/route.ts
└── v2/
├── users/route.ts
└── orders/route.ts
Strategy 2: Query Parameter Versioning
Version goes in the query string, not the path.
GET /api/users?version=1
GET /api/users?version=2
GET /api/users?v=2
Real example — Amazon AWS APIs:
# AWS API with date-based version in query parameter
https://ec2.amazonaws.com/?Action=DescribeInstances&Version=2016-11-15
Pros:
- Easy to set a default. No version param? Fall back to latest (or v1).
- The endpoint path itself never changes.
- Easy selective application — specific endpoints can have different versions.
Cons:
- Caching gets tricky. CDNs may or may not treat different query strings as different resources.
- Business parameters and meta-parameters (version) are mixed at the same level.
- Forgetting the version param accidentally invokes an unintended version.
Implementation:
app.get('/api/users', async (req, res) => {
const version = req.query.version ?? '1';
if (version === '2') {
const users = await getUsersV2();
return res.json(users);
}
// Default: v1
const users = await getUsersV1();
res.json(users);
});
Strategy 3: Custom Request Header Versioning
Version information lives in an HTTP header.
GET /api/users
X-API-Version: 2
GET /api/users
API-Version: 2026-01-01
Real example — Stripe:
# Stripe uses a date-based version in a custom header
curl https://api.stripe.com/v1/charges \
-H "Stripe-Version: 2024-04-10" \
-u sk_test_xxx:
Stripe's approach is particularly clever. They use dates, not integers. Every change has a clear point on a timeline, and there's no ambiguity about "is v3 newer than v2."
Pros:
- URIs stay clean. Closer to REST principles.
- Version logic is decoupled from routing logic.
- Easy to apply different middleware per version.
Cons:
- Can't test directly in a browser address bar (need curl or Postman).
- Requires fallback logic when clients omit the header.
- CDN/proxy routing based on headers is more complex to configure.
- Must log headers to see version info in logs.
Implementation:
// Version extraction middleware
const extractVersion = (req: Request, res: Response, next: NextFunction) => {
const version = req.headers['x-api-version']
?? req.headers['api-version']
?? '1';
req.apiVersion = String(version);
next();
};
app.use(extractVersion);
app.get('/api/users', async (req, res) => {
if (req.apiVersion === '2') {
return res.json(await getUsersV2());
}
res.json(await getUsersV1());
});
// TypeScript type extension
declare global {
namespace Express {
interface Request {
apiVersion: string;
}
}
}
Strategy 4: Accept Header (Content Negotiation)
Version info is encoded in the MIME type of the HTTP standard Accept header.
GET /api/users
Accept: application/vnd.myapp.v2+json
GET /api/users
Accept: application/vnd.github.v3+json
Real example — GitHub:
# GitHub uses Accept header for version and format control
curl https://api.github.com/repos/facebook/react \
-H "Accept: application/vnd.github.v3+json"
# Requesting a different representation
curl https://api.github.com/repos/facebook/react \
-H "Accept: application/vnd.github.raw+json"
vnd stands for "vendor" — an IANA-standard prefix indicating a vendor-specific media type.
Pros:
- Most faithful to HTTP standards. Roy Fielding (creator of REST) recommends this.
- The same URI can return different representations (versions, formats).
- Theoretically the most "RESTful."
Cons:
- Developer experience is poor. You can't paste the URL in a browser.
- Media type strings are long and annoying to type.
- Awkward to test and debug.
- Client library support varies.
- Almost nobody uses it in practice (theoretically superior, practically awkward).
Implementation:
app.get('/api/users', async (req, res) => {
const accept = req.headers['accept'] ?? '';
if (accept.includes('application/vnd.myapp.v2+json')) {
res.setHeader('Content-Type', 'application/vnd.myapp.v2+json');
return res.json(await getUsersV2());
}
// Default: v1
res.setHeader('Content-Type', 'application/vnd.myapp.v1+json');
res.json(await getUsersV1());
});
4. Comparison Table
| Criteria | URL Path | Query Param | Custom Header | Accept Header |
|---|
| Visibility | Excellent | Good | Low | Low |
| REST purity | Low | Low | Medium | Excellent |
| Developer experience | Excellent | Good | Average | Poor |
| Caching friendliness | Good | Needs care | Complex | Complex |
| Browser testable | Yes | Yes | No | No |
| Real-world adoption | High | Medium | High | Low |
| Used by | GitHub REST, Twilio | AWS | Stripe | GitHub (partial) |
5. What Real APIs Actually Choose
GitHub
Mix of URL Path (/v3/) and Accept Header. REST API uses /v3/, GraphQL has its own endpoint (/graphql). Accept header controls response format.
Stripe
Custom Header (Stripe-Version) + URL Path. https://api.stripe.com/v1/ has v1 in the URL, but real versioning is controlled by the Stripe-Version date header. The best-known example of date-based versioning.
# Stripe — omit the header and your account's default version is used
curl https://api.stripe.com/v1/customers \
-u sk_test_xxx: \
-H "Stripe-Version: 2024-04-10"
Twilio
Textbook URL Path versioning, but using dates: /2010-04-01/.
# Twilio — date in URL path acts as version
curl https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages \
-u AccountSid:AuthToken
Twitter/X API
URL Path versioning: /1.1/, /2/. v2 was a complete redesign, so major version numbers make sense.
6. Deprecation Strategy
You ship a new version. Eventually you have to retire the old one. Pulling the plug without warning breaks clients.
Sunset Header
Standardized by IETF RFC 8594. Add a header to responses communicating when the version will be shut down.
// Attach deprecation headers to all v1 responses
app.use('/api/v1', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
res.setHeader(
'Link',
'<https://api.example.com/v2>; rel="successor-version"'
);
next();
});
Deprecation Warning in Response Body
interface V1Response<T> {
data: T;
_deprecation?: {
message: string;
sunset_date: string;
migration_guide: string;
};
}
const wrapV1Response = <T>(data: T): V1Response<T> => ({
data,
_deprecation: {
message: 'API v1 is deprecated. Please migrate to v2.',
sunset_date: '2026-12-31',
migration_guide: 'https://docs.example.com/migration/v1-to-v2',
},
});
Realistic Deprecation Timeline
- Announcement (Day 0): Ship new version, announce deprecation timeline (minimum 6 months, ideally 12)
- Warning phase (Day 0 to Sunset): Deprecated version responses include warning headers and messages
- Traffic monitoring: Watch old version usage decline to acceptable levels
- Sunset (Sunset Date): Return 410 Gone, redirect to migration guide
// After sunset
app.use('/api/v1', (req, res) => {
res.status(410).json({
error: 'API v1 has been sunset',
message: 'Please upgrade to v2',
documentation: 'https://docs.example.com/v2',
migration_guide: 'https://docs.example.com/migration/v1-to-v2',
});
});
7. Versioning Best Practices
1. Version only major changes. Don't bump the version for minor additions. v1, v2, v3 is enough. v1.1, v1.2 creates management overhead.
2. Maximize backward compatibility. The fewer breaking changes you ship, the less often clients need to migrate. Adding fields and optional parameters doesn't require a new version.
3. Be explicit about the default version. Document what version gets used when no version is specified. Usually the latest stable version.
4. Maintain a changelog per version:
## API Changelog
### v2 (2026-01-15)
**Breaking Changes:**
- `user_name` → `user.name` (nested object)
- `user_email` → `user.email`
**New Features:**
- Added `user.id` field
- Added `/users/:id/preferences` endpoint
### v1 (2025-01-01)
- Initial release
- Sunset date: 2027-01-15
5. Match your team's reality. A theoretically perfect strategy that your team can't maintain is worse than a "good enough" strategy executed consistently. B2B SaaS? Consider Stripe-style date-based header versioning. Public API? URL path is hard to beat.
8. Conclusion
There's no single right answer. There's the right answer for your context.
- Public REST API / B2C: URL Path (
/v1/, /v2/) — visibility is paramount
- B2B SaaS / Partner APIs: Custom Header (Stripe-style) — date-based, granular change management
- Internal API / Microservices: URL Path or Header — follow team convention
- Experimental / Prototype APIs: Query Parameter — easy fallback behavior
The most important things: pick a strategy, apply it consistently, and define your deprecation policy before you ship v1. The moment you publish an API, you've signed a contract.