Serving v1 and v2 at Once: Which Versioning Scheme Survives Your Constraints
Your mobile app version 3.4 is installed on 40% of devices and still calls GET /v1/orders; the rewritten v2 endpoint ships this quarter. You cannot force-upgrade 40% of users, so both versions must keep working simultaneously, and every test you own has to run against both. Before the tests, the scheme decision — because it determines what your regression suite can even address.
| Scheme | Example | Wins | Loses |
|---|---|---|---|
| URI path | GET /v1/orders, GET /v2/orders |
trivially cacheable, loggable, visible in every proxy | version leaks into SDKs and URLs everywhere |
| Media-type header | Accept: application/vnd.acme.orders.v2+json |
clean URLs, one resource tree | intermediaries cache by URL unless Vary is set; harder to call from a browser |
| Custom header | X-API-Version: 2 |
easy to implement as middleware | same cache/Vary trap; invisible in a browser address bar |
| Date / additively only | GET /orders with new fields |
never forks the endpoint | constrains you to additive changes forever |
Most teams pick the URI scheme because it makes the compatibility question mechanical: /v1 and /v2 are two routes, often backed by one handler with a version adapter, and a request to the wrong path returns 404 or 400 instead of silently misbehaving. Whichever you choose, one rule outranks the rest: never change a version's behavior in a way existing callers can observe. A "v1" that starts rejecting limit=0 yesterday's clients happily send is a breaking change wearing a version number as a costume.
That is why the suite runs as a matrix over versions rather than once:
# .github/workflows/api-regression.yml
jobs:
contract:
strategy:
fail-fast: false
matrix:
api_version: [v1, v2]
env:
BASE_URL: https://staging.acme.dev/${{ matrix.api_version }}
steps:
- run: newman run collections/orders.postman_collection.json \
--env-var baseUrl=$BASE_URL --reporters cli,junit
- run: schemathesis run --checks all $BASE_URL/openapi.json
fail-fast: false matters: you want the full picture — green v2 with red v1 tells you the change was safe to ship but broke the old contract, which is the exact regression signal the phase exists for. Serving both versions usually means one handler behind a version adapter, not two codebases:
// one service, two shapes — the adapter owns the difference
router.get("/v1/orders/:id", async (req, res) =>
res.json(toV1(await orderService.get(req.params.id))));
router.get("/v2/orders/:id", async (req, res) =>
res.json(await orderService.get(req.params.id)));
The toV1 function is where compatibility actually lives: it re-adds total as an alias of amountCents, formats dates the old way, and strips fields v1 clients were never told about. That adapter is test surface — assert that v1 responses still carry exactly the v1 shape (a leaked v2 field is a contract change even though nothing was removed), and drive both routes from the same dataset rows so a behavior change cannot land on one path alone. If a CDN fronts the API, remember it caches by URL: /v1 and /v2 are separate cache entries, while a header-based scheme needs Vary: Accept or every client is served whichever variant was cached first.
Run the same boundary datasets and Pact verification against each version's broker tag, and the phrase "backward compatible" stops being an opinion. The taxonomy of what actually breaks is next — see Consumer-Driven Contract Testing for the per-consumer layer that sits under this one.
Breaking or Additive? Automating the Compatibility Diff Before Release Notes
Two engineers disagree about whether removing notes from the order payload is safe. The debate settles in seconds if a machine answers it, so make the diff a pipeline step that exits non-zero on breaking changes — the same gate you would want for OpenAPI Spec-Driven Testing, pointed at version-to-version comparison instead of lint.
# CI: compare the branch's contract against the last released one
npx oasdiff breaking openapi.base.yaml openapi.head.yaml
# exit 0 -> compatible exit 1 -> breaking change, fail the PR
npx oasdiff diff openapi.base.yaml openapi.head.yaml --format text > compat-report.txt
The classification table is the interview answer to "what makes an API change breaking?" — learn the direction logic, not just the rows:
| Change | Verdict | Reason |
|---|---|---|
| remove a field | breaking | readers still parse it |
change a field's type (total: number → string) |
breaking | typed clients throw |
add a field to required |
breaking | old payloads now fail validation |
tighten validation (limit=0 was 200, now 422) |
breaking | calls that worked are rejected |
narrow an enum |
breaking | in-flight values become invalid |
| new endpoint, new optional field | additive | old clients unaffected |
| loosen validation, add enum value to a response | additive* | *safe for producers, but consumers with exhaustive switches can break — assert it deliberately |
Notice the two that catch people: tightened validation is a breaking change even though nothing was removed, because the contract includes what the server accepts, not only what it returns; and a response enum addition can break clients that exhaustively match — which is precisely why your v1 suite must keep asserting against live traffic rather than only diffing documents.
Automate all three checks so regressions surface before release notes exist: (1) the document diff above, (2) response snapshots per version, (3) the v1 suite against the candidate build, which catches behavior drift no document records. Snapshots are the cheapest of the three because they need no second document:
// scripts/snapshot.mjs — capture deliberately, diff forever
for (const [name, op] of Object.entries(canon)) {
const body = await (await call(op)).json();
writeFileSync(`snapshots/${version}/${name}.json`,
JSON.stringify(summarize(body), null, 2)); // { id: "string", total: "number", ... }
}
// CI diff: any removed key or changed type = breaking, and the PR shows which
summarize reduces a payload to key → type recursively, so volatile values (ids, timestamps) never dirty the diff and only structure is compared. Keep snapshots per version — snapshots/v1/, snapshots/v2/ — because the versions are supposed to differ; one shared folder would bake today's accidental drift in as the baseline. Refresh them only in a pull request that says "the contract changed", never automatically in the pipeline: a self-updating baseline just documents whatever the server did last night.
Report the three separately in the pull request: spec diff, snapshot diff, runtime regression. When one is red, the question is no longer "is this breaking?" but "which consumer do we notify before we ship it?" — and answering that requires a policy, not a tool.
Deprecation as a Contract: Sunset Headers, Telemetry, and Staged Timelines
Turning /v1 off is not a deploy; it is a stakeholder process with a machine-checkable trail. Teams that "just remove it next sprint" learn the cost from angry partners, so write the policy down and let telemetry drive the timeline.
Phase 1 — announce and instrument. The policy document states the versioning scheme, the support window (a common, defensible number is 12 months or two mobile releases, whichever is longer), what counts as a breaking change, and where the changelog lives. Simultaneously, log the caller identity for every v1 request — API key, app version, user agent — because the honest question is who still uses it, and access logs answer it while guesses do not.
Keep the policy itself short and specific: the scheme in use; the definition of a breaking change (point at the classification your CI enforces); the support window; the exact headers you will send; the announcement channel (changelog plus status page or mailing list — a chat channel is not an archive anyone can search six months later); and the waiver process. Written while nothing is urgent, it turns every future deprecation from a negotiation into a procedure.
Telemetry then reduces to a query rather than a debate: grep '"path":"/v1/' access.log | jq -r '.api_key' | sort | uniq -c | sort -rn | head -20 converts a hunch into a ranked contact list, joined against account data to tell one partner on an old integration from 40% of installs. Those two numbers demand different responses — a named waiver conversation, or another release cycle before you touch anything.
Phase 2 — warn in-band. Standards let you say it in the response, so clients discover deprecation without reading your blog:
HTTP/1.1 200 OK
Deprecation: Sat, 01 Mar 2026 00:00:00 GMT
Sunset: Sat, 01 Sep 2026 00:00:00 GMT
Link: <https://docs.acme.dev/migrating-to-v2>; rel="successor-version"
X-API-Warning: v1 will be removed on 2026-09-01
Deprecation says when it became deprecated, Sunset says when it dies, Link rel=successor-version points at the replacement — and each of those headers becomes an assertion in your v1 suite (expect(res.headers.get('sunset')).toBeTruthy()), so removing the warning early fails a test instead of a partner.
Phase 3 — shrink deliberately. Watch the per-caller telemetry weekly. When a named caller remains past the deadline, that is a conversation with an owner, not an anonymous 404: you extend for that caller with an explicit waiver, or you break them with notice. Only when the log shows zero (or contractually negligible) traffic does 410 Gone replace 200 — and the v1 matrix job flips to expect 410, keeping the suite honest through the removal itself.
Mobile is what makes the window non-negotiable rather than aspirational: an app cannot be patched from the server, so the timeline must absorb app-store review, forced-update adoption curves, and users who update only on Wi-Fi. That is why "two mobile releases or 12 months" shows up as the floor in policy after policy — it is the shortest window in which every client can move, not a courtesy. Communicate on a schedule stakeholders can plan around: changelog entry and status-page post at Phase 1, a second notice 30 days before Sunset, and the internal per-caller list handed to whoever owns those relationships, so chasing stragglers is assigned work rather than an anonymous engineering chore.
State the whole arc in interviews as one line: version, prove both versions green, diff automatically, deprecate with headers and telemetry, then remove on evidence. It signals that you treat compatibility as an operated contract rather than a folder named /v2. The contract phase ends here; the same discipline — old versions, old devices, upgrade paths — reappears at once in Mobile Testing Approaches.
Quiz
1. An API previously accepted `limit=0` with 200 and now returns 422. No field was removed. Is this breaking?
2. Your CI matrix runs the contract suite against v1 and v2; v2 is green and v1 fails with 422 on `limit=0`. What does that combination tell you?
3. Why does `fail-fast: false` belong on a version-compatibility CI matrix?
4. Which deprecation mechanism lets an existing client discover the shutdown date without reading external documentation?
Flashcards
Question
Three common API versioning schemes, with the trade-off each one makes.
Click to reveal answer
Answer
URI path (/v1, /v2): cacheable and visible, but leaks version into URLs and SDKs. Media-type or custom header (Accept: application/vnd.acme.v2+json / X-API-Version): clean URLs, but caches need Vary and browsers make it awkward. Additive-only with no version: never forks, but constrains you to additions forever.
Question
Name four changes that are backward incompatible, including one people forget.
Click to reveal answer
Answer
Removing a field; changing a field's type; adding a field to required; narrowing an enum; and the one people forget — tightened validation (a request that used to return 200 now returns 422). The contract covers what the server accepts, not only what it returns.
Question
How do you automate a compatibility check between two API versions?
Click to reveal answer
Answer
Three independent gates: a document diff (`oasdiff breaking base.yaml head.yaml`, non-zero exit fails the PR), response snapshots per version (removal/type changes fail on diff), and running the old version's contract suite against the candidate build — which catches behavior drift no document records.
Question
What does a response enum addition mean for consumers, and why is it not automatically safe?
Click to reveal answer
Answer
Adding a value is additive for producers but can break consumers that exhaustively match enum cases (a mobile app's switch statement hits an unknown status). Whether it is safe depends on consumer handling — which is exactly what contract tests and versioned runtime suites should prove.
Question
The three phases of a safe API deprecation.
Click to reveal answer
Answer
1) Announce + instrument: written support window and changelog, per-caller logging to learn who still calls it. 2) Warn in-band: Deprecation, Sunset, and Link successor-version headers, asserted by tests. 3) Shrink on evidence: contact remaining callers, extend by waiver if needed, then return 410 Gone once telemetry shows negligible traffic.
Revision Notes
Key Takeaways
- 1. Versioning exists so old clients keep working: the suite must run against every supported version, not just the newest
- 2. The contract covers what the server accepts *and* returns — tightened validation is breaking even when nothing was removed
- 3. A document diff, response snapshots, and the old version's runtime suite catch different classes of drift; run all three before release notes
- 4. fail-fast: false on the version matrix so one red leg does not hide which version actually regressed
- 5. Deprecation is instrumented: headers tell clients, telemetry tells you who is left, and removal happens only on evidence
Interview Tips
- • "How do you version a REST API?" — URI vs header vs additive-only, with the constraint that decides it (caching, browser clients, mobile release cadence); then say you test every supported version concurrently
- • "What counts as a breaking change?" — removals, type changes, tightened required/validation, narrowed enums; explicitly name tightened validation as the one candidates forget
- • "How would you roll out a breaking v2 safely?" — parallel run, diff gate in CI, per-caller telemetry on v1, staged deprecation with Sunset headers, waiver process, then 410
- • "How do you know when it's safe to delete v1?" — access-log evidence of negligible remaining callers plus contractual sign-off; a calendar date alone is an assumption
Cheat Sheet
API Versioning and Compatibility
Scheme choice: URI (/v1) = cacheable, visible, leaks into URLs · header (Accept: vnd.acme.v2+json, X-API-Version) = clean URL but needs Vary for caches · additive-only = never forks, additions forever.
Concurrent test matrix:
strategy:
fail-fast: false # keep every leg's result for comparison
matrix: { api_version: [v1, v2] }
env:
BASE_URL: https://staging.acme.dev/${{ matrix.api_version }}
Breaking vs additive:
| Breaking | Additive |
|---|---|
remove field · change type · add to required |
new endpoint |
| tighten validation (200 → 422) | new optional field |
| narrow enum | loosen validation |
| add response enum value (breaks exhaustive consumers) |
Automate: npx oasdiff breaking base.yaml head.yaml (exit 1 = fail PR) · response snapshots per version · old-version suite against the candidate build.
Deprecation arc: written policy + support window (e.g. 12 months / 2 mobile releases) → per-caller telemetry → Deprecation / Sunset / Link: rel="successor-version" headers (asserted in the v1 suite) → named outreach to stragglers → 410 Gone on evidence.
Mantra: version → prove both green → diff automatically → deprecate with headers and telemetry → remove on evidence.