Once you've decided a change is breaking enough to need a version (the harder decision, and a separate one), there are really only four mechanisms in common use to expose that version to clients, and the debate over which is "most RESTful" is mostly beside the point — what matters is which one fits how your actual consumers integrate, debug, and cache.
URI path versioning: the pragmatic default
/v1/invoices versus /v2/invoices is the most common scheme in production APIs, for reasons that have nothing to do with REST purity and everything to do with practicality: the version is visible in every log line, every browser address bar, every curl command copy-pasted into a support ticket, and every CDN or reverse-proxy cache key without needing a Vary header. The cost is that, strictly speaking, /v1/invoices/4471 and /v2/invoices/4471 are different URIs for what's conceptually the same resource, which is the objection purists raise — in practice, almost nobody outside an API design mailing list cares.
GET /v2/invoices/4471 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
{"id": 4471, "status": "open", "total_cents": 152000}
Query parameter versioning: same resource identity, easy default fallback
/invoices/4471?version=2 keeps one canonical URI per resource (arguably more correct in REST terms than path versioning) while still being visible and debuggable in plain sight. It's a reasonable choice when you want an obvious default (omit the parameter, get the latest or a documented default version) without the URI structure itself branching by version. The trade-off is that query parameters are more commonly stripped or normalized by caches and proxies than path segments, so you need Vary or cache-key configuration more carefully than with path versioning.
Custom header versioning: clean URIs, invisible to casual debugging
A header like Api-Version: 2 keeps the URI completely clean and stable across versions, which some teams prefer philosophically since the resource's identity never changes. The real-world cost is that the version becomes invisible to anyone glancing at a URL, a server log line, or a curl command shared in Slack without also sharing the headers — which makes debugging a support ticket measurably slower in practice, even though the design is arguably cleaner on paper.
GET /invoices/4471 HTTP/1.1
Api-Version: 2
Accept: application/json
Media-type (Accept header) versioning: most correct, least adopted
Accept: application/vnd.example.v2+json reuses HTTP's actual content negotiation mechanism rather than inventing a bespoke header, which is the strongest argument for it on architectural grounds — the version is a property of the representation being requested, exactly what Accept was designed to negotiate. It shares custom headers' debuggability downside and adds its own: many HTTP clients, proxies, and API testing tools have poor ergonomics for setting custom Accept values compared to a simple path segment or query parameter, which is the main reason this technically-cleanest option is also the least commonly adopted in practice.
An API that silently serves "the latest version" when no version is specified will break every unversioned client the moment you ship a breaking change. Require an explicit version on every request (with a clear default that's documented and doesn't change), or your effective policy is that you're never allowed to ship a breaking change without also breaking silent, unversioned callers.
Picking one based on who's actually calling
URI path versioning wins for public APIs with a wide, uncoordinated consumer base, because debuggability and cacheability matter more than architectural purity at that scale. Header or media-type versioning is more defensible for internal APIs between teams you can coordinate directly, where the URI's conceptual cleanliness has real value and the debugging cost is lower because your own team already knows to check headers.
An API that versions some endpoints by URI path and others by header, added inconsistently over time as different teams built different parts, is harder to document and harder for consumers to reason about than either scheme applied consistently. Pick one at the start and apply it everywhere.
Wrapping up
URI path versioning wins on debuggability and caching for most real-world API consumer bases, despite being the least "pure" option architecturally; header and media-type versioning are more defensible for tightly coordinated internal APIs. The mechanism matters far less than picking one and applying it consistently across the whole API.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.