DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

The Burden of API Versioning: URI or Header?

API versioning is an inevitable necessity, especially for long-lived and continuously evolving systems. When you update an API in a way that breaks backward compatibility, you must also consider existing clients. At this point, the most fundamental question we face is: Where should we place the version information? In this article, I will address the fine lines between the two most common approaches—URI-based versioning and Header-based versioning—using concrete examples from my own field experience.

While working on a production ERP system we developed, we needed to redesign the supply chain module. The existing APIs had to support legacy versions while seamlessly delivering new features. During this process, we deeply debated which versioning strategy would be more suitable for us. In this article, I will share the thought process behind this decision and the practical outcomes of both methods.

The Fundamental Importance and Emergence of API Versioning

The evolution of an API over its lifecycle is a natural process. New features are added, existing functionalities are improved, or security vulnerabilities are patched. However, these changes can pose risks for existing clients using the API. If a change breaks backward compatibility, existing clients may suddenly stop working. This situation causes disruptions in workflows and places a significant burden on the development team. This is exactly where API versioning comes into play.

Versioning allows us to manage different states of the API. Two main strategies stand out: "gradual rollout" and "preventing surprise breaking changes." When you version an API, existing clients can continue to use the older version, while new or updated clients can adopt the new version. This makes the transition process manageable and minimizes risks. For example, distinct versions like v1 and v2 give clients a clear signal of what API behavior to expect.

ℹ️ What is Backward Compatibility?

Backward compatibility means that new versions of a system can work compatibly with previous versions. In the API world, this means that a new API version can still be used validly by legacy clients. Versioning is a way to manage this compatibility.

The goal of API versioning is to make this transition seamless. Versioning an API is essentially creating a collection of different "snapshots" of that API. Each new version is typically released when it contains changes that break backward compatibility. This gives developers and system administrators the ability to know which version of the API they are using and act accordingly.

URI-Based Versioning: The Classic and Common Approach

URI (Uniform Resource Identifier) based versioning is one of the most common methods encountered in API versioning. In this approach, version information is directly embedded as part of the API endpoint. The most common patterns are:

  • /api/v1/users
  • /api/v2/products
  • /v1/orders

This method explicitly specifies the version through the URL itself. Therefore, it is easy for humans to read and understand. A client can clearly see which API version they are sending a request to from the URL. For example, a request made to https://api.example.com/v1/users indicates that you want to access the first version of the users resource.

The biggest advantage of this approach is its simplicity. It is easy to implement and understand. It can be accessed effortlessly even with a web browser or a simple curl command. Additionally, the fact that many API gateways and proxies can automatically route these types of URL structures provides operational convenience. Tools like nginx or Traefik can route to different backend services based on these URLs.

However, URI-based versioning also has some disadvantages. The most significant is that it blurs the identity of the resources themselves. For example, the users resource exists in both v1 and v2, but they differ in their URLs. This can complicate API documentation and make it harder for clients to manage different versions.

⚠️ Potential Issues of URI Versioning

URI-based versioning can complicate the URL structure, especially when there are many versions or when the resource identity gets mixed up with the version. This can also negatively impact caching strategies. Since the same resource in different versions will have different URLs, it can lower the cache hit rate.

Another disadvantage is that creating a separate URL structure for each new version can make the overall API design look "cluttered." If your API has a large number of endpoints, adding prefixes like v1, v2, v3 to each endpoint can make URLs very long and reduce readability. In the API of a financial analysis platform we developed, we initially used this method. However, over time, long URLs and management difficulties arose, especially in communication between internal services.

Header-Based Versioning: A Cleaner Approach

Header-based versioning aims to keep the API design cleaner by removing version information from the URI. In this method, version information is added to the header section of the HTTP request. The most commonly used headers are:

  • A media-type-based approach using the Accept header, such as application/vnd.example.v1+json.
  • Using a custom header, for example, X-API-Version: 1 or Api-Version: 2.

The biggest advantage of this approach is that it keeps the core resource structure of the API cleaner. All versions can share the same URI, for example, https://api.example.com/users. The version information is passed in the header. This simplifies API documentation and makes resource identities more consistent. The users resource is always located under https://api.example.com/users; how it is accessed (with which version) is determined solely by the header.

💡 Versioning with Accept Header

Using the Accept header is considered a more RESTful approach. The client specifies which media type and version it wants from the server. For example, as Accept: application/json; version=2 or more commonly Accept: application/vnd.company.app.v2+json. This is part of Content Negotiation.

