Designing an api versioning integration that fails loudly, not silently
Most internal API changes do not cause an outage. They cause a field that quietly stops being populated, and a month of numbers nobody trusts. This is how to version an internal API so breakage is visible on the day it happens.
The failure mode is silence, not an error page
When an internal API changes shape, the first symptom is rarely a five hundred error. An api versioning integration goes wrong far more often in the quiet direction: a consumer keeps returning HTTP 200, keeps writing rows, and keeps looking healthy, while a field it depended on is now absent, renamed, or carrying a slightly different meaning. Nobody raises a ticket because nothing appears to be broken. The problem surfaces weeks later, when a finance manager asks why the courier cost on a set of orders is zero, or why a stock adjustment reason code has gone blank across an entire warehouse.
This happens because most integration clients are deliberately forgiving. JSON deserialisers ignore unknown properties by default and leave missing properties as null. That tolerance is useful when a producer adds a field, and dangerous when a producer removes one. A middleware layer that reads an internal order API and posts to Sage 200 will happily post an invoice with a missing analysis code, because null is a legal value in the object model even if it is nonsense in the ledger. The transformation succeeds, the downstream system accepts the payload, and the defect is now in your accounts rather than in your logs.
There is a second, subtler category: the shape stays identical but the semantics move. A quantity field that used to mean physically available stock starts meaning available less allocated. A timestamp that used to be local time starts arriving in UTC. A status value of "complete" starts being set at pick confirmation rather than at despatch. No schema validator on earth will catch these, because the contract, as written, has not changed. The only defence is to treat semantic change as a breaking change and version it accordingly, which requires the contract to state meaning as well as type.
The practical consequence is that versioning is not an academic exercise in URL design. It is a control that converts invisible data corruption into a visible, dated, attributable event. If your versioning scheme does not do that, it is decoration.
What an api versioning integration has to promise
Before choosing a mechanism, write down what a version number means in your estate. Without that, teams argue about whether a change is breaking based on how inconvenient it would be to admit that it is. A workable definition is consumer-centric: a change is breaking if a correctly written client, built against the published contract for version N, could behave differently after the change without altering its own code. That definition puts the burden on the producer, which is where it belongs, because the producer is the only party that knows the change is happening.
In our experience with mid-sized estates — typically one internal order and stock API sitting between Shopify, a warehouse system such as Peoplevox, and a finance system — the following list settles nine tenths of the disputes if it is agreed in writing during discovery and attached to the interface specification.
The other half of the promise is behavioural, and it rarely makes it into documentation. State the guaranteed ordering (or the explicit absence of it), the idempotency key semantics, the retry expectations, the maximum page size, and what a client should do with an unrecognised enum member. "Ignore and log" is a perfectly good instruction; no instruction at all means every consumer team invents its own, and half of them will throw.
- Non-breaking (safe within a version) — Adding an optional response field; adding a new endpoint; adding a new optional request parameter with a documented default; relaxing a validation rule so that previously rejected input is now accepted; adding a new value to an enum that consumers were told in advance is open-ended.
- Breaking (requires a new version) — Removing or renaming any response field; changing a data type, including integer to decimal or string to object; making an optional request field mandatory; tightening validation; changing the meaning, unit, timezone or rounding of an existing field; changing default sort order or pagination behaviour that consumers rely on.
- Breaking but frequently missed — Changing an HTTP status code for an existing condition; changing error body structure; changing the point in a business process at which a status is set; changing null handling from "absent" to "present and null"; altering the granularity of a timestamp.
- Never versioned, always fixed — Genuine defects where the API contradicts its own documentation. Fix these in place, notify consumers, and record the correction — do not manufacture a version to cover an error.
Choosing a mechanism you can actually operate
Three schemes dominate: a version segment in the path (/v2/orders), a custom request header (X-API-Version: 2026-02-01), and content negotiation via the Accept media type. All three work. The one that matters is the one your operations team can see in a log line, filter in a proxy, and explain to a subcontractor at nine in the morning. For internal estates, a path segment is almost always the right answer because it is visible in access logs, cacheable, trivially routable at the gateway, and impossible to omit by accident. Header-based versioning is more elegant and considerably easier to get wrong, because a missing header quietly resolves to a default.
Date-based versions are worth considering when the API evolves steadily and consumers upgrade at different speeds. A consumer pins to 2026-02-01 and keeps receiving the behaviour published on that date; the gateway applies a chain of small, ordered transformations to translate the current internal representation back to that consumer's pinned view. This is more engineering than most SMEs need, but it scales better than maintaining three parallel controller stacks. The cheaper variant, and the one we most often build, is a single internal representation with thin, versioned presenters — one per supported version — and a hard rule that business logic never branches on version.
Resist per-endpoint versioning unless you have a genuine reason. It looks efficient and becomes unmanageable the moment a consumer calls four endpoints at three different versions and has to reason about which combination is supported together. Version the API surface as a whole, keep the number of live versions small — two is comfortable, three is a warning sign — and accept that the cost of a new version is mostly the cost of the tests and documentation, not the code.
One more rule, learned the hard way: never allow an unversioned route to exist as a silent alias for "latest". It will be called by something, usually a script written in a hurry during a stocktake, and it will break on the day you promote a new version. If you must support a default, make it resolve to the oldest supported version rather than the newest, so time works in your favour instead of against it.
Instrumenting the boundary so breakage is visible on day one
Versioning tells consumers what to expect. Instrumentation tells you who is actually depending on what. Every request into the internal API should carry a consumer identifier — a client ID on the token, not a free-text user agent — and every log line should record consumer, version, endpoint and outcome. From that you can produce a weekly table of consumer by version by call volume. That table is the single most useful artefact in the whole exercise, because it converts "we think everyone has migrated" into a fact you can check before you switch anything off.
Contract tests belong in the producer's build, not in a document. For each supported version, hold a set of recorded example responses and assert the schema against them on every commit; a removed field then fails the pipeline rather than the ledger. Where the consumer is a piece of middleware you also own — the service posting into Xero, say, or reading despatch confirmations from a warehouse — add a consumer-side test that runs against the published contract for the version it pins to. The pair together catch the two directions of drift.
For fields that no schema can police, add semantic assertions to the acceptance pack: stock quantities are never negative, order totals reconcile to the sum of lines plus shipping plus tax, despatch timestamps are never earlier than order timestamps, currency codes are always ISO 4217. These read as trivial until the day a unit change slips through and the assertion fires within an hour rather than at month end. They also give the finance side something concrete to sign off during acceptance testing.
Finally, make deprecation machine-readable. Responses from a deprecated version should carry a Deprecation indicator and a Sunset date, and the gateway should log a warning per call. If your consumers are all internal, go further: have the middleware surface that warning into the same alerting channel as failures, so that continuing to call an old version is mildly annoying every day rather than catastrophic once.
Retiring a version without a fire drill
A retirement plan has four parts: an announcement with a dated deadline, a migration note that lists every change a consumer must make, a period of dual running where both versions are live and reconciled, and an evidence-based cut-off driven by the consumer-by-version table rather than by optimism. For a typical estate — an internal order API serving a Shopify storefront feed, a warehouse integration, and a nightly finance posting into Sage 200 — ninety days between announcement and sunset is comfortable, and thirty is achievable if the consumers are all in-house.
Dual running is where most of the value sits, and it is worth budgeting for properly. Run the old and new versions against the same input for a fortnight and diff the outputs, ignoring fields that are expected to differ. Anything unexpected in that diff is a defect found in a controlled window instead of on the morning after cut-over. Where the downstream effect is financial, reconcile at the level the finance team cares about: totals by day, by tax rate, by currency, compared between the two paths.
Before the sunset date, take the traffic table and account for every remaining call on the old version by name. "Some residual traffic" is not an answer; it is usually a scheduled job on a server nobody has logged into since a previous project. If a consumer cannot migrate in time, the honest options are to extend the date for everyone or to freeze the old version behind a separate route with a stated end date and no further changes. Quietly leaving it running with no owner is how estates accumulate four live versions and nobody willing to touch any of them.
We treat the versioning policy, the contract tests and the consumer register as deliverables in their own right, agreed at fixed scope before any code is written and handed over with the documentation at completion. They are not overhead. They are the difference between an integration that tells you it has changed and one that lets you find out from a customer. If you would like a second opinion on an internal API that several systems already depend on, we are on 01303 883111 or hello@api-integrations.co.uk.
Related platform guides
Key points
- Treat any change to a field's meaning, unit, timezone or timing as breaking, even when the schema is untouched — tolerant JSON clients will accept the change and corrupt data without raising an error.
- Version the API surface as a whole using a visible path segment, keep no more than two or three live versions, and never let an unversioned route act as a silent alias for the latest behaviour.
- Log consumer identity and version on every call, so retirement decisions rest on a table of who is calling what rather than an assumption that everyone has migrated.
Planning an integration?
Send us the two systems and the record types involved. We will come back with an outline scope and the approach we would recommend, within one working day.