Versioning a public API for a thousand tenants
The version is a promise to people you will never meet. Put it in the URL, keep changes additive, and treat a sunset date as a project rather than a header.
There is a tenant on a product I look after who has been calling /api/v1/invoices since 2018. The integration was written by a contractor who left in 2020, it runs as a scheduled task on a machine in a warehouse, and the person who owns it now knows two things about it: that it must not stop, and that nobody there can change it. They pay us every month. In 2024 I proposed turning v1 off and was talked out of it in about ninety seconds by someone from sales holding a contract.
That tenant is not an edge case. On any B2B product with a public API and more than a few hundred customers, some non-trivial fraction of your integrations are unattended software written by people who have moved on. The version number in your URL is not a technical marker. It is a promise made to strangers, and the whole design problem is arranging your code so that keeping that promise stays cheap.
Put the version in the path
I have run both schemes and I will defend the path. GET /api/v2/invoices/{id} is visible in a log line, pasteable into a browser, curlable without a flag, and unambiguous when a customer sends you a screenshot. Header versioning, with something like Api-Version: 2.0, keeps the URL clean in a way that satisfies a certain kind of REST argument and costs you every debugging session for the next six years. Azure API Management supports path, header and query string and takes no position between them, which tells you the choice is yours to make on operational grounds.
The one real argument against the path is that a resource then has two URLs, so a link in one response can point at the wrong version. Solve that by generating links from the version of the request, which is four lines in a helper, and forget about it.
Asp.Versioning, formerly Microsoft.AspNetCore.Mvc.Versioning, does the routing. It was renamed in late 2022, a few months before I first drew this sheet, and the old package names still show up in search results, which is worth knowing before you spend an hour wondering why the samples do not compile.
// .NET 9, Asp.Versioning 8.x
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
o.AssumeDefaultVersionWhenUnspecified = true;
o.ReportApiVersions = true; // api-supported-versions on every response
o.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddApiExplorer(o => o.GroupNameFormat = "'v'VVV");
var api = app.NewVersionedApi("Invoices");
var v1 = api.MapGroup("/api/v{version:apiVersion}/invoices").HasApiVersion(1.0);
var v2 = api.MapGroup("/api/v{version:apiVersion}/invoices").HasApiVersion(2.0);
v1.MapGet("/{id:guid}", async (Guid id, IInvoiceQueries q, CancellationToken ct) =>
await q.FindAsync(id, ct) is { } inv ? Results.Ok(InvoiceV1.From(inv)) : Results.NotFound());
v2.MapGet("/{id:guid}", async (Guid id, IInvoiceQueries q, CancellationToken ct) =>
await q.FindAsync(id, ct) is { } inv ? Results.Ok(InvoiceV2.From(inv)) : Results.NotFound());
ReportApiVersions is the setting people skip. It puts api-supported-versions and api-deprecated-versions on every response, which means a client library written by somebody careful can notice a deprecation without reading your changelog. Two of our integrators did exactly that.
Most changes should not be a version at all
A new version is expensive: two code paths, two sets of documentation, two things to test, and a migration to run with every customer. Reserve it. Adding a field to a response is not a breaking change if your clients ignore unknown properties, and any client that does not is going to break on the next thing anyway. Adding an optional request field, adding an endpoint, adding an enum value that only appears for newly created resources: all additive, all safe, all shipped without ceremony.
What forces a version is removing or renaming a field, changing a type, changing the meaning of an existing value, making an optional input required, or tightening validation on something you used to accept. That last one catches people. We started rejecting VAT numbers with spaces in them in 2024 and it was, technically, a bug fix. It was also a breaking change for eleven tenants, and it should have gone into v2.
The drawing is the design. Versions live in one layer and nowhere else. The domain model has no Version property, no if (v1) branches, and no fields kept only because an old contract mentions them. When the domain drops something, the v1 adapter computes it, defaults it, or returns a constant, and there is one file to read when a customer asks why v1 shows a zero.
Sunset dates, and the fact that nobody reads headers
RFC 8594 gives you a Sunset response header with a date, and Asp.Versioning has a policy builder that emits it along with a link to the migration guide. Configure it. It costs nothing and it is the machine-readable half of the announcement.
builder.Services.AddApiVersioning(o =>
{
o.Policies.Sunset(1.0)
.Effective(2026, 6, 30)
.Link("https://docs.example.be/api/v2/migrating")
.Title("Migrating from v1 to v2")
.Type("text/html");
});
Then do the human half, because in four years I have met one integrator who noticed a header. What actually moves tenants off a version is a per-tenant report of who called v1, how often, and from which client, sent to the person on the account, followed by a phone call to the ones still on it a month before the date. We built that report in 2024. It is a query over the request log grouped by tenant and version, it takes thirty seconds to run, and it retired v1 for 940 of our tenants inside five months.
The number you need before you can even have the conversation is per-tenant usage by version. Log the resolved version on every request from day one. Without it you are guessing, and guessing means you never turn anything off.
Some tenants will not move at all. The scheduled task in the warehouse will not move. Decide deliberately what you do about that, and write it down, because the decision will be made either way and you would rather make it than inherit it.
The three answers I have used: keep the version alive indefinitely and accept the maintenance, which is viable if the adapter is genuinely thin; keep it alive behind a price, as a legacy support line item, which concentrates the mind of the customer's finance department better than any email; or freeze it, meaning no bug fixes and no new fields, and say so in writing. What does not work is a sunset date that slips twice, because after the second slip nobody believes the third.
Contract tests, or you will break v1 by accident
The reason v1 breaks is never a deliberate change to v1. It is a refactor three layers down that changes a JSON property name because a domain property was renamed and the adapter used an implicit mapping. Record the responses and compare them.
[Theory]
[InlineData("v1")]
[InlineData("v2")]
public async Task Invoice_response_matches_the_recorded_contract(string version)
{
var actual = await client.GetStringAsync($"/api/{version}/invoices/{KnownInvoiceId}");
var expected = await File.ReadAllTextAsync($"Contracts/{version}/invoice.json");
Assert.Equal(
JsonNode.Parse(expected)!.ToJsonString(),
JsonNode.Parse(actual)!.ToJsonString());
}
The golden files live in the repository and changing one requires a deliberate edit in the same commit, which is exactly the friction you want. Ours has failed nine times and eight of those were somebody renaming a domain property with a refactoring tool that helpfully renamed the DTO too.
What I would set up on day one
Version in the path, ReportApiVersions on, an adapter per version even when it is a straight copy, the resolved version logged on every request, and golden-file contract tests in the same solution as the API. Then keep the second version out of existence for as long as you honestly can, because the cheapest API version to support is the one you never had to cut.
Drawn from
- Web API design best practiceslearn.microsoft.com
- dotnet/aspnet-api-versioninggithub.com
- Versions in Azure API Managementlearn.microsoft.com