Version an API when its consumer contract must change incompatibly, and preserve supported behavior while clients migrate. URL, header and media-type versioning select a contract; none automatically makes a change backward compatible. A long-lived API also needs consumer inventory, contract tests, a deprecation policy and observable migration.
Key takeaways
- Version the external contract rather than every internal deployment.
- Choose one documented selection rule and reject unsupported combinations.
- Test behavior, errors and authorization as well as response schemas.
- Deprecation and shutdown are separate lifecycle events.
URL vs header vs media-type versioning
The Azure API design guidance describes several version-selection options. A URL makes selection visible in the path; a custom header keeps the path stable; a versioned media type uses representation negotiation. Evaluate clients, gateways and diagnostics before choosing.
| Strategy | Illustrative request | Operational consideration |
|---|---|---|
| URL path | GET /api/v1/orders/42 | Visible routes; preserve links and version-specific documentation |
| Custom header | GET /api/orders/42 with Api-Version: 1 | Forward, log and include selection in the cache contract |
| Media type | Accept: application/vnd.example.order.v1+json | Implement negotiation and representation-specific responses |
These are fictional conventions, not universal header names. Specify what happens when the version is absent, unknown or conflicts with another selector. Avoid quietly routing unsupported requests to the newest contract.
For a public API with many independent consumers, path selection may simplify support and inspection. For representation negotiation in an established HTTP architecture, media types may fit existing tooling. A custom-header approach needs evidence that every intermediary preserves and distinguishes the header.
Do not infer a client’s business tenant from its selected version. Versioning and authorization are independent. Every supported contract must enforce current access controls, even if its data shape remains unchanged.
A worked backward-compatibility example
Suppose a fictional order API returns {"id":"42","total":1250}, where the documented unit is minor currency units. Replacing total with an object breaks consumers expecting a number. Changing its meaning to major currency units can break them even though the JSON type remains numeric.
Contract v1
{"id":"42","total":1250}
Contract v2
{"id":"42","amount":{"value":1250,"currency":"GBP","unit":"minor"}}A migration adapter can preserve the v1 contract while v2 makes amount semantics explicit. It must not invent currency for old records where it is unknown. Decide whether the old model can faithfully represent new business cases before promising indefinite compatibility.
Adding a field is often easier than removing one, but it is not automatically harmless. A strict deserializer, generated client or signed-payload scheme may reject unknown fields. Adding an enumeration value can also break code that assumes a closed set. Verify the actual consumer expectations.
Changes to sorting, default page size, nullability, error bodies and permission requirements can affect clients without renaming a field. Record them in compatibility review instead of limiting that review to an OpenAPI schema diff.
Keep internal implementation revisions separate from consumer versions. Refactoring a query or changing a storage engine should not require a new public version if the documented contract and supported behavior are preserved.
Route and cache the selected contract consistently
Client selects supported version
↓
Gateway validates / forwards selection
↓
Contract adapter → shared domain operation → versioned representation
↓
Response identifies representation; telemetry records selected contract
Caches distinguish versions and obey authorization/cache policyDo not put all historical business logic into one giant conditional handler. Separate contract adapters where useful, then reuse domain operations only when their semantics genuinely match. A shared implementation should not accidentally change an older supported promise.
Azure API implementation guidance highlights consistency between gateway and application versioning, including cache distinction for header-selected versions. Test the entire route through production-like intermediaries rather than only calling the application directly.
For header negotiation, configure the cache key and appropriate Vary behavior where caching is permitted. That does not authorize caching private responses: access-sensitive content still needs a correct cache policy and isolation.
For media negotiation, make response Content-Type match the selected representation. Document unsupported-media behavior. Do not label an error representation as a successful order payload merely because the request asked for that media type.
Write a usable deprecation policy
RFC 9745 defines the Deprecation response header to signal a resource’s deprecation. RFC 8594 defines Sunset for an expected point at which the resource becomes unresponsive. Deprecation alone does not change its behavior or mean it has already shut down.
A practical policy should name the affected contract, replacement, migration steps, support contact and dates. Machine-readable signals are useful, but many consumer teams also need direct communication and release notes.
- Announcement: explain why change is needed and what remains supported.
- Migration period: provide examples, test environment and compatibility guidance.
- Evidence: monitor active consumers and unresolved migration blockers.
- Retirement: follow the agreed policy, with documented exceptions and recovery.
Choose dates from customer obligations and migration complexity. Do not publish a generic “90 days” promise unless the organization can support it. A rarely used annual integration may be absent from a short traffic sample.
A shutdown response and status code should follow the actual contract and retirement policy. Do not redirect a write request to a different version and assume its payload has the same meaning.
Test consumer contracts before release
Build fixtures for supported versions and representative consumers. Validate request interpretation, response fields, errors and important business rules. A schema checker is useful but cannot prove semantic compatibility.
Example release checks:
v1 request → v1 amount semantics preserved
v2 request → explicit currency/unit representation
missing version → documented default or rejection
unknown/conflicting version → documented error
gateway + cache → no cross-version representation reuse
wrong tenant / object → denied on every supported versionThis is a proposed test specification, not a claim that an application was deployed or tested against real clients. Adapt it to the language, gateway and contract tooling you operate.
Test old client binaries or generated SDK versions when they are part of the support promise. Include null values, large numbers, unknown enum values, pagination and retry behavior. A happy-path sample alone will miss many compatibility failures.
Observe traffic by contract version and a controlled consumer identifier. Avoid logging tokens or sensitive payloads just to identify usage. Track failures and business completion as well as request count during migration.
API versioning troubleshooting
| Symptom | Inspect | Correction to evaluate |
|---|---|---|
| Client receives another version’s shape | Gateway forwarding and cache keys | Align version selection through every intermediary |
| Schema unchanged but client fails | Units, defaults, ordering and error semantics | Restore the supported contract or migrate explicitly |
| Unknown version silently succeeds | Default/fallback routing | Apply the documented rejection policy |
| Old SDK rejects new response | Strict models and enum assumptions | Preserve old representation and provide migration guidance |
| Retirement surprises an integration | Consumer inventory and infrequent jobs | Reconcile owners and migration evidence before cutoff |
Review compatibility at the service boundary before deployment. If a team cannot explain who consumes an API, versioning syntax will not solve the lifecycle problem.
See the API architecture comparison for interface choices and the idempotency guide for retry contracts that must survive migrations.
Common questions
How can you version REST APIs without breaking clients?
Preserve supported contracts, introduce incompatible changes behind an explicit new selection, test consumers and operate a documented migration policy.
Is URL versioning better than header versioning?
It is often easier to inspect, but the choice depends on clients, gateways and caching. Neither automatically ensures compatibility.
Is adding a response field always backward compatible?
No. Strict clients and other assumptions may fail. Verify actual consumer behavior and the documented extension contract.
Does Deprecation mean an API is unavailable?
No. Deprecation signals lifecycle status; Sunset can communicate an expected unresponsiveness date. Maintain the supported behavior during migration.
Should every deployment change the public API version?
No. Internal changes that preserve the consumer contract usually do not require a new public version.
Evolve APIs with a supportable consumer contract
Bring the interfaces, client inventory and planned changes. Request an API integration architecture review to define compatibility, version selection and migration.