Another important advantage of header-based versioning is that it can be more beneficial for caching. Because all versions share the same URI, HTTP caching mechanisms can be used more effectively. If headers like Cache-Control are configured correctly in a request, even requests for different versions to the same URI can be served from the cache. This can increase performance, especially for frequently accessed and unchanging data.

However, header-based versioning has its own challenges. The most prominent disadvantage is that this method is not as intuitive to understand and implement as the URI-based method. Managing these headers with browsers or simple curl commands is not as easy as changing a URL. This can make it difficult for developers to quickly test or explore the API.

There are also differences between using a custom header (X-API-Version: 1) and using the Accept header. While custom headers are more flexible, using the Accept header is more compliant with standards. In our own projects, while developing a mobile app, we used these headers in the backend API we developed with Flutter. We specifically experimented with versioning via the Accept header, but we encountered cases where proxies on some older mobile devices or network layers could not process these headers correctly. This led to inconsistencies in API access.

Trade-offs: In Which Scenario Does Which Make More Sense?

The choice of API versioning strategy depends on the specific requirements of the project, the experience of the team, and the target audience. Both approaches have their own advantages and disadvantages, so there is no single "best" solution.

URI-based versioning can be a good starting point, especially for public APIs. Because it is easy to understand, and the fact that clients can clearly see which version they are using from the URL simplifies the debugging process. Considering that customers integrate with different systems in an order tracking API opened to the outside by a manufacturing firm, URI-based versioning can ensure they encounter fewer surprises.

# Example: GET request with URI-based versioning
curl -X GET "https://api.example.com/v2/products?category=electronics" \
     -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Enter fullscreen mode Exit fullscreen mode

On the other hand, header-based versioning may be more suitable, especially for communication between internal services or in a more controlled ecosystem. This approach keeps the core resources of the API cleaner and more organized. Moreover, when the Accept header is used, it offers a more standards-compliant solution. In a microservice architecture, when services talk to each other, passing version information in the header allows services to better protect their internal structures. In our internal ERP system, we preferred this method for API calls that different modules made to each other.

ℹ️ Header-Based Versioning Example

Using a custom header (X-API-Version) for versioning can also be preferred to avoid the complexity of the Accept header. However, in this case, because a standard convention is not followed, it is important that the documentation is very clear.

# Example: Versioning with Custom Header
curl -X GET "https://api.example.com/users" \
     -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     -H "X-API-Version: 2"

Things to consider when making a choice:

  1. Target Audience: Who will use the API? The internal team or external developers?
  2. API Complexity: How many endpoints does the API have? How often is it expected to change?
  3. Ease of Documentation: Which approach is easier to document and understand?
  4. Caching Strategies: How important is caching in the API?
  5. Proxy and Gateway Support: Which method does the existing infrastructure support better?

By evaluating these factors, you can determine the most appropriate versioning strategy for your project.

Practical Challenges and Solutions of URI Versioning

The simplicity of URI-based versioning can lead to serious practical challenges in some cases. One of the most important issues is that the overall URL structure of the API can become very complex over time. If an API has v1, v2, v3, and even more granular versions like v1.1, v2.0.1, URLs can become illegible. For example, in the APIs of a large telecom company I once worked at, there were hundreds of endpoints and multiple versions for each. This made things difficult for both developers and operations teams.

Another major issue is related to caching. According to HTTP standards, different URLs are treated as different resources. Since each version has its own URL in URI-based versioning, requests for GET /api/v1/users and GET /api/v2/users are evaluated as completely different requests by caching mechanisms. This can lead to retrieving the same data repeatedly across different versions, preventing the effective use of cache.

⚠️ Caching Issues in URI Versioning

URI-based versioning can complicate caching strategies, especially for frequently updated APIs or APIs with many versions. Because different versions are located at different URLs, cache hit rates can drop, leading to performance issues.

There are some solutions to deal with these problems. First, it is important to choose the versioning strategy carefully and proceed with as few major versions (v1, v2 etc.) as possible. If there are minor changes that require backward compatibility, these can be handled with optional parameters or the PATCH method instead of releasing a new major version.

Secondly, it can be beneficial to establish smart routing mechanisms at the API gateway or proxy level. For example, tools like nginx or Traefik can route requests to different service instances based on specific URL patterns. In some cases, it is even possible to extract the version information from the URL and convert it into a header when passing it to the backend service. This offers a familiar URI structure for clients while allowing backend services to use a cleaner versioning mechanism.

For example, a rule can be defined in an API Gateway as follows:

  1. Incoming request: GET /api/v2/orders
  2. The gateway detects v2 in the URL.
  3. It forwards the request to the backend service with the X-API-Version: 2 header.

This hybrid approach can combine the advantages of both methods. However, one must not forget that such extra layers can increase system complexity and operational overhead.

