Header versioning follows the same pattern, but reads the version from a named request header instead:
csharp
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new HeaderApiVersionReader("X-API-Version");
})
.AddMvc();
The route template stays clean without any version token:
csharp
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult GetAllV1()
{
return Ok("Version 1 products");
}
[HttpGet]
[MapToApiVersion("2.0")]
public IActionResult GetAllV2()
{
return Ok("Version 2 products with full details");
}
}
Clients send the version in the header:
http
GET /api/products
X-API-Version: 2.0
Combining Multiple Version Readers
In practice, many APIs need to support more than one strategy simultaneously, especially during a migration period. ApiVersionReader.Combine lets you specify multiple readers, and the middleware will check each one in order until it finds a version:
csharp
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-API-Version"),
new MediaTypeApiVersionReader("ver")
);
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
With this configuration, the following requests all target version 2:
http
GET /api/v2/products
GET /api/products?api-version=2.0
GET /api/products
X-API-Version: 2.0
GET /api/products
Accept: application/json;ver=2.0
This is useful when you have multiple client types. Browser-based clients might use URL paths. Partner integrations might prefer headers. Internal services might use query strings. Combining readers accommodates all of them without requiring you to choose just one.
Minimal APIs with Version Sets
Minimal APIs in .NET 10 use a different mechanism for versioning called Version Sets. Rather than decorating controllers with attributes, you define a version set that declares all the versions your minimal API supports, and then assign each endpoint to a specific version within that set.
First, install the Asp.Versioning.Http package instead of Asp.Versioning.Mvc:
bash
dotnet add package Asp.Versioning.Http
Then configure versioning without calling .AddMvc():
csharp
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
var app = builder.Build();
After building the app, define a version set and attach endpoints to it:
csharp
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.ReportApiVersions()
.Build();
app.MapGet("/api/v{version:apiVersion}/products", async (IProductRepository repo) =>
{
var products = await repo.GetAllAsync();
return Results.Ok(products.Select(p => new ProductResponseV1(p.Id, p.Name, p.Price)));
})
.WithApiVersionSet(versionSet)
.MapToApiVersion(1, 0);
app.MapGet("/api/v{version:apiVersion}/products", async (
IProductRepository repo,
bool? inStockOnly = null) =>
{
var products = await repo.GetAllAsync();
if (inStockOnly == true)
products = products.Where(p => p.StockQuantity > 0).ToList();
return Results.Ok(products.Select(p => new ProductResponseV2(
p.Id, p.Name, p.Description, p.Price,
p.Sku, p.StockQuantity, p.CreatedAt, p.UpdatedAt)));
})
.WithApiVersionSet(versionSet)
.MapToApiVersion(2, 0);
app.MapPost("/api/v{version:apiVersion}/products", async (
CreateProductRequestV2 request,
IProductRepository repo) =>
{
var product = new Product
{
Id = Guid.NewGuid(),
Name = request.Name,
Description = request.Description,
Price = request.Price,
Sku = request.Sku,
StockQuantity = request.InitialStock,
CreatedAt = DateTime.UtcNow
};
await repo.AddAsync(product);
return Results.Created($"/api/v2/products/{product.Id}",
new ProductResponseV2(
product.Id, product.Name, product.Description, product.Price,
product.Sku, product.StockQuantity, product.CreatedAt, product.UpdatedAt));
})
.WithApiVersionSet(versionSet)
.MapToApiVersion(2, 0);
app.Run();
The Version Sets approach maps cleanly to the minimal API philosophy of explicit, co-located endpoint definitions. Each endpoint declares exactly which version it belongs to, and the version set acts as the registry of all available versions for that group of endpoints.
Deprecating Old API Versions
Deprecation is the formal process of signaling to clients that a version is scheduled for removal. It is a two-step process: first you mark the version as deprecated in code to update the response headers and documentation, then you enforce an actual sunset date after which the version stops responding.
Marking a Version as Deprecated
Add Deprecated = true to the [ApiVersion] attribute on the controller:
csharp
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/v{version:apiVersion}/products")]
public class ProductsV1Controller : ControllerBase
{
}
This action alone does two things automatically when ReportApiVersions is enabled:
- The
api-deprecated-versions response header will now list 1.0
- The Swagger documentation for v1 will display a deprecation notice
Clients that check this header can show warnings to their developers or log alerts indicating that migration is needed.
Sending Sunset and Deprecation Headers
The industry standard way to communicate deprecation metadata over HTTP uses three headers defined in RFC 8594 and related specifications:
Sunset — The date and time after which the API version will no longer be available
Deprecation — The date from which the version was considered deprecated
Link — A relation link pointing to the successor version
You can add these using a simple middleware or an action filter. Here is a clean middleware approach:
csharp
app.Use(async (context, next) =>
{
await next();
var apiVersion = context.GetRequestedApiVersion();
if (apiVersion?.MajorVersion == 1)
{
context.Response.Headers.TryAdd("Sunset", "Sun, 01 Jun 2026 00:00:00 GMT");
context.Response.Headers.TryAdd("Deprecation", "Mon, 01 Jan 2026 00:00:00 GMT");
context.Response.Headers.TryAdd(
"Link",
"</api/v2/products>; rel=\"successor-version\"");
}
});
After the sunset date arrives, you replace the middleware logic to return a 410 Gone response for all version one requests, giving clients a clear indication that the endpoint has been retired rather than just returning a confusing 404.
csharp
app.Use(async (context, next) =>
{
var apiVersion = context.GetRequestedApiVersion();
if (apiVersion?.MajorVersion == 1)
{
context.Response.StatusCode = StatusCodes.Status410Gone;
await context.Response.WriteAsJsonAsync(new
{
error = "This API version has been retired.",
details = "Please migrate to v2. See: /api/v2/products",
retiredOn = "2026-06-01"
});
return;
}
await next();
});
This is far more helpful to clients than silently redirecting or returning an unexpected response shape.
OpenAPI and Swagger Integration
Generating accurate Swagger documentation across multiple API versions requires a few additional setup steps. The key is that each API version needs its own separate OpenAPI document, and the Swagger UI needs to know about all of them.
Setting Up Multi-Version Swagger Documentation
After installing both Asp.Versioning.Mvc.ApiExplorer and Swashbuckle (Swashbuckle.AspNetCore), the setup looks like this:
bash
dotnet add package Swashbuckle.AspNetCore
Create a ConfigureSwaggerOptions class that dynamically generates one Swagger document per discovered API version:
csharp
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
{
_provider = provider;
}
public void Configure(SwaggerGenOptions options)
{
foreach (var description in _provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, CreateApiInfo(description));
}
}
private static OpenApiInfo CreateApiInfo(ApiVersionDescription description)
{
var info = new OpenApiInfo
{
Title = "Product Catalog API",
Version = description.ApiVersion.ToString(),
Contact = new OpenApiContact
{
Name = "Muhammad Rizwan",
Url = new Uri("https://rizwanashraf.dev/contact")
}
};
if (description.IsDeprecated)
{
info.Description = string.Concat(
info.Description ?? string.Empty,
" This API version has been deprecated. ",
"Please migrate to a current version.");
}
return info;
}
}
Register it in your Program.cs:
csharp
builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
builder.Services.AddSwaggerGen();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
Then configure the middleware to serve all version documents:
csharp
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI(options =>
{
var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions.Reverse())
{
options.SwaggerEndpoint(
$"/swagger/{description.GroupName}/swagger.json",
$"Product Catalog API {description.ApiVersion}");
}
});
With this setup, the Swagger UI will display a dropdown that lets users switch between API version documentation instantly. Deprecated versions will show a deprecation notice in their documentation description. The URL for each auto-generated document follows the pattern /swagger/v1/swagger.json, /swagger/v2/swagger.json, and so on.

