
Most API versioning advice answers the wrong question. The debate over URL versioning versus header versioning consumes the discussion, and it is the least consequential decision you will make.
The consequential ones:
What you consider a breaking change — and whether your definition matches your consumers'
Whether you can add without versioning — which determines how often you need a new version at all
Your deprecation policy — written, published, and honoured before you need it
How you know who is still on the old version — you cannot deprecate what you cannot measure
The recommended default for most products: URL versioning at a coarse grain (/v1/, /v2/), additive changes within a version, a published deprecation window of twelve months minimum, and per-consumer usage telemetry from day one.
You should expect to ship v2 rarely — every year or two at most. If you are versioning frequently, the problem is that your changes are breaking when they did not need to be.
Creating a new API version is a weekend of work. Routing, a duplicated controller layer, updated docs.
Retiring the old one takes eighteen months and a customer success budget.
That asymmetry is the whole subject. A version you cannot retire is not a version — it is a permanent maintenance obligation. Every bug fix must be applied to both. Every security patch. Every schema change underneath must keep both contracts satisfied. Two versions is roughly 1.6× the maintenance cost of one, and three is worse than 2.5×, because the interaction surface grows faster than the count.
The teams that handle this well are not the ones with the cleverest versioning scheme. They are the ones who:
Change additively, so new versions are rare
Published a deprecation policy before their first customer integrated
Can name every consumer still calling a deprecated endpoint
Have actually retired a version, at least once, and know the process works
The rest accumulate versions the way a codebase accumulates feature flags — permanently, and with nobody willing to be the person who deletes one.
This is the most important section here, and it is where most teams get it wrong — usually by being too permissive early and then discovering their definition was not shared by their consumers.
Removing an endpoint, field, or query parameter
Renaming anything in a request or response
Changing a field's type — string to int, scalar to array
Adding a required request field or a new required parameter
Making validation stricter — a value that previously passed now fails
Changing an HTTP status code for an existing condition
Changing the meaning of a field while keeping its name and type
Changing default sort order or default page size
Changing error response structure
Adding a new endpoint
Adding an optional request field
Adding a new field to a response (with a caveat below)
Adding a new enum value (with a significant caveat below)
Relaxing validation — accepting inputs previously rejected
Performance improvements that do not change semantics
Adding a response field. Non-breaking in principle. Breaking in practice for any consumer using strict deserialisation that rejects unknown fields — common in strongly-typed clients and in generated SDKs configured conservatively.
Resolution: state explicitly in your documentation that clients must tolerate unknown fields, and publish that before your first integration. It is a contract term, and if it is not stated up front you cannot rely on it later.
Adding an enum value. This is the one that bites hardest. A consumer with a switch statement over status values will hit an unhandled case when you add partially_refunded. Their code does not fail gracefully — it throws, or worse, silently falls through.
Resolution: document from day one that enums are open, and that clients must handle unknown values. Better still, avoid bare enums in responses for anything likely to grow. Return an object with a stable code plus a display label, so unknown codes degrade to displaying the label rather than crashing.
Changing a rate limit or quota. Technically not a contract change. Practically, a consumer whose batch job runs nightly at their previous limit will fail the night you tighten it.
Resolution: treat quota reductions as breaking changes with the same notice period. Increases are fine.
Would a reasonable consumer's existing code stop working, or start behaving differently, without them changing anything?
If yes, it is breaking — regardless of what the specification technically permits. Consumers do not read your reasoning; they experience the outage.
Covered briefly, because this is the part every other article covers at length and it matters least.
URL path ( | Header ( | Media type ( | |
|---|---|---|---|
Visible in logs and analytics | Yes | Requires config | Requires config |
Cacheable by default | Yes | Needs | Needs |
Easy to test in a browser | Yes | No | No |
Granularity | Whole API | Whole API or per-resource | Per-resource |
Discoverability | High | Low | Low |
Purity by REST doctrine | Low | Medium | High |
Use URL versioning unless you have a specific reason not to. It is visible, cacheable, debuggable, and every consumer understands it immediately. The theoretical objections are real and almost never outweigh the operational benefits.
The date-based variant is worth considering for products with frequent, small contract changes. Instead of /v1/ and /v2/, consumers pin a date — API-Version: 2026-08-01 — and you ship dated revisions, each a small delta from the last. Consumers upgrade one step at a time rather than facing a monolithic v1-to-v2 migration.
The trade-off: dated versioning requires infrastructure to transform requests and responses between versions, and that transformation layer is real engineering. It suits platforms with many external consumers and continuous contract evolution. For most products, coarse URL versioning is the right amount of machinery.
The single most useful technique here, and it is the same pattern as the zero-downtime column rename from our database design guide.
Most changes that feel breaking can be made additively, in sequence, without a new version.
Naive: rename customer_name to customer_full_name. Breaking. Requires v2.
Expand-and-contract:
Expand. Add customer_full_name alongside customer_name. Both populated, identical values. Nothing breaks.
Announce. Document customer_name as deprecated with a retirement date.
Observe. Track which consumers still read the old field. You will need per-field telemetry to do this — see below.
Contact. Reach out directly to remaining consumers as the date approaches.
Contract. Remove customer_name at the announced date.
Same outcome, no version bump, and the consumers migrate on their own schedule rather than yours.
Add the new field with the new type under a new name
Populate both, deprecate the old one
Remove the old field after the deprecation window
Add the field as optional, with a documented default
Warn in the response — a Warning header, or a deprecations array in the payload — when it is omitted
Track omission rate per consumer
Make it required only when omissions reach zero
Add the new endpoint
Keep the old one, implemented as a translation over the new model
Deprecate the old endpoint
Remove it after the window
The principle: a breaking change is usually an additive change plus a removal, and those two things do not have to happen on the same day. Separating them by a deprecation window converts a forced migration into a voluntary one.
Teams that adopt this ship a new major version every few years rather than every few quarters, which is the actual goal.
Almost no API documentation includes a deprecation policy, and almost every API eventually needs one. Writing it before your first integration is the highest-leverage hour in this entire subject.
The notice period, by consumer tier. Twelve months is a defensible default for public APIs. Six months for partner integrations with a commercial relationship. Thirty days for internal consumers. Enterprise contracts frequently negotiate longer — know your longest commitment.
What triggers the clock. Deprecation announced in the changelog, in the response headers, and by direct email to affected consumers. The clock starts at the last of these, not the first.
How deprecation is signalled in-band. The Deprecation and Sunset HTTP headers exist for this and are under-used. A consumer's monitoring can alert on them, which reaches an engineer far more reliably than a changelog entry does.
What happens at sunset. Does the endpoint return 410 Gone, or start failing with a specific error? Say which, so consumers can code for it.
Whether there are exceptions. Security vulnerabilities may require faster removal. State that up front rather than invoking it unannounced.
Before final removal, disable the deprecated endpoint for short, announced windows — an hour, then a day, at intervals.
This is deliberately noisy. It surfaces consumers who ignored every email, and it does so while the endpoint still exists and can be turned back on. Far better to discover a critical integration during a scheduled one-hour brownout than at permanent sunset.
Announce brownouts in advance. The goal is to make silence expensive for the consumer, not to cause an incident.
You cannot deprecate what you cannot measure. Before any deprecation, you need to answer: which consumers called this endpoint or read this field in the last thirty days, and how often?
That requires per-consumer, per-endpoint request logging with the API key or client identifier attached, and ideally field-level read tracking for response deprecations — achievable with sparse fieldsets or GraphQL, harder with plain REST.
Without it, deprecation becomes an announcement into the void followed by an outage you did not predict. Build the telemetry before you need it, because retrofitting it means waiting another thirty days for usable data before you can start any deprecation clock.
The engineering is easy. This part is not, and it is mostly not engineering.
Direct, individual contact. A changelog entry reaches nobody. An email to the technical contact of each affected account, naming the specific endpoints they use and the specific date, reaches someone. Segment the list by usage volume and start with the heaviest.
A migration guide, not a diff. Consumers do not want a list of changes. They want: here is what you are calling, here is what to call instead, here is the code. Write it per common integration pattern rather than per endpoint.
A dual-running period. Both versions live simultaneously, so consumers can migrate endpoint by endpoint rather than in one cutover. This is the difference between a two-hour job and a project someone has to schedule.
A sandbox against the new version. Consumers will not migrate what they cannot test.
Escalating in-band warnings. Deprecation headers first. Then a Warning header. Then brownouts. Each step is louder than the last and each is announced.
Assuming consumers read documentation. They integrated once, eighteen months ago, and have not looked since. The engineer who wrote it may have left.
Announcing only in a changelog. Nobody subscribes to your changelog. The people who most need to know are the least engaged.
A short window with a hard cutoff. It generates escalations, and you will extend it under pressure — which teaches every consumer that your dates are negotiable, making the next deprecation harder.
Silent removal. Whatever the temptation with an endpoint that appears unused, verify with telemetry first. "Appears unused" and "unused" differ by exactly one critical nightly batch job.
Rarely quantified, which is why breaking changes get approved too easily.
Item | Effort |
|---|---|
Building and testing the new version | 10 – 30 engineer-days |
Maintaining both versions during the window | ~60% of one version's maintenance, ongoing |
Migration guide and updated documentation | 3 – 8 days |
Consumer outreach and support | 5 – 20 days, spread over months |
Telemetry and monitoring for the migration | 3 – 6 days |
Total | 25 – 65 engineer-days plus a year of dual maintenance |
At India blended rates that is roughly $7,000 to $19,000 in direct engineering, before the dual-maintenance drag — which is the larger number over twelve months.
This is the one that matters commercially and is almost never considered.
Every consumer must schedule engineering work they did not plan, to receive no new functionality. For a mid-sized integration that is two to ten engineer-days each. Across fifty consumers that is 100 to 500 engineer-days of other people's time that you caused.
The commercial consequence is real. A forced migration is the moment a customer re-evaluates whether to keep integrating with you. It is not a neutral event — it is a competitive opening, and it lands on the technical stakeholder rather than the commercial one.
The practical conclusion: the bar for a breaking change should be considerably higher than "the new design is cleaner." Cleanliness is worth something. It is rarely worth 300 engineer-days of your customers' time and a procurement conversation you did not want.
The right answer changes substantially with who consumes the API.
You control every consumer and can deploy them. Versioning is often unnecessary — coordinate the change and deploy together.
Exception: if services deploy independently, you cannot coordinate, and you need at minimum a compatibility window. This is precisely the constraint discussed in our monolith versus microservices guide — independent deployment means every internal API is a contract with a consumer you cannot deploy in lockstep, which is one of the permanent costs of splitting.
Recommendation: expand-and-contract with a short window, measured in weeks. No formal version numbers needed.
You know every consumer by name and have a commercial relationship with each.
Recommendation: URL versioning, a six-month deprecation window, direct outreach, and dual-running. Telemetry per partner is straightforward at this scale and you should have it.
You do not know who is calling, what they built, or whether the original engineer still works there.
Recommendation: URL versioning, a twelve-month minimum window, deprecation headers, brownouts before sunset, and telemetry that identifies consumers by key. Assume nobody reads announcements and design your signalling accordingly.
A public API is a much larger commitment than most teams appreciate at launch. Before publishing one, be certain the contract is one you can live with for several years — because retiring it is genuinely hard, and every consumer you gain increases the cost of the eventual change.
The technical practice that makes all of the above enforceable rather than aspirational.
The problem it solves: you can review a change and believe it is non-breaking. You cannot verify that belief across every consumer's actual usage by reading code.
What it looks like in practice:
Schema-first with automated diffing. Define the contract as OpenAPI or an equivalent, and run a diff on every pull request. Tools exist that classify each change as breaking or non-breaking against a ruleset. A PR introducing a breaking change fails CI rather than reaching review.
This is the single highest-return practice in this article and it is comparatively cheap to set up — a day or two for most codebases. It converts "we try not to break things" into a mechanically enforced rule.
Consumer-driven contract tests. Each consumer publishes the subset of the API they actually depend on. Your CI runs against the union of those expectations. A change breaking any consumer fails before deployment.
Genuinely excellent for internal and partner APIs. Impractical for public APIs, since unknown consumers cannot publish expectations.
Recorded traffic replay. Capture real production requests, replay them against the candidate build, diff the responses. Catches changes a schema diff misses — semantic changes where the shape stayed identical but the meaning shifted.
The minimum viable version: OpenAPI spec in the repository, automated breaking-change detection in CI, and a documented process for the cases where a breaking change is genuinely intended. That combination catches the large majority of accidental breakage, which is where most incidents originate.
The version that never dies. v1 deprecated three years ago, still serving traffic, still receiving security patches. Usually because nobody built telemetry to identify remaining consumers, so nobody can approve the removal.
Versioning everything. A new version for every change, breaking or not. Consumers face constant upgrades, stop trusting the version number as a signal, and pin to whatever works. Version numbers should be rare enough to be meaningful.
The accidental breaking change. A field type quietly changes because someone altered the underlying database column. Nobody noticed because there was no contract test. This is the most common cause of API incidents and it is fully preventable with schema diffing in CI.
Silent semantic change. The field name, type and structure are identical, but the meaning changed — total now includes tax where it previously did not. No schema diff catches this. It requires review discipline and, ideally, replay testing.
Deprecation without a date. "This endpoint is deprecated" with no sunset date means nobody migrates. Deprecation without a date is a note, not a policy.
The extended deadline. Sunset slips once under customer pressure. It slips every time thereafter, because consumers have learned the dates are soft. Extend at most once, announce it as final, and honour it.
Versioning the URL but not the semantics. /v2/ exists but v1 and v2 share the same handler with conditional logic. Every change now risks both, and the version boundary provides no isolation at all.
Before an API is exposed to anyone outside your team:
Versioning mechanism chosen and documented
Written definition of what you consider a breaking change, published
Documented requirement that clients tolerate unknown fields
Documented requirement that clients tolerate unknown enum values
Deprecation policy published, with notice period by consumer tier
Deprecation and Sunset header behaviour defined
Per-consumer, per-endpoint telemetry in place
OpenAPI spec in the repository, breaking-change detection in CI
Sandbox environment available to consumers
A named process for approving an intentional breaking change
Error response structure finalised — it is as much a contract as the success path
Pagination, filtering and sorting conventions settled — these are painful to change later
Twelve items. Most take under an hour before launch and cost weeks after.
URL path versioning — /v1/orders — for most products. It is visible in logs, cacheable by default, testable in a browser, and immediately understood by consumers. Header and media-type versioning are more theoretically correct but harder to debug and monitor, and the practical benefits of URL versioning almost always outweigh the doctrinal objections.
Removing or renaming anything, changing a field's type, adding a required field, tightening validation, changing status codes, or changing a field's meaning. The reliable test: would an existing consumer's code stop working or behave differently without them changing anything? If yes, it is breaking, regardless of what the specification technically permits.
Not in principle, but it breaks any consumer using strict deserialisation that rejects unknown fields. State explicitly in your documentation, before your first integration, that clients must tolerate unknown fields. If that requirement is not published up front you cannot rely on it later.
Twelve months minimum for public APIs, six months for partner integrations with a commercial relationship, and weeks for internal consumers you can coordinate with. Check your enterprise contracts — some negotiate longer commitments, and your policy cannot be shorter than your longest contractual obligation.
Use expand-and-contract. Add the new field or endpoint alongside the old one, populate both, deprecate the old one with a date, track who still uses it, and remove it after the window. Most changes that feel breaking are an additive change plus a removal, and separating those two by a deprecation window avoids a version bump entirely.
Per-consumer, per-endpoint request logging with the API key or client identifier attached. This must exist before you announce a deprecation, since retrofitting it means waiting another thirty days for usable data before the clock can start. You cannot safely deprecate what you cannot measure.
Standard response headers signalling that an endpoint is deprecated and when it will be removed. They are valuable because a consumer's monitoring can alert on them automatically, which reaches an engineer far more reliably than a changelog entry or an email to an address that may no longer be monitored.
A short, announced period during which a deprecated endpoint is disabled — an hour, then later a day — before permanent removal. It surfaces consumers who ignored every notification, while the endpoint still exists and can be re-enabled. Far better to discover a critical integration during a scheduled brownout than at final sunset.
Only if services deploy independently. If you can coordinate and deploy consumers together, versioning is unnecessary overhead — change and deploy in lockstep. Independent deployment removes that option and makes every internal API a contract, which is one of the permanent costs of a microservices architecture.
Automated verification that a change does not break the API contract. The minimum useful version is an OpenAPI spec in the repository with breaking-change detection running in CI, so a pull request introducing accidental breakage fails before review. Consumer-driven contract tests and recorded traffic replay add further coverage.
For the provider, roughly 25 to 65 engineer-days plus a year of maintaining two versions. For consumers, two to ten engineer-days each — so fifty consumers represents 100 to 500 engineer-days of other people's unplanned work, received in exchange for no new functionality. That consumer cost is the reason the bar for breaking changes should be high.
Consider it if you have many external consumers and frequently evolving contracts, since it lets consumers upgrade in small steps rather than facing a monolithic major-version migration. It requires a transformation layer between versions, which is real engineering. For most products, coarse URL versioning is the appropriate amount of machinery.
The versioning mechanism you choose barely matters. What matters is how rarely you need it.
An API that changes additively, publishes what it considers breaking, states a deprecation policy before anyone integrates, and can identify every consumer of every endpoint will ship a major version every few years and retire the old one without incident. An API without those things will accumulate versions it cannot retire and eventually charge its customers for the privilege of not benefiting from the change.
The question worth asking before any contract change: would a reasonable consumer's existing code stop working, or start behaving differently, without them touching anything? If yes, you are not making a change — you are scheduling work on fifty other engineering teams' backlogs. Sometimes that is justified. It should never be accidental.
We put an OpenAPI spec in the repository and breaking-change detection in CI from the first sprint of any project with an external contract. It costs a day or two and it converts "we try not to break things" into a rule the build enforces. It is the cheapest quality control available in API work and it is skipped remarkably often.
We also write the deprecation policy before the first external consumer integrates, even when there is no deprecation on the horizon. Writing it later means writing it under pressure, with a specific customer in the room, which produces a worse policy.
Across 180+ software products delivered in 15+ industries, the APIs that aged well shared three traits: additive-by-default change, per-consumer telemetry from launch, and a published policy the team actually honoured. The ones that became expensive had versions nobody could retire because nobody knew who was still calling them.
Our enterprise HRMS platform illustrates the enum problem well. Employment status values genuinely grow over time as organisations and statutory categories change, so the API returns a stable code paired with a display label rather than a bare enum — unknown codes degrade to showing the label instead of throwing in a consumer's switch statement. That decision was made before the first integration and it has meant zero breaking changes on a field that has expanded repeatedly.
We are rated 4.9 out of 5 from 110 Google reviews and 5.0 on GoodFirms.
If you are designing an API you will have to live with, book a call with me directly. Bring your consumer profile — internal, partner, or public — because that single answer determines most of the policy.
You can also read how we structure custom software development engagements and SaaS product builds, or post your requirement for a response within one business day.
Subscribe to the Akoode newsletter for carefully curated insights on AI, digital intelligence, and real-world innovation. Just perspectives that help you think, plan, and build better.