API versioning carries a much heavier responsibility for mobile teams than for web teams: a web page reloads on every request, but a mobile app keeps running against the old API contract for days until the update is approved on the App Store. This article covers backward-compatible API versioning end to end — from URL/header/media-type approaches to the deprecation calendar — with concrete examples. The goal is to build a contract that can grow on the server side without breaking changes.
💡 Pro Tip: Adding a new field is never a breaking change; removing a field or changing its meaning always is. Turn this single sentence into a team rule and most versioning debates resolve themselves.
Table of Contents
- Why Versioning Is Harder on Mobile Than on Web
- Three Approaches: URL Path, Header, Media Type
- URL Path Versioning (`/v1/users`)
- Header-Based Versioning
- Media Type (Content Negotiation) Versioning
- The Real Definition of a Breaking Change (From the Client's Perspective)
- Additive-Only Design: Add Fields, Never Remove Them
- Deprecation Calendar and the Sunset/Deprecation Headers
- The Forced-Update Gate and Minimum Supported Version
- Measuring Old-Version Traffic (Grounding the Decision in Data)
- Absorbing Migration on the Server Instead of Pushing It to the Client
- Comparing the Approaches
- Change Type and Version Impact
- FAQ
- Should API versioning live in the URL or in a header?
- How long should I support old app versions?
- How do you write a deprecation policy?
- How do you design a forced-update screen?
- Update (September 2026)
- Conclusion
- Sources
Why Versioning Is Harder on Mobile Than on Web
A web client typically updates its API contract within hours: the user refreshes the page, a new JavaScript bundle arrives. Mobile clients have no such loop. A binary goes through App Store/Play Store review, the user may not update immediately, and there is no update without internet on the device. The result: the server has to support multiple client contracts at once for weeks or even months. This is why versioning decisions in a mobile backend require a much longer "backward-compatibility window" than in a web API, and contract changes are governed not by deployment speed but by store review turnaround and user update habits.
The practical consequence: a single server-side endpoint must correctly answer three or four contract shapes at once — the newest client version, the previous major version, and older versions still generating traffic. This almost never happens on the web, since the browser fetches current code every visit; on mobile it's a normal, everyday requirement of server code — versioning isn't a "sometimes needed" feature, it's an ongoing discipline.
Three Approaches: URL Path, Header, Media Type
There are three common versioning techniques, and none of them is a "universal truth" — each optimizes for a different assumption.
URL Path Versioning (`/v1/users`)
The most common and most contested method. Easy to read, easy to cache, but the Zalando RESTful API Guidelines explicitly advise against it: the rule is written as "MUST not use URL versioning" — the rationale being that URL versioning creates tighter client-server coupling and makes coordinating version upgrades harder for hyperlinked service dependencies (a consumer must wait until the provider finishes upgrading); media-type versioning with content negotiation is required instead. As a separate general REST argument: a resource's identity should stay fixed in the URL, while version is a representation detail.
This theoretical objection has a concrete counterpart on mobile: if you model a user record as two separate URLs, /v1/users/42 and /v2/users/42, then deep links, push-notification payloads, and offline cache keys on the client also have to carry version information. An in-app notification link that saved /v1/users/42, tapped two months later when the server only serves /v2/, breaks — whereas keeping the same resource at a fixed URL and moving the version into a header never creates this problem.
Header-Based Versioning
Microsoft's Azure API Guidelines define carrying the version in a query parameter (api-version) and an azure-deprecating header for deprecation notices; together these two mechanisms make visible, both in the query string and in the response header, which contract the client expects on every request. Stripe's 2017 engineering blog post solves the same problem from a different angle: the moment an account makes its first API request, it is automatically pinned to that day's newest version, and versions are named by date — in their own words, versions are «named with the date they're released (for example, 2017-05-24)»; the account stays on that version unless it explicitly upgrades, and every request states which contract it expects via the Stripe-Version header. Azure's query parameter and Stripe's header implement the same idea through two carriers: separating version info from the resource part of the URL and moving it into the request itself. Practical differences exist too: Azure's api-version query parameter is easiest to add (append one query parameter), but query strings can end up as plain text in proxy/CDN logs; Stripe's Stripe-Version header is a separate channel from the body, so logging differs, and the server can fall back to a per-account default (the pinned version) — even if the client never sends the header, the account's last-pinned version still applies. For a mobile client this matters: while an update waits in App Store review, the server still knows which contract that account expects, because version info relies on a server-side record, not the client sending the right header every time.
1GET /users/42 HTTP/1.12Host: api.example.com3Accept-Version: 2025-05-20Media Type (Content Negotiation) Versioning
The same Zalando guide that rejects URL versioning requires media-type versioning (via a custom Accept header using a MIME type). The server returns a different representation based on a header like Accept: application/vnd.example.v2+json; the resource's URL stays fixed.
1GET /users/42 HTTP/1.12Accept: application/vnd.example.v2+jsonOn mobile the practical choice generally leans toward the header/media-type side, because keeping the URL fixed means deep links and cache keys never break; only the request header carries version information.
A third option is a date-formatted version value; Stripe's model already uses this, since 2017-05-24 is both sortable and answers "what date was this frozen at." Rather than seeking one right answer among these three, it's more useful to ask: is it more critical for the client to cache the URL, or for the server to easily see how many contracts it carries at once? URL versioning eases the first; header/date-based versioning eases the second.
The Real Definition of a Breaking Change (From the Client's Perspective)
Per semver.org's definition, a MAJOR version bump happens "when you make incompatible API changes" — in other words, semver's own contract directly equates a breaking change with backward incompatibility. From a mobile client's perspective this means one of three things: (1) a field the client expects no longer arrives, (2) an existing field's type or meaning changed, (3) a request the client sends is no longer accepted (a newly required parameter, a removed endpoint). By contrast, adding a new field, defining a new optional parameter, or opening a new endpoint — even if the client doesn't read it — does NOT count as a breaking change. Keeping this distinction clear ends the "does every change need a new version" debate.
Additive-Only Design: Add Fields, Never Remove Them
Stripe's 2017 engineering blog post grounds its API versioning strategy in this principle: the moment an account makes its first API request, it's automatically pinned to the current newest version, and it stays on that version unless it explicitly upgrades; every request can state which contract it expects via the Stripe-Version header. The practical rule behind this model is additive-only design: when a new feature ships, existing fields are preserved and only new fields are added; removing a field or changing its behavior requires a separate version bump.
1{2 "id": "usr_42",3 "email": "[email protected]",4 "phone_verified": true5}Adding a new field like "marketing_opt_in": false to the body above doesn't affect old clients — the client doesn't read it, the server keeps sending it. But renaming "phone_verified" to "phoneVerified", or changing its type to "true" (a string), instantly breaks every old client that reads that field.
The boundary of additive-only design is just as clear: leaving a field blank because it's "unused," or silently filling it with a different meaning (for example, a field that once carried a username now returning a user ID) is not additive — even though the field is still there, its semantics changed, so an old client processes wrong data believing it's correct. So "I didn't delete the field" alone isn't sufficient assurance; you also have to guarantee the field's meaning hasn't changed.
Deprecation Calendar and the Sunset/Deprecation Headers
There is a standard, machine-readable way to notify clients before removing a field or endpoint entirely. RFC 8594, published in 2019 as an Informational RFC, defines the Sunset HTTP header: it states the date on which a resource will be completely disabled. The complementary Deprecation header was published as RFC 9745 in March 2025 as an IETF Standards Track (Proposed Standard) — this header announces that a resource is _now_ deprecated (even if not yet shut down). Used together, the client gets two distinct pieces of timing information: "this is now deprecated" (Deprecation) and "it will fully shut down on this date" (Sunset).
1HTTP/1.1 200 OK2Deprecation: @17476992003Sunset: Wed, 20 Aug 2025 00:00:00 GMT4Link: <https://api.example.com/docs/migration-v2>; rel="deprecation"Practical rule: before removing a field or endpoint, first mark it with the Deprecation header, then add a Sunset date after a sufficiently long transition window; without a client-side layer that logs or warns on these headers, these notices get silently ignored.
Just as important as the headers themselves is where you actually consume them. Adding the Deprecation header to the server response alone isn't enough; without a small mobile-side interceptor that reads it and turns it into a log line — or, in dev builds, a visible warning — this information silently gets lost. It isn't shown to users in production, but it lands in telemetry, so the team can answer "how many requests still use this field" from the header itself, not from a separate document.
The Forced-Update Gate and Minimum Supported Version
At some point, deprecation notices aren't enough and the server has to reject old clients outright — usually when a security vulnerability is patched or the contract has changed so much an old client can't function meaningfully. The server-side counterpart is a minimum-version gate that checks the version/build the client sends on every request. Below that threshold, the server responds with a special "update required" status instead of a normal response; the client resolves this with a forced-update screen routing to the App Store/Play Store. The critical part: the decision to raise the threshold is based on data — actual traffic — not an arbitrary date.
Before turning this gate on, there's another separate question: in which cases is "warn but still run" enough, rather than an outright reject? For irreversible cases like a security vulnerability, a hard reject makes sense; but for cases like simply not supporting a new feature, returning a response that omits that feature (graceful degradation) usually creates less friction than shutting the old client out entirely. The gate itself shouldn't be a single switch, but a deliberately chosen spectrum between "reject" and "serve an incomplete but working response."
Measuring Old-Version Traffic (Grounding the Decision in Data)
The most common mistake in raising the forced-update threshold is basing it on an assumption ("surely no one is on that old version anymore"). Instead, the server should write each request's client version/build (from a header or User-Agent) into a structured log field and track it grouped by version. This answers three questions concretely: which versions still generate traffic, whether that share is falling or rising over time, and how many real users a shutdown would affect. Without such a measurement layer, a forced-update or shutdown decision never rises above a blind guess.
1// version-metrics.ts — tag the client version on every request2function recordClientVersion(req: Request, metrics: MetricsClient) {3 const version = req.headers.get("x-client-version") ?? "unknown";4 metrics.increment("api.requests_by_client_version", { version });5}This one function looks small but feeds two different decisions. First, it answers "can we shut this down now" with a curve over time — if a version's share is converging to zero, the shutdown decision is backed by data. Second, it's useful AFTER the shutdown decision too: if the same metrics dashboard shows an unexpected spike after shutdown (say, the unknown tag rising), that's an early signal that some clients weren't sending the header at all, and the shutdown affected a wider audience than predicted.
Absorbing Migration on the Server Instead of Pushing It to the Client
The lowest-friction migration strategy is closing the contract gap on the server side without ever forcing the old client to update. The concrete way to do this is an adapter layer that serves old-version requests: the server uses the new (v2) model internally, but when a v1 request arrives, it passes the response through a thin transform function that reshapes it into the v1 shape.
1// adapters/user-v1.ts2function toV1Shape(userV2: UserV2): UserV1 {3 return {4 id: userV2.id,5 email: userV2.email,6 // the two fields split apart in v2 are merged back for the v1 client7 name: `${userV2.firstName} ${userV2.lastName}`,8 };9}The cost of this approach is keeping a handful of adapter functions on the server; the payoff is that the client never feels "update urgently" pressure — the migration stays entirely dependent on server deploys, and store review time is never an obstacle in front of the migration.
The key to keeping this pattern scalable is grouping adapter functions in one directory, separate from business logic. Otherwise every v1/v2/v3 transform scatters across the main service code, and tracking which field was transformed for which version gets hard. In practice this means one "canonical" model (the newest contract) inside the server, plus one converter back to each supported version; adding a version just means writing a new converter, not touching existing ones.
Comparing the Approaches
Placing the three methods side by side in a mobile context:
Approach | Does the resource URL change | Cache/deep-link impact | Source |
|---|---|---|---|
URL path ( /v2/...) | Yes | High (every version, separate URL) | Zalando (opposing view) |
Header/media-type | No | Low | Zalando |
Date-formatted version | Depends on context | Low-medium | Stripe, Azure API Guidelines |
Change Type and Version Impact
Change | Breaking | semver impact |
|---|---|---|
Adding a new optional field | No | Minor |
Removing an existing field | Yes | MAJOR |
Changing a field's type | Yes | MAJOR |
Adding a new endpoint | No | Minor |
Requiring a new mandatory parameter | Yes | MAJOR |
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
Below is a plain list of questions you should ask yourself before making an API change; if every item is "yes," the change can safely be treated as additive — if any single one is "no," you need a MAJOR version and a deprecation calendar.
FAQ
Should API versioning live in the URL or in a header?
Both are used, but the Zalando RESTful API Guidelines explicitly advise against URL versioning ("MUST not use URL versioning") because URL versioning tightens client-server coupling and makes version coordination harder for hyperlinked service dependencies; it requires media-type/header-based versioning instead. In mobile backends, the header/media-type approach generally creates less friction because it keeps deep links and cache keys stable.
How long should I support old app versions?
Giving a fixed number would be misleading, because the duration varies from project to project. The concrete rule is: base the duration not on an assumption but on the version-based traffic measurement described in "Measuring Old-Version Traffic," and confirm that a version's real traffic share has meaningfully dropped before shutting it down.
How do you write a deprecation policy?
Use the Deprecation header defined by RFC 9745 to announce that a resource is now deprecated, and the Sunset header from RFC 8594 to state, in a machine-readable way, on what date it will be fully shut down. Send these two headers in actual HTTP responses, not just as human-readable text in documentation — so that automatic warning/logging mechanisms can be built on the client side.
How do you design a forced-update screen?
Set up a minimum-version gate on the server side that checks the client version; meet requests below the threshold with a special status instead of a normal response, and have the client resolve this with a full-screen update prompt that routes the user to the store. The gate's server-side logic was covered above in "The Forced-Update Gate"; the screen itself should not be dismissible and should offer no way out other than going to the store page.
Update (September 2026)
This article was originally written on 2025-05-20, with the tools and standards of that day. Since then, two concrete developments have happened in the field:
- The Deprecation header now has a live production example. GitHub's REST API sends the Deprecation (RFC 7231 HTTP-date) and Sunset (RFC 8594) headers in its responses for a version approaching shutdown; its policy is to support the previous version for at least 24 more months after a new version ships. GitHub's current version is 2026-03-10, and 2022-11-28's support ends March 10, 2028 (docs.github.com/en/rest/about-the-rest-api/api-versions). This means the two-stage deprecation model described in this article (first a Deprecation notice, then a Sunset date) now has a live example on a major platform.
- OpenAPI Specification v3.2.0 (2025-09-19) added a "Versions and Deprecation" section to the spec, formalizing OAS's own lifecycle policy for deprecated fields/features (spec.openapis.org/oas/v3.2.0.html).
Google AIP-185 expanded after the publish date. As of 2025-05-20, AIP-185 only listed Channel-based / Release-based / Visibility-based strategies and required REST APIs to carry the major version as the first segment of the URI path. The guide later added an Interface-based versioning section, making stable, YYYY-MM-DD-formatted versions (e.g. 2025-09-04) carried via the X-Goog-Api-Version HTTP header or the $apiVersion URL query parameter an official strategy as well (google.aip.dev/185).
On mobile, two store rules also indirectly set a lower bound on "how long is an old version supported": Google Play requires targeting Android 16 (API 36) for new apps and updates as of 2026-08-31 (developer.android.com/google/play/requirements/target-sdk); Apple requires apps uploaded to App Store Connect to be built with Xcode 26 and the iOS 26 SDK starting April 28, 2026 (developer.apple.com/news/upcoming-requirements/). These dates aren't about the API contract, but they do increase forced-update pressure on the binary itself — creating an additional trigger to revisit the threshold of the server-side minimum-version gate.
Conclusion
API versioning on mobile isn't a one-time technical decision, it's an ongoing discipline: additive-only design minimizes breakage, Deprecation/Sunset headers warn the client in a machine-readable way, and the minimum-version gate kicks in based on real traffic data. Moving the contract change into a server-side adapter layer instead of onto the client keeps store review time from ever standing in the way of a migration.
To go deeper on the topic: for the core principles of an API contract, see REST API design principles; for server-side performance and query cost, the database indexing and query performance guide is a good complement; for mobile backend security, API security: mobile backend covers the security side of the header-based approaches in this article. If you're using a Swift backend like Vapor on the server, Swift Vapor backend API shows the concrete setup steps; if you want to tie versioning decisions into your team process, managing technical debt on a mobile team covers how to prioritize this kind of decision.
Sources
- semver.org — Semantic Versioning 2.0.0 specification; MAJOR version defined as "incompatible API changes."
- Zalando RESTful API Guidelines — rule against URL versioning and requirement for media-type versioning.
- Microsoft Azure API Guidelines — query-parameter (
api-version) versioning and theazure-deprecatingheader definition. - Stripe Engineering Blog — API Versioning — per-account version pinning and the
Stripe-Versionheader model (2017). - RFC 8594 — The Sunset HTTP Header Field — Informational RFC, 2019.
- RFC 9745 — The Deprecation HTTP Response Header Field — IETF Proposed Standard, March 2025.
- GitHub REST API Versions — production use of the Deprecation (RFC 7231 HTTP-date) and Sunset (RFC 8594) headers, version
2026-03-10. - OpenAPI Specification v3.2.0 — 2025-09-19, added a "Versions and Deprecation" section to the spec.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