Using the Built-in OpenAPI Support in .NET 9 and 10
Starting with .NET 9, ASP.NET Core includes a first-party OpenAPI document generation package that does not require Swashbuckle. For .NET 10 projects that do not need Swashbuckle specifically, you can use Microsoft.AspNetCore.OpenApi instead:
bash
dotnet add package Microsoft.AspNetCore.OpenApi
Register a separate OpenAPI document per version:
csharp
builder.Services.AddOpenApi("v1");
builder.Services.AddOpenApi("v2");
Then map the document endpoint in middleware:
csharp
app.MapOpenApi("/openapi/{documentName}.json");
You can render this with Scalar, a modern OpenAPI UI, as an alternative to Swagger UI:
bash
dotnet add package Scalar.AspNetCore
csharp
app.MapScalarApiReference();
Both approaches produce accurate per-version OpenAPI documents. Swashbuckle remains the more widely used option in the community due to its maturity and plugin ecosystem, but the built-in approach is catching up quickly in .NET 10.
Version Selection Policies
There are a few additional options worth understanding as you tune the behavior of your versioning setup.
Neutral Versioning
Sometimes you have endpoints that should respond to any version, without requiring consumers to specify a version at all. A health check endpoint is a common example. You can decorate it with [ApiVersionNeutral]:
csharp
[ApiController]
[ApiVersionNeutral]
[Route("health")]
public class HealthController : ControllerBase
{
[HttpGet]
public IActionResult Check() => Ok(new { status = "healthy" });
}
This endpoint will be reachable regardless of which version is requested, or whether a version is included at all.
Custom Error Responses for Unsupported Versions
By default, requesting an unsupported version returns a 400 Bad Request with a generic error. You can customize this response by injecting a custom error response provider:
csharp
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.UnsupportedApiVersionStatusCode = StatusCodes.Status422UnprocessableEntity;
});
Or implement IErrorResponseProvider for fully custom error bodies. This gives you control over the exact JSON shape returned when a client requests a version that does not exist or has been retired.
Testing Your Versioned API
A versioned API requires slightly different testing strategies to make sure every version behaves as expected both independently and in relation to each other.
Integration Tests
Using WebApplicationFactory<TProgram> from Microsoft.AspNetCore.Mvc.Testing, you can write integration tests that target specific versions:
csharp
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetProductsV1_Returns_SimpleShape()
{
var response = await _client.GetAsync("/api/v1/products");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<List<ProductResponseV1>>();
Assert.NotNull(json);
Assert.All(json, p =>
{
Assert.NotEqual(Guid.Empty, p.Id);
Assert.NotEmpty(p.Name);
Assert.True(p.Price > 0);
});
}
[Fact]
public async Task GetProductsV2_Returns_FullShape_With_Stock()
{
var response = await _client.GetAsync("/api/v2/products");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<List<ProductResponseV2>>();
Assert.NotNull(json);
Assert.All(json, p =>
{
Assert.NotEmpty(p.Sku);
Assert.True(p.StockQuantity >= 0);
});
}
[Fact]
public async Task GetProducts_ReturnsVersionHeaders()
{
var response = await _client.GetAsync("/api/v2/products");
Assert.True(response.Headers.Contains("api-supported-versions"));
var supported = response.Headers.GetValues("api-supported-versions").First();
Assert.Contains("2.0", supported);
}
}
Testing with .http Files
.NET's built-in HTTP file support in Visual Studio and JetBrains Rider makes it easy to manually test each version. Create a ProductsApi.http file in your project:
http
@baseUrl = https://localhost:5001
### Get all products - Version 1
GET {{baseUrl}}/api/v1/products
Accept: application/json
### Get all products - Version 2
GET {{baseUrl}}/api/v2/products
Accept: application/json
### Get version 2 with stock filter
GET {{baseUrl}}/api/v2/products?inStockOnly=true
Accept: application/json
### Test header versioning (if configured)
GET {{baseUrl}}/api/products
X-API-Version: 2.0
Accept: application/json
### Test deprecated version response headers
GET {{baseUrl}}/api/v1/products
###
# Inspect the response headers for:
# api-deprecated-versions: 1.0
# Sunset: ...
# Deprecation: ...
Running these requests directly from your IDE gives you rapid feedback without needing a separate tool.