ASP.NET Core Web API Versioning: URL, Query String and Header with Asp.Versioning
To ship breaking changes without breaking old clients, we build a two-version API with Asp.Versioning.Mvc. We try URL, query string and header readers, plus deprecation and the Sunset header.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
At the end of the previous post we were left with a question: when you need to change the shape of ProductDto, how do you avoid breaking the mobile app that depends on the old one? The answer of this post is API versioning.
First, a guess. In a moment we will write two controllers that share the same route, api/products: one is version 1.0, the other 2.0. An old version of the mobile app sends a GET api/products request without any version information. What happens? A 500 because the two controllers collide, 2.0 because it is the newer one, or 1.0?
Why Versioning?
Not every change breaks the client. Adding a new field to a response is usually safe; well-written clients ignore fields they do not recognize. But removing a field, renaming it or changing its type is a breaking change. In our example price is currently a plain number. To support multiple currencies we want to turn it into an object such as { "amount": 1450, "currency": "TRY" }. If you make this change directly, every client that expects the price to be a number breaks.
You can update a web app with a single deploy, but mobile app users may not install the update for weeks. Think of how a city does not close the old road on the same day it opens a new one: for a while both stay open, and a sign at the entrance of the old road says "this road closes on June 1". Versioning is exactly that: putting the new contract next to the old one, keeping the old one alive for a while and announcing in advance that it will close.
The Package and Its Versions
ASP.NET Core has no versioning built in; for that we use the Asp.Versioning.Mvc package under the .NET Foundation. The Microsoft.AspNetCore.Mvc.Versioning package you will see in older projects stopped at 5.1.0 and is marked as deprecated on NuGet; its description says the new package has been available since August 2022 and that you should migrate.
The new package's major version follows the .NET version. When I looked at the package definitions on NuGet, the picture was this: 6.4.1 targets netcoreapp3.1 and net6.0, 8.1.1 targets net8.0, and 10.2.1 targets only net10.0. So we add it to a .NET 6 project with an explicit version:
dotnet add package Asp.Versioning.Mvc --version 6.4.1
dotnet add package Asp.Versioning.Mvc.ApiExplorer --version 6.4.0
The second package carries version information into the API Explorer; we will need it on the Swagger side in the next post. In the project where I compiled this post's code for both .NET 6 and .NET 10, the two version groups look like this:
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
<PackageReference Include="Asp.Versioning.Mvc" Version="6.4.1" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="6.4.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
</ItemGroup>
The C# code below compiled on both versions without a single change or warning.
Program.cs Settings
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services
.AddApiVersioning(options =>
{
// If no version is specified, assume 1.0
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
// Report supported and deprecated versions in response headers
options.ReportApiVersions = true;
// The version can be read from the URL, the query string or a header
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-Api-Version"));
options.Policies.Sunset(1.0)
.Effective(new DateTimeOffset(2027, 6, 1, 0, 0, 0, TimeSpan.Zero))
.Link("https://example.com/api/versioning-policy")
.Title("API versioning policy")
.Type("text/html");
})
.AddMvc()
.AddApiExplorer(options =>
{
// Group names such as "v1", "v2" in Swagger
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var app = builder.Build();
app.MapControllers();
app.Run();
AddApiVersioning registers the core services, and .AddMvc() wires them into controllers; it is the line people coming from the old package forget most often. We will talk about the sunset policy in a moment.
The answer to the opening guess is in the AssumeDefaultVersionWhenUnspecified line. A request without version information counts as 1.0, and the old mobile app keeps getting the old format without noticing anything. When I deleted this line and sent the same request again, I got a 400:
{"type":"https://docs.api-versioning.org/problems#unspecified","title":"Unspecified API version","status":400,"detail":"An API version is required, but was not specified.","traceId":"00-f71f31360a64ea7a91a0f0b1174739ba-ee32c0d3e32ae3b7-00","code":"ApiVersionUnspecified"}
In a new API this strictness can be a good thing; it forces clients to send a version from day one. But if you are adding versioning to an API that is already live and called without a version, you have to set the default version to match the existing behavior.
Two Versions, Two Controllers
We keep the versions as two controllers with the same name in separate namespaces. Version 1.0:
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Controllers.V1;
public record ProductDto(int Id, string Name, decimal Price);
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/products")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IEnumerable<ProductDto> GetAll() => new[]
{
new ProductDto(1, "Mechanical Keyboard", 1450m),
new ProductDto(2, "Wireless Mouse", 600m)
};
}
Version 2.0 returns the price as an object:
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Controllers.V2;
public record Money(decimal Amount, string Currency);
public record ProductDto(int Id, string Name, Money Price);
[ApiController]
[ApiVersion("2.0")]
[Route("api/products")]
[Route("api/v{version:apiVersion}/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IEnumerable<ProductDto> GetAll() => new[]
{
new ProductDto(1, "Mechanical Keyboard", new Money(1450m, "TRY")),
new ProductDto(2, "Wireless Mouse", new Money(600m, "TRY"))
};
}
Normally two controllers sharing the same route means an "ambiguous match" error. The versioning package adds the version to route matching and decides by itself which controller to pick. {version:apiVersion} is a route constraint; it reads the version part of the URL. The response to GET api/v2/products:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
api-supported-versions: 2.0
api-deprecated-versions: 1.0
[{"id":1,"name":"Mechanical Keyboard","price":{"amount":1450,"currency":"TRY"}},{"id":2,"name":"Wireless Mouse","price":{"amount":600,"currency":"TRY"}}]
Thanks to ReportApiVersions every response carries two headers: api-supported-versions lists the supported versions, and api-deprecated-versions lists the ones nearing the end of their life. Because we marked 1.0 as deprecated, only 2.0 appears in the supported list; a client developer sees at a glance which version to move to.
URL, Query String or Header?
With ApiVersionReader.Combine we opened all three ways at once. All of them returned the same 2.0 response:
curl http://localhost:5080/api/v2/products
curl "http://localhost:5080/api/products?api-version=2.0"
curl http://localhost:5080/api/products -H "X-Api-Version: 2.0"
The URL segment is the most visible: the version jumps out in the browser, in logs and in documentation, and caches see different versions as different addresses. The price is that one resource has more than one URL. The query string keeps the same URL and is easy to test. A header keeps the URL completely clean, but it is harder to try in a browser and caches have to vary by the header. There is no single right answer; what matters is that the team picks one and uses it consistently. For public APIs I prefer the URL segment.
Combining the ways has a side effect: if the client sends two different versions in two different places, the package does not guess:
curl "http://localhost:5080/api/products?api-version=1.0" -H "X-Api-Version: 2.0"
{"type":"https://docs.api-versioning.org/problems#ambiguous","title":"Ambiguous API version","status":400,"detail":"The following API versions were requested: 1.0, 2.0. At most, only a single API version may be specified. Please update the intended API version and retry the request.","traceId":"00-7ff54f9d48bd56b1c27ba8aa5c793adb-7a83cd855c71f957-00","code":"AmbiguousApiVersion"}
When a version that does not exist is requested, the answer depends on the way. For ?api-version=3.0 I got a 400 ProblemDetails containing "code":"UnsupportedApiVersion". api/v3/products, on the other hand, returned a 404 with an empty body; since the route constraint does not recognize 3.0, there is no matching endpoint.
Deprecation and Sunset
[ApiVersion("1.0", Deprecated = true)] does not shut the version down; it only says "this version is approaching the end of its life". The version keeps working and is listed as deprecated in the headers. To say when it will close, we used the sunset policy in Program.cs. Every response to 1.0 now gets two more headers:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
api-supported-versions: 2.0
api-deprecated-versions: 1.0
Sunset: Tue, 01 Jun 2027 00:00:00 GMT
Link: <https://example.com/api/versioning-policy>; rel="sunset"; title="API versioning policy"; type="text/html"
The Sunset header is defined in RFC 8594; the Link header points the client to the migration guide. These two headers are not on 2.0 responses.
I fell into a trap live here. On the first try I gave the date with the .Effective(2027, 6, 1) overload, and the header came back as Sunset: Mon, 31 May 2027 21:00:00 GMT. This overload interprets the date in the server's local time zone; since my machine is at UTC+3, it shifted by three hours. So that two servers in different time zones do not announce different dates from the same code, we give the date as a DateTimeOffset, in UTC.
Deprecation is a communication tool. Teams that log or monitor headers on the client side notice it automatically. But the real work is on the human side: the announcement, the migration guide, and logs showing which clients still call 1.0. Watch 1.0 traffic until the shutdown date; do not shut it down before it gets close to zero.
Common Pitfalls
Installing the old package. Microsoft.AspNetCore.Mvc.Versioning still shows up in search results; in a new project use Asp.Versioning.Mvc. The namespace changed too: Asp.Versioning.
Forgetting the .AddMvc() line. The core services are registered, but controllers do not see the version information.
Changing the default version later. The day you set DefaultApiVersion to 2.0, every old client that sends no version suddenly gets the new format. The default version should stay tied to the oldest and most widely used contract.
Opening a new version for every small change. Adding a new field is not breaking. The more versions you have, the more code paths you maintain; save new versions for changes that really break the contract.
Missing small differences between package versions. When I ran the same code on .NET 10 with 10.2.1, the bodies of the 400 responses came back empty; the newer versions expect the AddProblemDetails() registration that arrived with .NET 7 in order to produce ProblemDetails, and adding it brought the body back. 10.2.1 also has a Deprecate policy that produces the Deprecation header from RFC 9745. Neither exists on .NET 6; we will see them in the upcoming version posts.
A Small Experiment
★☆☆ Add a GET api/v2/products/{id} action to the 2.0 controller. See what comes back when you send the same request as api/v1/products/1 and explain why.
★★☆ Carry two versions in a single controller: add both [ApiVersion("1.0")] and [ApiVersion("2.0")] to the controller and mark the action that behaves differently with [MapToApiVersion("2.0")].
★★★ Write a small middleware or filter that counts 1.0 calls: read the requested version with HttpContext.Features.Get<IApiVersioningFeature>()?.RequestedApiVersion (this way compiles on both 6.4.1 and 10.2.1) and log the client's User-Agent on every 1.0 request. Make the shutdown decision with this data.
The API now has more than one contract, and client developers need to know each of them. The headers give the list of versions, but they do not describe the endpoints or the request and response schemas. In the next post we set up Swagger/OpenAPI documentation with Swashbuckle and show the v1 and v2 groups prepared by the AddApiExplorer setting as separate Swagger documents.