The Depths of Header Versioning and Key Considerations

While header-based versioning makes API design more elegant, it harbors important points to consider within itself. One of the most common challenges is the lack of simplicity on the client side. When a developer goes directly to https://api.example.com/users in the browser, they may not know which version will return by default. If the server returns a default version (usually the latest), this may not always exhibit the desired behavior. Therefore, setting the Accept header or custom version header correctly is of critical importance.

When developing our own mobile application, while making our backend services talk with Flutter, we sometimes preferred to use a custom Api-Version header instead of the Accept header. The reason for this was that some third-party libraries modified or ignored the Accept header in unexpected ways. Custom headers offered a more reliable control mechanism in such cases. However, because this was not a standard solution, it always had to be clearly specified in the documentation.

💡 Versioning with Content Negotiation

Versioning done with the Accept header is based on Content Negotiation principles. The server understands which format and version the client wants from the Accept header and returns the most appropriate response. This is a powerful approach for RESTful API design.

Another important issue is version management on the server side. In header-based versioning, you need to develop logic on the server side to separate requests coming to the same URI according to different versions. This can typically be done by an API gateway, reverse proxy, or directly by the application itself. For example, if you are using Express.js with Node.js, you can check the header in the request and route to different controllers or handlers.

// Example: Header-based versioning with Express.js
const express = require('express');
const app = express();

app.get('/users', (req, res) => {
  const apiVersion = req.headers['x-api-version'];

  if (apiVersion === '2') {
    // v2 logic
    res.send('Response from v2');
  } else if (apiVersion === '1') {
    // v1 logic
    res.send('Response from v1');
  } else {
    // Default to latest or error
    res.status(400).send('Invalid API version');
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));
Enter fullscreen mode Exit fullscreen mode

This kind of code is simple but effective. However, as the API grows, this if-else structure can become unmanageable. At this point, it may be necessary to adopt a more modular approach, such as organizing versions into separate modules or using a versioning middleware. In the backend of an e-commerce platform we developed, we managed versions using such a middleware. This kept the code cleaner and made adding new versions easier.

Beyond the Versioning Strategy: Naming and Lifecycle

API versioning is not limited only to the question of URI or header. A successful versioning strategy also requires good naming practices and the ability to manage the lifecycle of versions.

Regarding naming, a simple and clear structure like v1, v2 is usually best. Applying semantic versioning (SemVer) principles like v1.0, v1.1 to the API can also be considered. However, applying SemVer in APIs is less common than in software packages and can sometimes create unnecessary complexity. Usually, starting a new major version (v2) when there is a breaking change is sufficient.

ℹ️ SemVer and APIs

SemVer (Semantic Versioning) is a versioning scheme (MAJOR.MINOR.PATCH) typically used for software packages. It can be adapted for APIs, but major version changes (MAJOR) should generally be reserved for backward-incompatible situations.

Managing the lifecycle of versions is also critical. When an API version is released, there should be a clear strategy on how long it will be supported. When legacy versions will be deprecated and when they will be retired completely should be planned in advance. This information must be clearly stated in the API documentation, and clients should be given sufficient transition time.

For example, in a financial services API, we planned to deprecate version v1 after two years. When we announced this decision, we gave clients 6 months to transition to v2. During this process, we regularly sent reminders both via email and through the API's error messages. This kind of proactive communication ensures that clients make a seamless transition and prevents sudden outages.

Finally, regardless of the versioning strategy chosen, robust API documentation is a must. Clients need to clearly understand which version offers which features, which changes break backward compatibility, and when old versions will stop being supported. Tools like Swagger/OpenAPI are a great way to automate and standardize this documentation process.

Conclusion: Making a Pragmatic Choice

API versioning is a complex but mandatory part of modern software development. While URI-based versioning stands out with its simplicity and readability, header-based versioning offers the advantages of keeping API design clean and caching. Based on my own experience, I can say that weighing the pros and cons of these two approaches carefully, keeping your project's specific requirements in mind, is the most accurate path.

Whether developing the backend of a production ERP system or designing the API of a mobile application, the challenges we faced always taught us to be pragmatic. URI-based versioning can offer a starting point with fewer surprises, especially for public APIs exposed to the outside world. However, as API complexity increases or caching becomes critical, header-based or hybrid approaches can become more appealing.

The important thing, regardless of the strategy you choose, is to apply it consistently, provide clear documentation, and carefully manage the lifecycle of older versions. Remember, the best versioning strategy is the one that you and those working with you can use most comfortably and efficiently. In this process, you will find the most suitable solution by experimenting and learning.

Top comments (0)