Versioning & Evolution
Evolve your API without breaking existing clients — URL versioning, header versioning, backward compatibility, and deprecation strategies.
Why Version?
APIs evolve. You will need to rename fields, change response formats, or remove endpoints. Without a versioning strategy, every change risks breaking existing clients.
Real-World Analogy
Like a phone OS update — the old version still works for a while, but new features are only on v2. The maker doesn’t break everyone’s apps overnight; they deprecate gradually.
Versioning Strategies
1. URL Path Versioning
The most common and most visible approach:
// Version in the URL path
GET /api/v1/users
GET /api/v2/users
// Express implementation
import v1Router from "./routes/v1";
import v2Router from "./routes/v2";
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
// routes/v1/users.ts
router.get("/users", async (req, res) => {
const users = await db.users.find();
// v1 returns flat structure
res.json(users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
})));
});
// routes/v2/users.ts
router.get("/users", async (req, res) => {
const users = await db.users.find();
// v2 returns envelope with split name
res.json({
data: users.map((u) => ({
id: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
createdAt: u.createdAt,
})),
meta: { total: users.length },
});
}); Pros: Easy to understand, visible in URLs, easy to route Cons: Duplicates code, hard to maintain many versions
2. Header Versioning
Version is specified in a custom header or the Accept header:
// Custom header
GET /api/users
X-API-Version: 2
// Accept header (content negotiation)
GET /api/users
Accept: application/vnd.myapi.v2+json
// Implementation
app.get("/api/users", async (req, res) => {
const version = getApiVersion(req);
const users = await db.users.find();
if (version === 1) {
return res.json(formatUsersV1(users));
}
res.json(formatUsersV2(users));
});
function getApiVersion(req: express.Request): number {
// Check custom header first
const headerVersion = req.headers["x-api-version"];
if (headerVersion) return Number(headerVersion);
// Check Accept header
const accept = req.headers.accept || "";
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
if (match) return Number(match[1]);
// Default to latest
return 2;
} Pros: Clean URLs, no code duplication Cons: Less visible, harder to test in browser
3. Query Parameter Versioning
// Version in query string
GET /api/users?version=2
// Simple but not recommended for production APIs Pros: Easy to switch versions for testing Cons: Mixes concerns in query params, caching issues
Which Strategy to Choose?
- URL versioning is the best default choice for public APIs — it is explicit, cacheable, and easy to understand
- Header versioning works well for internal APIs and when you want clean URLs
- Query parameter versioning is fine for quick prototypes but not for production
- Most major APIs (Stripe, GitHub, Twilio) use URL versioning
Backward-Compatible Changes
Not every change needs a new version. These changes are backward-compatible:
// SAFE — Adding new fields (clients ignore unknown fields)
// v1 response
{ "id": 1, "name": "Rahim" }
// Updated response — still compatible with v1 clients
{ "id": 1, "name": "Rahim", "avatar": "https://..." }
// SAFE — Adding new endpoints
GET /api/users/:id/preferences // New endpoint, does not affect existing ones
// SAFE — Adding optional query parameters
GET /api/users?include=orders // New filter, existing calls without it still work
// SAFE — Making a required field optional
// Before: name was required
// After: name is optional with a default
// BREAKING — Removing a field
{ "id": 1, "name": "Rahim" } // "email" was here before, clients depend on it
// BREAKING — Renaming a field
{ "id": 1, "full_name": "Rahim" } // Was "name", clients break
// BREAKING — Changing a field's type
{ "id": "user_1" } // Was a number, now a string
// BREAKING — Removing an endpoint
// DELETE /api/legacy-users — clients still call this Deprecation Strategy
When you need to retire an old version:
// Step 1: Mark endpoints as deprecated with headers
app.use("/api/v1", (req, res, next) => {
res.set("Deprecation", "true");
res.set("Sunset", "Sat, 01 Jun 2026 00:00:00 GMT");
res.set("Link", '</api/v2>; rel="successor-version"');
// Log deprecated usage for monitoring
metrics.increment("api.v1.deprecated_call", {
endpoint: req.path,
client: req.headers["user-agent"],
});
next();
});
// Step 2: Return warnings in response body
function deprecationWrapper(handler: express.RequestHandler): express.RequestHandler {
return async (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body: unknown) => {
if (typeof body === "object" && body !== null) {
(body as Record<string, unknown>)._warnings = [
{
code: "DEPRECATED_VERSION",
message: "API v1 is deprecated. Please migrate to v2 by June 2026.",
docs: "https://docs.myapi.com/migration/v1-to-v2",
},
];
}
return originalJson(body);
};
handler(req, res, next);
};
}
// Step 3: After sunset date, return 410 Gone
app.use("/api/v1", (req, res) => {
res.status(410).json({
error: {
code: "VERSION_RETIRED",
message: "API v1 has been retired. Please use v2.",
migrationGuide: "https://docs.myapi.com/migration/v1-to-v2",
},
});
}); Migration Guide Pattern
Always provide a clear migration guide when introducing a new version:
// Document every breaking change
const migrationGuide = {
from: "v1",
to: "v2",
changes: [
{
type: "field_renamed",
endpoint: "GET /users",
before: { field: "name", type: "string" },
after: { fields: ["firstName", "lastName"], type: "string" },
migration: "Split the 'name' field on space, or use the new fields directly",
},
{
type: "response_wrapped",
endpoint: "ALL",
before: "Direct array/object response",
after: "Wrapped in { data, meta } envelope",
migration: "Access response.data instead of response directly",
},
{
type: "field_type_changed",
endpoint: "GET /users",
before: { field: "id", type: "number" },
after: { field: "id", type: "string", format: "user_<number>" },
migration: "Update ID comparisons to use string matching",
},
],
}; Common Versioning Mistakes
- Versioning too early — you do not need v2 for backward-compatible changes
- Versioning too late — once clients depend on a behavior, it is a contract
- Maintaining too many versions — support at most 2 (current + previous)
- Not monitoring deprecated endpoint usage — you need data to know when it is safe to retire
Key Takeaways
- URL path versioning is the standard for public APIs — explicit and cacheable
- Additive changes are safe — new fields, new endpoints, new optional parameters
- Removing or renaming fields is breaking — always requires a new version or a migration period
- Deprecation is a process — announce, warn, sunset, then retire
- Support at most two versions at a time to keep maintenance manageable
- Monitor deprecated endpoint usage to know when it is safe to remove old versions