API Versioning

Version controllers and minimal APIs with Asp.Versioning, choose URL, header or query versions, and retire old versions with Deprecation and Sunset headers.

Syntaxbuilder.Services.AddApiVersioning(o => { o.DefaultApiVersion = new ApiVersion(1, 0); o.ApiVersionReader = new UrlSegmentApiVersionReader(); }).AddMvc(); [ApiVersion(2.0)] [Route("api/v{version:apiVersion}/orders")]

A version is a promise: clients on v1 keep working while v2 changes the contract. Adding an optional field or a new endpoint is not a breaking change and needs no new version. Renaming or removing a field, changing a type (a total number becoming a money object) or changing meaning is. Version only for breaking changes, and keep the number of live versions small: each one is code you must test, document and secure.

The packages

Asp.Versioning.Http (10.2.3) versions minimal APIs. Controllers need Asp.Versioning.Mvc, and Asp.Versioning.Mvc.ApiExplorer adds per-version API descriptions for OpenAPI. These replace the old Microsoft.AspNetCore.Mvc.Versioning packages. Registration is AddApiVersioning(...), then .AddMvc() for controllers and .AddApiExplorer(...) for documents.

Where the version travels

ReaderRequestTrade-off
UrlSegmentApiVersionReader/api/v2/orders/7Visible in logs, links and caches; the URL of a resource changes between versions
QueryStringApiVersionReader?api-version=2.0Stable paths, easy to forget
HeaderApiVersionReader("X-Api-Version")HeaderClean URLs; invisible in access logs, the service must send Vary for it
MediaTypeApiVersionReader("v")Accept: application/json;v=2.0Most RESTful, least understood by clients

Public APIs usually take the URL segment because every developer, proxy and log line can see it. Internal services often take a header. ApiVersionReader.Combine(...) accepts several.

Behaviour you must know

  • AssumeDefaultVersionWhenUnspecified is false by default: a request with no version gets 400 with code ApiVersionUnspecified.
  • A header or query version that no endpoint supports gets 400 UnsupportedApiVersion. With URL segments, a route for /api/v9/... simply does not match, so the client sees 404.
  • ReportApiVersions = true adds api-supported-versions and api-deprecated-versions response headers.

Controllers declare [ApiVersion(1.0)] and [ApiVersion(2.0)] on the class, [MapToApiVersion(2.0)] on an action, and use {version:apiVersion} in the route. Minimal APIs use app.NewVersionedApi("Orders"), then MapGroup(...).HasApiVersion(2.0).

Deprecating and retiring

Mark a version deprecated with [ApiVersion(1.0, Deprecated = true)] or HasDeprecatedApiVersion(1.0). Policies add standard headers: options.Policies.Deprecate(1.0).Effective(date).Link(url) writes a Deprecation header (RFC 9745) with a rel="deprecation" link, and options.Policies.Sunset(1.0).Effective(date).Link(url) writes Sunset (RFC 8594) with a rel="sunset" link. Then measure: tag logs or metrics with the requested version and only remove v1 when traffic has stopped or the sunset date passes.

The production failure is moving the default. With AssumeDefaultVersionWhenUnspecified = true and DefaultApiVersion changed from 1.0 to 2.0, every unversioned client silently receives the v2 contract. Pin the default forever, or require explicit versions from day one.

Example

Example · csharp
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddControllers();

builder.Services
    .AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);
        options.AssumeDefaultVersionWhenUnspecified = false;
        options.ReportApiVersions = true;
        options.ApiVersionReader = new UrlSegmentApiVersionReader();

        // Deprecation: @<epoch> plus Link: <...>; rel="deprecation"
        options.Policies.Deprecate(1.0)
            .Effective(new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero))
            .Link("https://developer.example.com/orders/v1-deprecation")
                .Title("Orders API v1 deprecation")
                .Type("text/html");

        // Sunset: Wed, 31 Mar 2027 00:00:00 GMT plus Link: <...>; rel="sunset"
        options.Policies.Sunset(1.0)
            .Effective(new DateTimeOffset(2027, 3, 31, 0, 0, 0, TimeSpan.Zero))
            .Link("https://developer.example.com/orders/migrate-to-v2")
                .Title("Migrating to Orders API v2")
                .Type("text/html");
    })
    .AddMvc()
    .AddApiExplorer(options =>
    {
        options.GroupNameFormat = "'v'VVV";          // v1, v2
        options.SubstituteApiVersionInUrl = true;    // documents show /api/v2/orders
    });

var app = builder.Build();

app.MapControllers();

// Minimal APIs in the same app, versioned the same way.
var products = app.NewVersionedApi("Products");
var productsV1 = products.MapGroup("/api/v{version:apiVersion}/products").HasDeprecatedApiVersion(1.0);
var productsV2 = products.MapGroup("/api/v{version:apiVersion}/products").HasApiVersion(2.0);

productsV1.MapGet("/{sku}", (string sku) => new { sku, price = 39.95m });
productsV2.MapGet("/{sku}", (string sku) => new { sku, price = new Money(39.95m, "EUR") });

app.Run();

public record Money(decimal Amount, string Currency);
public record OrderV1(int Id, string CustomerId, decimal Total);
public record OrderV2(int Id, string CustomerId, Money Total, string Status);

