Your API Versioning Strategy Is a Roadmap to Your Worst Day
For: A CTO or lead engineer at a 30–150-person B2B SaaS company whose public API now has 3+ active versions in production, one or two enterprise customers pinned to v1 by contract, and a growing internal debate about whether to force a migration or keep patching the oldest version indefinitely
Here is the uncomfortable truth about your API versioning strategy: the scheme you picked (URL path, header, media type) is not the thing that will hurt you. What will hurt you is that you version the URL but not the contract enforcement behind it. Your v1 and v3 endpoints almost certainly share the same validation logic, the same database writes, and the same side effects. Which means when you tell an enterprise customer “we still support v1,” that statement is a polite fiction your system is maintaining on your behalf, and neither you nor the customer knows where the fiction ends and reality begins.
This is why the migration you keep postponing gets harder every month. Not because the URL routing is complex. Because v1 has been quietly accumulating undocumented business logic that no longer matches your current data model, and you have no test suite that proves otherwise.
The thesis: versioning is a contract problem, not a routing problem
Every article you have read about how to version a REST API argues about URI versioning vs. header versioning vs. content negotiation. This debate is mostly irrelevant to the failure mode that actually kills teams. The failure mode is this:
- You ship v1. It has a
customerstable and aPOST /v1/customersendpoint that writes to it. - Twelve months later, product needs multi-tenant customers. You add v2, split the table, and route
POST /v2/customersto a new service. - But v1 still needs to work. So
POST /v1/customersgets a shim that translates the old payload into the new model. - Six months after that, someone adds a required
regionfield for compliance. The v2 handler validates it. The v1 shim silently defaults it to"us-east"because nobody wanted to break the enterprise customer pinned to v1. - Now a v1 caller can create records that no v2 or v3 caller could ever create. Your invariants are gone. Your data model has forked, and only one branch is in the schema.
Multiply this by three years and forty endpoints. The v1 you are “supporting” is not the v1 you originally shipped. It is a Frankenstein of default values, silent coercions, and skipped validations that exists nowhere in your API documentation and nowhere in your test suite. When the enterprise customer finally agrees to migrate, you cannot tell them what will change, because you do not know what v1 actually does anymore.
Why the URL-vs-header debate is a distraction
URL versioning, header versioning, and Accept-header versioning all have the same fatal weakness if you do not version the enforcement layer: they route to the same handlers. The version string in the URL is a label. Labels do not enforce anything.
The teams I have seen survive this cleanly do one specific thing: they treat every active API version as a fully isolated contract with its own request schema, its own response schema, its own validation, and its own integration test suite that runs on every deploy. Not shared code with conditionals. Not “if version == 1 then skip this field.” Actually separate.
Yes, this is more code. Yes, some of that code is duplicated. That duplication is the point. The moment your v1 handler shares a validator with v3, v1 will silently inherit v3’s rules, and your promise of backwards compatibility becomes a lie by omission.
Three concrete failure patterns
1. The shared validator
A logistics platform I looked at had a shared validateShipment() function called by v1, v2, and v3 handlers. When the product team added carrier verification to v3, they added it to the shared validator. v1 callers started getting 422 errors for payloads that had worked for two years. The v1 customer’s CI pipeline broke on a Friday afternoon. The engineering team’s explanation to the customer was, essentially, “we didn’t know v1 used that code.”
2. The shared write path
A fintech SaaS routed all three API versions through a single createInvoice service. When they added a tax_jurisdiction requirement for a new market, the write path started rejecting invoices without it. v1 customers, who had no way to send that field, could no longer create invoices. Rollback took nine hours because the fix required either a schema migration or a shim, and neither had been designed for.
3. The silent side effect
An HR product added an audit-log side effect to their user-update endpoint. It applied to every version, because it was in the shared service layer. A v1 customer’s integration, which polled and updated user records every 30 seconds, generated tens of millions of audit rows in a week. The customer noticed only when their exported audit reports became unusable. No version bump had been shipped. Nothing in v1’s documented behavior had changed. But v1’s actual behavior had.
The strongest counter-argument
“Fully isolating every version is not realistic. We would double our maintenance burden and slow down every feature.”
This is a fair objection, and I want to be honest about it. Full isolation is expensive. It means more code paths, more tests, more deploy artifacts, and often more infrastructure. For a small team shipping fast, it is genuinely a bad tradeoff on day one.
But here is the counter-counter: you are already paying this cost. You are paying it in the form of on-call incidents, in customer-support hours spent explaining behavior nobody documented, in the migration you keep postponing because you cannot risk it, and in the enterprise renewal conversation where the customer refuses to move off v1 because they no longer trust that v2 will do the same thing. Isolation is not free. Neither is the fiction of shared code. Pick which one you would rather pay for.
The pragmatic middle path is this: share code aggressively within a version, isolate strictly between versions, and treat the boundary as sacred. A shared utility for formatting phone numbers is fine. A shared write path for the resource the API is about is not.
What to do differently on Monday
If you are running three or more API versions in production and one enterprise customer is pinned to v1, here is what I would do in order:
- Audit what v1 actually does today. Not what the docs say. Run every documented v1 request against production (or a mirror) and record the actual response, the actual database state, and the actual side effects. This becomes your real v1 contract. It will surprise you.
- Pin v1 to that audit. Write a contract test suite that fails the build if any of those behaviors change. From this moment on, v1 is frozen against the audit, not against the documentation.
- Separate the handlers. If your v1 handlers share validation, serialization, or write paths with newer versions, fork them. Yes, duplicate the code. The duplication is a feature, not a bug. It lets you evolve the newer versions without touching v1.
- Publish a real deprecation timeline. Not a vague “we will sunset v1 eventually.” A specific date, a migration guide that documents the actual differences your audit revealed, and a support commitment for the enterprise customer through the cutover. An honest api deprecation strategy is worth ten polite ones.
- Version the enforcement, not just the URL. For any new version going forward, the schema, validator, handler, and write path all live under the version namespace. If you cannot delete a version by deleting a folder, you have not really versioned it.
If you are working through a legacy API surface that has drifted this way and need help mapping the actual behavior before you cut over, this is the kind of work our team does inside legacy modernization engagements. But most teams can do the audit themselves. The hard part is not the tooling. The hard part is admitting that “we support v1” has been a claim you cannot actually verify.
The one line to remember
Breaking changes in an API do not happen when you ship a new version. They happen quietly, in the versions you thought you were preserving, every time you touch shared code. The versioning scheme is cosmetic. The enforcement boundary is the product.
Frequently Asked Questions
What is the best way to version a REST API in production?
The scheme (URL path, custom header, Accept header) matters less than most teams think. What matters is that each active version has its own isolated request validation, response schema, and handler code — not shared code with version conditionals. Pick whichever scheme your clients find easiest to use, then invest the real engineering effort in enforcement isolation.
How many API versions should we support at once?
As few as you can contractually get away with. Every additional active version multiplies your test surface, your incident risk, and the probability of silent behavioral drift. Two supported versions (current and previous) is a healthy default; three is manageable with discipline; four or more usually means you have avoided a hard conversation with a customer.
How do we force enterprise customers off an old API version?
You do not force them — you make staying more expensive than moving. Freeze the old version against a behavioral audit, stop backporting features and non-critical fixes, provide a genuinely useful migration guide based on real behavioral differences (not just the docs), and set a specific sunset date with enough runway for their engineering cycle. If the customer is contractually pinned, negotiate the migration as part of the next renewal.
What counts as a breaking change in a production API?
Any change a well-behaved client cannot absorb without code changes: removed fields, renamed fields, changed types, new required request fields, changed error codes, changed enum values, or changed side effects. The trap most teams fall into is treating side-effect changes (new audit rows, new webhook events, changed rate limits) as non-breaking. For an integration partner, those are often more disruptive than a schema change.
How long should we take to migrate customers off a deprecated version?
It depends on how far v1 has drifted from your current data model and how many enterprise contracts are pinned to it. Rather than commit to a generic timeline, we usually recommend starting with a behavioral audit to size the actual delta. If you want a specific plan for your API surface, contact CodeNicely for a personalized assessment.
Found this useful? CodeNicely publishes engineering and product playbooks weekly. Browse the archive or tell us what you're building.
_1751731246795-BygAaJJK.png)