REST API design is often cluttered by versioned URLs. Learn how to use content negotiation to manage API versioning effectively and keep your code clean.

When I was refactoring our core order-processing service last year, I spent an entire afternoon staring at a URL structure that looked like /v1/orders/v2/items. It was a mess. We had accumulated so much technical debt from rapid iterations that our endpoints were becoming unreadable, and our documentation was falling out of sync with reality.
If you’re building a REST API design that needs to survive more than a single release cycle, you’ve likely debated where to put the version number. Putting it in the URL is the most common approach, but it’s not the only one. In fact, it often forces you into a corner where you’re maintaining parallel code paths forever.
Early in my career, I was a massive fan of /v1/, /v2/ prefixes. They’re easy to cache, easy to route, and dead simple to debug with curl. However, they violate the principle that a URI should identify a resource, not a specific implementation of that resource.
When you ship a change, you’re not necessarily creating a new resource; you’re just representing the existing one differently. By forcing the version into the URI, you treat the resource as a different entity entirely. This makes it harder to apply shared API versioning strategies because your load balancers and API gateways have to be aware of every single version prefix you’ve ever launched.
Instead of polluting the URI, we started using content negotiation. It’s a standard HTTP feature that allows the client and server to agree on the representation format. By using the Accept header, we can request specific versions of a resource while keeping the URI stable.
Here is how a request looks when you use media type negotiation:
HTTPGET /orders/123 HTTP/1.1 Host: api.example.com Accept: application/vnd.mycompany.v2+json
The server inspects the Accept header. If it finds v2, it executes the logic for the second version. If the header is missing, it defaults to a stable v1 implementation. This pattern is far more elegant than URL versioning because the resource identity remains constant regardless of the structural changes underneath.
In a microservices architecture, you don't want your upstream services to know about your internal implementation details. If you change a field name in your JSON response, you don't want to force every consuming service to update their base URL.
We first tried to route requests based on a custom X-API-Version header. It worked for about two months. The problem? It wasn't standard. When we added a caching layer at the edge, the cache keys were inconsistent because the header wasn't part of the standard Vary field. We ended up serving cached v1 responses to v2 clients, which caused a production incident that took us roughly 4 hours to trace back to the CDN configuration.
Switching to Accept headers fixed this. Because Accept is a standard HTTP header, well-behaved caches automatically include it in the Vary response header. This ensures that the cache correctly distinguishes between different representations of the same URL.
When you implement content negotiation for versioning, keep these three rules in mind:
application/json. Use vendor-specific types like application/vnd.mycompany.v2+json. This clearly signals the intent and version.406 Not Acceptable status code. This is the correct HTTP way to tell the client you can't fulfill their specific representation request.I won't pretend this is perfect. Debugging is slightly harder because you can't just copy-paste a URL into a browser to see the result; you have to set the header. Also, some older proxy servers or restrictive firewalls might strip custom headers or struggle with complex Accept values.
However, the payoff is a significantly cleaner codebase. You stop thinking about "v1 endpoints" and "v2 endpoints" and start thinking about "v1 representations" and "v2 representations." It’s a subtle shift, but it forces you to design your REST API design around the resource, not the deployment timeline.
I’m still not 100% convinced this is the best approach for every team. If your clients are primarily non-technical users or simple web forms, the added complexity of setting headers might be a dealbreaker. But for internal service-to-service communication, it’s been a game changer for us. We’ve managed to deprecate two older versions of our order schema in the last year without touching a single URL path, which is a win in my book.
Master API versioning and maintain backward compatibility in your distributed systems. Learn pragmatic strategies to evolve your services without breaking clients.