[ApiController]
[ApiVersion(1.0, Deprecated = true)]
[ApiVersion(2.0)]
[Route("api/v{version:apiVersion}/orders")]
public sealed class OrdersController : ControllerBase
{
    // GET /api/v1/orders/1001 -> total is a number
    [HttpGet("{id:int}")]
    [MapToApiVersion(1.0)]
    public ActionResult<OrderV1> GetV1(int id) => new OrderV1(id, "CUST-1042", 79.90m);

    // GET /api/v2/orders/1001 -> total carries its currency
    [HttpGet("{id:int}")]
    [MapToApiVersion(2.0)]
    public ActionResult<OrderV2> GetV2(int id) =>
        new OrderV2(id, "CUST-1042", new Money(79.90m, "EUR"), "Paid");
}

When to use it

  • Ship /api/v2/orders with a money object for totals while existing mobile apps keep calling /api/v1/orders unchanged.
  • Announce the v1 retirement date to every client through Deprecation and Sunset headers before switching v1 off.
  • Let internal services select an inventory contract with an X-Api-Version header while keeping one stable URL.

More examples

One controller per version

Splitting versions into separate controllers keeps the deprecated contract untouched while v2 evolves, and deleting v1 later is deleting one file. Both classes share the route template; the versioning policy picks the controller by version. An <code>ApiVersion</code> action parameter receives the requested version when an action needs it, for example to log usage per version before the sunset date.

Example · csharp
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

public record CustomerV1(int Id, string Name, string Email);
public record CustomerV2(int Id, string FirstName, string LastName, string Email, string? Phone);

// Old contract frozen in its own class: nobody edits it by accident.
[ApiController]
[ApiVersion(1.0, Deprecated = true)]
[Route("api/v{version:apiVersion}/customers")]
public sealed class CustomersV1Controller : ControllerBase
{
    [HttpGet("{id:int}")]
    public ActionResult<CustomerV1> Get(int id) => new CustomerV1(id, "Ada Lovelace", "[email protected]");
}

[ApiController]
[ApiVersion(2.0)]
[Route("api/v{version:apiVersion}/customers")]
public sealed class CustomersV2Controller : ControllerBase
{
    [HttpGet("{id:int}")]
    public ActionResult<CustomerV2> Get(int id, ApiVersion apiVersion) =>
        new CustomerV2(id, "Ada", "Lovelace", "[email protected]", null);
}

Header versioning for an internal minimal API

With header versioning the path stays <code>/inventory/{sku}</code> for every version. A request without either header or query parameter gets 400 <code>ApiVersionUnspecified</code>, and <code>X-Api-Version: 3</code> gets 400 <code>UnsupportedApiVersion</code>, both as problem details. The service must send <code>Vary: X-Api-Version</code> itself (Asp.Versioning does not add it), or a shared cache will serve a v1 body to a v2 caller. The query-string form needs no <code>Vary</code> because the version is in the URL, but the same resource is then cached under two keys. <code>RequestedApiVersion</code> is an extension property on <code>HttpContext</code>.

Example · csharp
using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.ReportApiVersions = true;
    // Services send X-Api-Version; humans testing in a browser can use ?api-version=.
    options.ApiVersionReader = ApiVersionReader.Combine(
        new HeaderApiVersionReader("X-Api-Version"),
        new QueryStringApiVersionReader("api-version"));
});

var app = builder.Build();

// Asp.Versioning does not add Vary; without it a shared cache serves a v1 body to a v2 caller.
app.Use(async (context, next) =>
{
    context.Response.Headers.Append("Vary", "X-Api-Version");
    await next(context);
});

var stock = app.NewVersionedApi("Inventory");
var v1 = stock.MapGroup("/inventory").HasApiVersion(1.0);
var v2 = stock.MapGroup("/inventory").HasApiVersion(2.0);

v1.MapGet("/{sku}", (string sku) => new { sku, onHand = 12 });
v2.MapGet("/{sku}", (string sku, HttpContext http) =>
    new { sku, onHand = 12, reserved = 3, version = http.RequestedApiVersion?.ToString() });

app.Run();

What a client sees for a deprecated version

<code>Deprecation</code> uses the structured date format from RFC 9745 (an <code>@</code> and Unix seconds, here 1 October 2026), while <code>Sunset</code> uses an HTTP date. Client SDKs and API gateways can alert on these headers, which is far more reliable than an email. The second request shows the URL segment case: an unknown version matches no route and returns 404, not a versioning error.

Example · http
GET /api/v1/orders/1001 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
api-supported-versions: 2.0
api-deprecated-versions: 1.0
Deprecation: @1790812800
Sunset: Wed, 31 Mar 2027 00:00:00 GMT
Link: <https://developer.example.com/orders/migrate-to-v2>; rel="sunset"; title="Migrating to Orders API v2"; type="text/html", <https://developer.example.com/orders/v1-deprecation>; rel="deprecation"; title="Orders API v1 deprecation"; type="text/html"

{"id":1001,"customerId":"CUST-1042","total":79.90}

###

GET /api/v9/orders/1001 HTTP/1.1
Host: api.example.com

HTTP/1.1 404 Not Found

Discussion

  • Be the first to comment on this lesson.