API Versioning Without Breaking Existing Clients
Learn how to evolve REST APIs safely using backward compatibility, API versions, expand-and-contract migrations, deprecation policies, and compatibility testing.
27 min read
A Small API Change That Broke an Old App
Imagine our Order API has returned this response for a long time:
{
"id": "ord_123",
"status": "paid",
"total": 12500
}Here:
total = 12500means:
$125.00because the value is stored in minor units.
But the field name is not very clear.
total does not tell us:
Is this dollars?
Is this cents?
Which currency is this?So we improve the API:
{
"id": "ord_123",
"status": "paid",
"totalMinor": 12500,
"currency": "USD"
}This response is much clearer.
Our web frontend is deployed together with the API, so we update it:
order.totalMinor;Everything works.
But an older mobile app still contains:
order.total;Now it receives:
undefined;The checkout confirmation breaks.
A partner integration might also still expect:
{
"total": 12500
}and their nightly job fails too.
The API change took a few minutes.
But clients may take:
days
weeks
monthsto update.
This is the real reason API versioning exists.
API versioning is not mainly about writing:
/v1
/v2
/v3It is about this question:
How can we change an API without unexpectedly breaking clients that upgrade on their own schedule?
The First Rule: Do Not Create v2 Too Quickly
Before creating a new version, ask:
Can the old and new behavior safely exist together?If yes, prefer a backward-compatible change.
For example, instead of removing total immediately:
{
"id": "ord_123",
"status": "paid",
"totalMinor": 12500,
"currency": "USD"
}we can temporarily return:
{
"id": "ord_123",
"status": "paid",
"total": 12500,
"totalMinor": 12500,
"currency": "USD"
}Now:
Old clients
→ continue reading total
New clients
→ use totalMinor + currencyThis gives consumers time to migrate.
What Is an API Contract?
Developers often think the contract is only the URL.
For example:
GET /orders/123But clients depend on much more than that.
The API contract includes:
URL
HTTP method
request body
response body
field names
field types
nullability
default values
status codes
errors
validation behavior
authorization behavior
pagination
sorting
filtering
idempotency behavior
rate limitsAll of these are observable behavior.
For example, suppose the API currently returns:
200 OKwith:
{
"items": []
}when no orders exist.
Later we change it to:
404 Not FoundEven though the URL did not change, this may still break clients.
A frontend may contain:
if (response.status === 200) {
renderOrders(response.data.items);
}The API contract changed.
TypeScript Does Not Protect Your API at Runtime
Suppose we define:
interface OrderResponse {
id: string;
status: string;
totalMinor: number;
}This helps developers while writing TypeScript.
But TypeScript disappears when the application runs.
A client can still send:
{
"totalMinor": "hello"
}or omit required data entirely.
That is why public APIs usually need runtime validation.
For example with Zod:
const orderSchema = z.object({
id: z.string(),
status: z.string(),
totalMinor: z.number().int(),
});Think of it like this:
TypeScript
→ protects developers while coding
Runtime schema
→ protects the actual API boundaryIs the Change Actually Breaking?
Before creating a new version, classify the change.
Usually Safe Changes
Examples:
Add a new endpoint
Add an optional response field
Add an optional request field
Add metadataExample:
Before:
{
"id": "ord_123",
"status": "paid"
}After:
{
"id": "ord_123",
"status": "paid",
"createdAt": "2026-09-22T10:00:00Z"
}Usually, old clients simply ignore createdAt.
But Even Additive Changes Can Be Dangerous
Suppose the status type was:
type OrderStatus = "pending" | "paid" | "cancelled";The client might write:
switch (order.status) {
case "pending":
return showPending();
case "paid":
return showPaid();
case "cancelled":
return showCancelled();
default:
throw new Error("Unknown status");
}Now the server adds:
refundingNothing was removed.
But the old application may crash.
So adding an enum value is not always completely safe.
Common Breaking Changes
These are much more likely to require a new version.
Renaming a Field
Before:
{
"total": 12500
}After:
{
"totalMinor": 12500
}Old clients still read:
order.total;So this is breaking.
Removing a Field
Before:
{
"id": "ord_123",
"status": "paid",
"total": 12500
}After:
{
"id": "ord_123",
"status": "paid"
}Any consumer using total breaks.
Changing a Type
Before:
{
"total": 12500
}After:
{
"total": "125.00"
}The name stayed the same.
But the type changed:
number
→ stringThis is breaking.
Changing Meaning
This can be even more dangerous.
Imagine:
{
"total": 125
}Originally this means:
$125Later someone decides it means:
125 centsThe type is still number.
The field name is still total.
But the meaning changed.
This is a breaking contract change.
Making an Optional Field Required
Before:
{
"customerId": "cus_1"
}Maybe currency was optional.
Later:
{
"customerId": "cus_1",
"currency": "USD"
}and the API rejects requests without currency.
Old clients now fail validation.
Tightening Validation
Suppose this was previously accepted:
{
"name": "A"
}Then the server changes validation to:
z.string().min(3);Old requests that worked yesterday may now fail.
So tighter validation can also be a breaking change.
A Practical Compatibility Table
| Change | Usually safe? |
|---|---|
| Add optional response field | Usually |
| Add optional request field | Usually |
| Add new endpoint | Usually |
| Add enum value | Maybe |
| Rename field | No |
| Remove field | No |
| Change field type | No |
| Change field meaning | No |
| Make optional input required | No |
| Change error behavior | Usually no |
| Change pagination behavior | Usually no |
| Tighten validation | Often no |
The important word is:
usuallyCompatibility depends on how real consumers use the API.
Use Expand-and-Contract Before Creating v2
One of the safest migration strategies is:
Expand
↓
Migrate
↓
ContractSuppose we want:
total
→ totalMinor + currencyInstead of changing everything immediately:
Step 1
Add new fields
Step 2
Keep old field
Step 3
Update clients
Step 4
Measure old usage
Step 5
Deprecate old field
Step 6
Remove it laterThe response temporarily becomes:
{
"id": "ord_123",
"status": "paid",
"total": 12500,
"totalMinor": 12500,
"currency": "USD"
}This is called expand-and-contract.
When Expand-and-Contract Is Not Enough
Sometimes both meanings cannot safely exist.
Imagine v1 defines:
total = major currency unitsFor example:
{
"total": 125
}means:
$125But the new system wants:
total = minor currency unitswhere:
{
"total": 125
}would mean:
$1.25Using the same field with two meanings would be dangerous.
That is a strong reason for a new contract.
For example:
/v1/orders
/v2/ordersAPI Versioning Strategies
There are several common approaches.
1. URL Versioning
Example:
POST /v1/orders
POST /v2/ordersThis is probably the easiest approach to understand.
Advantages:
Easy to see
Easy to log
Easy to debug
Easy to document
Easy for gateways
Easy for clientsFor example:
POST /v1/ordersclearly tells us which contract the client wants.
Main Risk of URL Versioning
Teams sometimes do this:
controllers/v1
services/v1
repositories/v1
controllers/v2
services/v2
repositories/v2and copy the entire application.
Now we effectively have:
Application V1
Application V2That creates duplicated business logic.
Later:
security fix
fraud rule
pricing change
bug fixmust be applied to both versions.
This is usually a bad design.
We will fix this later.
2. Header Versioning
Another approach is:
POST /orders
API-Version: 2The URL remains:
/ordersand the version comes from the header.
Another common style is media types:
Accept: application/vnd.codewithfaisal.order.v2+jsonAdvantages:
Stable URL
Version separated from resource pathBut debugging becomes less obvious.
If someone sees:
POST /ordersthey still need to inspect headers.
Your:
gateway
cache
logs
traces
documentation
SDKsmust also understand the version header.
3. Query Parameter Versioning
Example:
POST /orders?version=2This is simple to implement.
But it can look like an ordinary filter instead of an API contract decision.
For public APIs, URL or header versions are often clearer.
4. Date-Based Versioning
Some APIs use dates:
API-Version: 2026-09-22The date represents a contract snapshot.
This can work well for APIs that evolve frequently.
But documentation becomes very important.
Consumers must know:
What changed before this date?
What changed after this date?
What behavior belongs to this version?Which Versioning Strategy Should You Use?
A reasonable starting point:
| API type | Common approach |
|---|---|
| Public REST API | URL or header/date |
| Mobile backend | Additive changes first, then URL version |
| Partner API | Explicit versions with long migration windows |
| Internal services | Compatibility first |
| Completely different workflow | Often a new endpoint/resource |
For this guide we will use:
/v1/orders
/v2/ordersbecause it is easy to understand and operate.
Version the API Boundary, Not the Whole Application
This is one of the most important ideas.
Do not create:
V1 Controller
→ V1 Service
→ V1 Repository
V2 Controller
→ V2 Service
→ V2 RepositoryThat duplicates the system.
Instead:
V1 request
↓
V1 mapper
┐
│
├──→ Shared CreateOrderService
│
V2 mapper
↑
V2 requestThen:
Shared service result
↓
┌─────────┐
│ │
V1 presenter V2 presenterOnly the public API boundary knows about versions.
The business logic remains shared.
One Shared CreateOrder Use Case
Our application can use one internal command:
type CreateOrderCommand = {
tenantId: string;
customerId: string;
currency: "USD" | "PKR";
items: Array<{
sku: string;
quantity: number;
}>;
};And one internal result:
type CreatedOrder = {
id: string;
status: "pending";
totalMinor: number;
currency: "USD" | "PKR";
};Notice what is missing:
v1
v2
HTTP
ExpressThe business use case should not care which HTTP contract called it.
Shared Repository Contract
interface OrderRepository {
save(
order: CreatedOrder & {
tenantId: string;
customerId: string;
},
): Promise<void>;
}Product pricing also stays independent of API versions:
interface ProductCatalog {
getUnitPriceMinor(
tenantId: string,
sku: string,
currency: string,
): Promise<number>;
}Shared CreateOrderService
class CreateOrderService {
constructor(
private readonly orders: OrderRepository,
private readonly catalog: ProductCatalog,
) {}
async execute(command: CreateOrderCommand): Promise<CreatedOrder> {
let totalMinor = 0;
for (const item of command.items) {
const unitPriceMinor = await this.catalog.getUnitPriceMinor(
command.tenantId,
item.sku,
command.currency,
);
totalMinor += unitPriceMinor * item.quantity;
}
const order = {
id: crypto.randomUUID(),
customerId: command.customerId,
tenantId: command.tenantId,
status: "pending" as const,
totalMinor,
currency: command.currency,
};
await this.orders.save(order);
return order;
}
}This service knows:
how an order is createdIt does not know:
whether request came from v1
whether request came from v2That is the boundary's responsibility.
Version-Specific Request Schemas
Suppose v1 looks like this:
{
"customerId": "cus_1",
"items": [
{
"sku": "book",
"quantity": 2
}
]
}v1 only supports USD.
So currency is not included.
v1 Schema
import { z } from "zod";
const itemSchema = z.object({
sku: z.string().min(1).max(80),
quantity: z.number().int().min(1).max(100),
});
const createOrderV1Schema = z
.object({
customerId: z.string().min(1),
items: z.array(itemSchema).min(1).max(100),
})
.strict();v2 Request
Now v2 supports multiple currencies and a different customer shape:
{
"customer": {
"id": "cus_1"
},
"currency": "PKR",
"items": [
{
"sku": "book",
"quantity": 2
}
]
}Its schema becomes:
const createOrderV2Schema = z
.object({
customer: z
.object({
id: z.string().min(1),
})
.strict(),
currency: z.enum(["USD", "PKR"]),
items: z.array(itemSchema).min(1).max(100),
})
.strict();We now have two public contracts.
But both should map to the same internal command.
Map v1 to the Internal Command
function toCommandV1(
body: z.infer<typeof createOrderV1Schema>,
tenantId: string,
): CreateOrderCommand {
return {
tenantId,
customerId: body.customerId,
currency: "USD",
items: body.items,
};
}Because v1 does not accept currency:
v1
→ defaults to USDMap v2 to the Same Command
function toCommandV2(
body: z.infer<typeof createOrderV2Schema>,
tenantId: string,
): CreateOrderCommand {
return {
tenantId,
customerId: body.customer.id,
currency: body.currency,
items: body.items,
};
}Now both versions produce:
CreateOrderCommand;The application service does not need two implementations.
Version-Specific Responses
The shared service returns:
{
id,
status,
totalMinor,
currency,
}But v1 expects:
{
"id": "ord_123",
"status": "pending",
"total": 5000
}So create a presenter:
function presentOrderV1(order: CreatedOrder) {
return {
id: order.id,
status: order.status,
total: order.totalMinor,
};
}v2 Presenter
v2 returns:
{
"id": "ord_123",
"status": "pending",
"totalMinor": 5000,
"currency": "USD"
}Presenter:
function presentOrderV2(order: CreatedOrder) {
return {
id: order.id,
status: order.status,
totalMinor: order.totalMinor,
currency: order.currency,
};
}Now the old name:
totalexists only at the API boundary.
It does not pollute our internal domain model.
The Full Flow
The request flow now looks like:
POST /v1/orders
↓
Validate V1 schema
↓
toCommandV1()
↓
CreateOrderService
↓
CreatedOrder
↓
presentOrderV1()
↓
V1 responseAnd:
POST /v2/orders
↓
Validate V2 schema
↓
toCommandV2()
↓
CreateOrderService
↓
CreatedOrder
↓
presentOrderV2()
↓
V2 responseThe important part:
Same business use case
Same domain behavior
Same repository
Different API boundariesExpress Handler Example
We can support both versions with one handler factory:
import type { NextFunction, Request, Response } from "express";
import { ZodError, type ZodType } from "zod";
type AuthenticatedRequest = Request & {
auth: {
userId: string;
tenantId: string;
};
};
function parseBody<T>(schema: ZodType<T>, body: unknown): T {
return schema.parse(body);
}Now create the handler:
function createOrderHandler(
version: "v1" | "v2",
createOrder: CreateOrderService,
) {
return async (
request: AuthenticatedRequest,
response: Response,
next: NextFunction,
) => {
try {
const tenantId = request.auth.tenantId;
const command =
version === "v1"
? toCommandV1(parseBody(createOrderV1Schema, request.body), tenantId)
: toCommandV2(parseBody(createOrderV2Schema, request.body), tenantId);
const order = await createOrder.execute(command);
const body =
version === "v1" ? presentOrderV1(order) : presentOrderV2(order);
response
.status(201)
.location(`/${version}/orders/${order.id}`)
.json(body);
} catch (error) {
next(error);
}
};
}Then register both routes:
app.post("/v1/orders", createOrderHandler("v1", createOrder));
app.post("/v2/orders", createOrderHandler("v2", createOrder));Keep Security Outside Client-Controlled Fields
Notice this:
const tenantId = request.auth.tenantId;We do not do:
const tenantId = request.body.tenantId;because the client should not be able to choose another tenant.
Authentication middleware provides trusted identity data.
That rule should remain consistent across API versions.
Validation Errors Are Part of the Contract Too
Suppose validation fails.
We can return:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request does not match this API version.",
"requestId": "req_01JXYZ"
}
}For example:
app.use(
(
error: unknown,
request: Request,
response: Response,
next: NextFunction,
) => {
if (error instanceof ZodError) {
response.status(422).json({
error: {
code: "VALIDATION_FAILED",
message: "The request does not match this API version.",
requestId: response.locals.requestId,
details: error.flatten(),
},
});
return;
}
next(error);
},
);A client may depend on:
422
VALIDATION_FAILED
error.detailsSo changing the error shape can also be breaking.
Unsupported Versions Should Fail Clearly
Suppose header versioning is used.
The client sends:
API-Version: 3but only versions 1 and 2 exist.
Do not silently fall back to v2.
Return something predictable:
HTTP/1.1 400 Bad Request
Content-Type: application/json{
"error": {
"code": "UNSUPPORTED_API_VERSION",
"message": "API version 3 is not supported.",
"requestId": "req_01JXYZ"
}
}Why?
Because the client explicitly requested:
version 3 semanticsExecuting the request as version 2 could produce behavior the client did not expect.
API Versioning and Database Versioning Are Different
This distinction is extremely important.
These:
/v1/orders
/v2/ordersdo not mean you need:
orders_v1 table
orders_v2 tableBoth APIs can use the same current database schema.
The API boundary can translate the data.
Database Migration Example
Suppose the database currently has:
totaland we want:
total_minor
currencyDo not immediately run:
DROP COLUMN total;Instead, expand first.
ALTER TABLE orders
ADD COLUMN total_minor bigint;
ALTER TABLE orders
ADD COLUMN currency text;Now the database can temporarily support both old and new application versions.
Why This Matters During Deployment
Suppose deployment works like:
Server A
→ old application
Server B
→ new application
Server C
→ new applicationDuring a rolling deployment, old code and new code may run at the same time.
If the migration immediately removes fields required by the old application:
old server
→ crashesSo database compatibility matters even when every external client is already using v2.
Safe Database Expand-and-Contract
A safer sequence is:
1. Add new nullable columns.
2. Deploy code that writes
old + new columns.
3. Backfill existing rows.
4. Verify the migrated data.
5. Change reads to new columns.
6. Add NOT NULL constraints.
7. Stop writing old column.
8. Remove old column later.For example:
ALTER TABLE orders
ALTER COLUMN total_minor
SET NOT NULL,
ALTER COLUMN currency
SET NOT NULL;But only after old rows have been backfilled.
API Migration and Database Migration Can Happen Separately
The architecture may look like:
V1 API ─┐
│
├→ Application
│ ↓
V2 API ─┘ Current DB schemaThe API contract is one migration concern.
The database schema is another.
Do not unnecessarily tie them together.
Deprecation Is More Than Writing "Deprecated"
Suppose we want to retire:
/v1/ordersAdding this to documentation:
Deprecatedis not enough.
Consumers need:
What changed?
When should I migrate?
What replaces v1?
When will v1 stop working?
How do I test v2?
Who do I contact if migration fails?Deprecation is an operational process.
Deprecation Headers
A v1 response can include information like:
Deprecation: @1798761600
Sunset: Tue, 30 Mar 2027 23:59:59 GMT
Link: </docs/migrations/orders-v2>; rel="deprecation"This helps tools and clients detect deprecated APIs.
But headers alone are not enough.
You should also provide:
release notes
migration guide
emails or partner communication
SDK documentation
support informationWhat Should a Migration Guide Explain?
For example:
V1:
{
"customerId": "cus_1"
}
V2:
{
"customer": {
"id": "cus_1"
}
}Also explain changes such as:
total
→ totalMinor
currency
→ now required
validation
→ changed
error codes
→ changed
SDK version
→ required
sunset date
→ March 30, 2027The easier the migration is to understand, the fewer clients will remain on the old version.
Measure Version Usage Before Removing It
Suppose we plan to remove v1.
Do not only look at the calendar:
"Sunset date passed.
Delete it."Measure real usage.
For example:
api_requests_total{
version="v1",
route="POST /orders",
status="201"
}and:
api_requests_total{
version="v2",
route="POST /orders",
status="201"
}Then we can answer:
How much traffic still uses v1?
Which endpoints are still used?
Are v1 errors increasing?
When was v1 last called?Be Careful With Metric Cardinality
Do not create Prometheus labels like:
customerId
userId
requestIdif they have millions of possible values.
That can create high-cardinality metrics.
Good metric dimensions:
version
route
statusFor exact client identity, use:
structured logs
analytics
database recordsinstead.
Contact Important Consumers
Imagine metrics show:
v1 traffic = 0.5%That sounds tiny.
But that 0.5% could be:
one important enterprise customeror:
one payroll integrationSo do not rely only on percentages.
Combine:
sunset policy
+
usage metrics
+
known consumer identity
+
direct communicationbefore removing a version.
Test Every Supported Version
If your API supports:
v1
v2then both must be tested.
Not just v2.
For example:
describe.each([
{
path: "/v1/orders",
body: {
customerId: "cus_1",
items: [
{
sku: "book",
quantity: 2,
},
],
},
expected: {
status: "pending",
total: 5000,
},
},
{
path: "/v2/orders",
body: {
customer: {
id: "cus_1",
},
currency: "USD",
items: [
{
sku: "book",
quantity: 2,
},
],
},
expected: {
status: "pending",
totalMinor: 5000,
currency: "USD",
},
},
])(
"preserves the $path contract",
async ({ path, body, expected }) => {
const response = await request(app)
.post(path)
.set("Authorization", validTenantToken)
.send(body);
expect(response.status).toBe(201);
expect(response.body).toMatchObject(expected);
},
);This verifies:
Current server
still supports
old clientsWhat Else Should Compatibility Tests Check?
Do not test only the happy response.
Also test:
required fields
optional fields
nullable fields
validation errors
status codes
error codes
authorization
tenant isolation
idempotency
pagination
sorting
deprecation headers
unsupported versionsThese are all part of the contract.
Test Old Requests Against the New Server
A very useful technique is keeping examples of old real requests.
For example:
{
"customerId": "cus_1",
"items": [
{
"sku": "book",
"quantity": 1
}
]
}After backend changes, run this old request against the current server.
If it unexpectedly fails, CI should catch the regression before deployment.
Consumer-Driven Contract Testing
Suppose you have a few known consumers:
Web frontend
Mobile app
Billing service
Partner serviceEach consumer can define expectations such as:
POST /v1/orders
must return:
id
status
totalThe provider then verifies those contracts.
This is useful when the consumers are known.
But be careful.
Consumer contracts should describe public behavior.
They should not freeze private implementation details.
OpenAPI Should Be Version-Aware
Every supported version should have an authoritative API description.
For example:
openapi/
orders-v1.yaml
orders-v2.yamlOr one OpenAPI document with clearly separated versioned paths.
For example:
paths:
/v1/orders:
post: ...
/v2/orders:
post: ...CI should validate these contracts.
It can also detect accidental breaking changes.
SDK Versions and API Versions Are Different
This is another common source of confusion.
Suppose:
Server supports:
API v1
API v2but:
SDK v3.0.0supports only API v2.
These numbers describe different things.
You should clearly document:
API version
SDK versionso consumers know whether they need to:
upgrade their SDK
change the API version
or bothAPI Versioning Also Affects Caches
Suppose versioning happens through headers:
GET /orders/123
API-Version: 1and:
GET /orders/123
API-Version: 2The URL is identical.
But the responses may differ.
A cache must not return the v1 response to a v2 client.
Cache keys need to include the selected version.
For HTTP caches, this may involve:
Vary: API-Versiondepending on the design.
URL versions make this easier because:
/v1/orders/123
/v2/orders/123are already different cache keys.
Events Need Versioning Too
REST APIs are not the only contracts.
Suppose we publish:
order.placedwith:
{
"orderId": "ord_123",
"total": 12500
}Later we silently change:
totalto mean something different.
Old event consumers may break.
For durable events, prefer explicit versions such as:
order.placed.v1
order.placed.v2or use schema evolution rules that guarantee compatibility.
This is especially important because events may be replayed much later.
Webhooks Are Contracts Too
Suppose your service sends a webhook:
POST https://partner.com/webhooks/orderwith:
{
"orderId": "ord_123",
"status": "paid"
}The receiving partner controls their own deployment schedule.
So webhook changes require the same thinking as API changes.
Preserve:
payload fields
event IDs
signatures
retry behavior
delivery semanticsA new REST API version does not automatically mean a new webhook version.
Feature Flags Are Not API Versions
Suppose:
50% clients
→ old behavior
50% clients
→ new behaviorduring a rollout.
A feature flag is useful for temporary rollout.
But if different clients permanently depend on both behaviors:
old behavior forever
new behavior foreverthen you effectively created two contracts.
That should be represented explicitly rather than hidden behind a permanent feature flag.
Common API Versioning Mistakes
Mistake 1: Creating v2 for Every Small Change
For example:
Add optional field
→ v2
Add another optional field
→ v3
Add endpoint
→ v4Soon you have:
v1
v2
v3
v4
v5with very little difference.
Every version increases:
documentation cost
testing cost
support cost
security maintenance
operational complexityPrefer backward-compatible evolution when possible.
Mistake 2: Copying the Whole Application
Bad:
v1/
controller
service
repository
v2/
controller
service
repositoryNow bug fixes must happen twice.
Better:
v1 schema ─┐
├→ Shared application service
v2 schema ─┘Version the boundary.
Not the whole business system.
Mistake 3: Thinking TypeScript Is Runtime Validation
This:
type CreateOrderRequest = {
customerId: string;
};does not stop a real HTTP client from sending:
{
"customerId": 123
}Use runtime validation.
For example:
z.object({
customerId: z.string(),
});Mistake 4: Assuming Internal Services Always Deploy Together
You might think:
"It's internal.
We do not need compatibility."But internal consumers may include:
cron jobs
workers
queues
mobile backends
reporting jobs
other services
cached applicationsThey may deploy independently.
Internal APIs still have contracts.
Mistake 5: Changing Errors Without Thinking
Suppose old API returns:
409 Conflictwith:
{
"error": {
"code": "ORDER_ALREADY_EXISTS"
}
}Then someone changes it to:
400 Bad Requestwith:
{
"message": "Duplicate order"
}A client may contain:
if (error.code === "ORDER_ALREADY_EXISTS") {
showExistingOrder();
}That client now breaks.
Errors are part of the API contract.
Mistake 6: Supporting Versions Forever
Compatibility is important.
But supporting:
v1
v2
v3
v4
v5
v6forever also creates problems.
Every active version may require:
security patches
tests
documentation
monitoring
supportSo define a support policy.
For example:
Each major API version
supported for 18 months.The exact policy depends on your product and customers.
The important part is that consumers know it.
A Safe Migration From total to totalMinor
Let's put everything together.
We want to migrate:
{
"total": 12500
}to:
{
"totalMinor": 12500,
"currency": "USD"
}A safe process could be:
1. Decide whether the change
can initially be additive.
2. Add database columns:
total_minor
currency
3. Write both old and new
database representations.
4. Backfill existing records.
5. Add totalMinor + currency
to existing response if safe.
6. Publish /v2.
7. Update first-party clients.
8. Update SDKs.
9. Publish migration guide.
10. Mark v1 deprecated.
11. Add sunset date.
12. Measure v1 usage.
13. Contact remaining consumers.
14. Remove v1.
15. Remove old database fields
in a later deployment.Notice that this does not require:
server
mobile app
web app
partner API
databaseto all change at exactly the same moment.
That is the goal.
Decision Guide
Add an optional response field
Example:
{
"createdAt": "..."
}Prefer:
existing versionAdd an optional request field
Prefer:
existing versionif there is a safe default.
Rename a field
Example:
total
→ totalMinorPrefer:
return both temporarily
then remove old field
in a later versionChange a field's meaning
Prefer:
new versionbecause keeping the same field name would be misleading.
Change pagination model
Example:
offset pagination
→ cursor paginationThis may require:
new versionor a new endpoint contract.
Completely Different Workflow
Suppose:
POST /ordersbecomes a completely different business process.
Sometimes the right answer is not:
/v2/ordersbut a new resource.
For example:
/subscriptionsor:
/payment-intentsVersioning should not be used to hide a fundamentally different concept.
A Simple Mental Model
When you want to change an API, ask these questions in order.
1. Is this externally observable?If no:
change it internallyIf yes, continue.
2. Will existing clients continue
working unchanged?If yes:
prefer backward-compatible evolutionIf no:
3. Can old + new behavior
safely coexist temporarily?If yes:
use expand-and-contractIf no:
create a new explicit contract/versionThen ask:
4. How will clients migrate?
5. How will we know who still uses
the old version?
6. When can we safely remove it?That is the full API versioning lifecycle.
Production Checklist
Before changing a production API:
□ Identify whether the change
is externally observable.
□ Classify it as compatible
or breaking.
□ Prefer additive changes.
□ Use runtime request validation.
□ Keep old and new API translations
at the boundary.
□ Share domain and business logic.
□ Avoid copying the whole application
for each version.
□ Treat errors as part of the contract.
□ Treat pagination and ordering
as part of the contract.
□ Keep authorization consistent.
□ Keep idempotency behavior consistent.
□ Maintain OpenAPI for
supported versions.
□ Test all supported versions.
□ Replay old requests against
the current server.
□ Make caches version-aware.
□ Track traffic by API version.
□ Avoid high-cardinality
metric labels.
□ Publish migration documentation.
□ Publish deprecation dates.
□ Publish sunset dates.
□ Contact important remaining consumers.
□ Keep security fixes available
while a version is supported.
□ Remember webhooks and events.
□ Migrate database schemas separately.
□ Remove old versions deliberately.Conclusion
Good API versioning does not begin with:
"Should this be /v2?"It begins with:
"Will this change break an existing client?"If the answer is no, evolve the existing contract.
If the answer is yes, first check whether the old and new behavior can temporarily coexist.
Use:
expand
→ migrate
→ measure
→ deprecate
→ contractwhen possible.
When a true breaking change requires a new version, keep version-specific code at the API boundary:
V1 request
↓
V1 mapper
┐
├→ Shared business logic
┘
V2 mapper
↑
V2 requestDo not build two separate business systems.
And remember that versioning does not end when /v2 launches.
A complete versioning lifecycle is:
Design
→ release
→ support
→ measure
→ deprecate
→ migrate consumers
→ sunset
→ removeThat is how APIs evolve without turning a normal backend deployment into an outage for somebody else's application.
For the surrounding contract decisions—HTTP semantics, error shapes, idempotency, caching, and compatibility testing—see the production-ready REST API guide.
References
Related
Written by
Faisal
Software engineer writing about backend systems, Node.js, system design, scalable applications, and modern web and mobile development.