# API Versioning

Course: [API](https://www.devtutorials.ai/learn/building-apis) · Section: Production-Ready APIs
Source: https://www.devtutorials.ai/learn/building-apis/production-ready/api-versioning

> Change an API without breaking its clients: spot breaking changes, serve /v1 and /v2 side by side, and retire old versions with Deprecation and Sunset headers.

**API versioning** allows you to improve an API without breaking existing applications that depend on it. When a change would cause older clients to stop working, you create a new version of the API instead of changing the existing one. Existing clients continue using the old version, while newer clients can move to the new version when they are ready.

This approach helps you evolve your API safely while maintaining compatibility with applications that are already in production.

## Watch One Rename Break a Partner

A developer decides to rename `customerName` to `name`, because it looks cleaner. Press **Run**.

The response still looks correct. The server still returns: `200 OK`

However, the delivery partner's application expects the field `customerName`. When that field disappears, the application cannot find the customer name.

As a result, delivery labels print with blank names. The server appears healthy, and no errors appear in the logs, but the application of the partner is broken.

**In simple words:** even a small improvement can break someone else's application if it changes the API contract.

## TL;DR

- A **breaking change** causes existing clients to fail.
- Examples of breaking changes include renaming fields, removing fields, changing field types, removing endpoints, and adding required request fields.
- A **safe change** adds functionality without breaking clients.
- Examples of safe changes include adding optional fields, adding new endpoints, and adding optional parameters.
- Put breaking changes into a new version.
- Keep older versions working for existing users.
- Notify clients before removing an old version.
- Use **Deprecation**, **Sunset**, and **Link** headers to guide migrations.
- Use automated tools to detect breaking changes before release.

## A Real-World Example

Think about a city bus stop. The city wants to move the stop to a new location.

If the old stop disappears overnight, passengers wait in the wrong place, people miss the bus, and complaints increase.

A better approach is:

1. Create the new stop.
2. Add signs showing the future location.
3. Keep both stops available temporarily.
4. Remove the old stop later.

API versioning works the same way. You release a new version, notify users, and allow time for migration before removing the old version.

## What You Will Learn

In this lesson, you will learn:

1. Which changes are breaking changes.
2. Which changes are backward compatible.
3. How to run multiple API versions together.
4. How to notify clients about upcoming changes.
5. How to detect breaking changes automatically.

## Breaking and Non-Breaking Changes

### Breaking Changes

These changes can cause existing clients to fail: renaming a field, removing a field, changing a field type, removing an endpoint, renaming an endpoint, adding a required request property, and changing the response structure.

Examples: `{"customerName": "Asha"}` changed to `{"name": "Asha"}` is breaking. `{"total": 450}` changed to `{"total": "450"}` is also breaking.

### Safe Changes

These changes are usually backward compatible: adding optional fields, adding new endpoints, adding optional query parameters, and adding new response fields.

Example: before the change, the response is `{"id": 101, "customerName": "Asha"}`. After the change, it is `{"id": 101, "customerName": "Asha", "phone": "555-1234"}`.

Most existing clients continue working because they ignore unknown fields.

## Two Versions Side by Side

Instead of changing an existing API version, create a new version.

Version 1 returns: `{"customerName": "Asha", "total": 450}`

Version 2 returns: `{"name": "Asha", "total": {"amount": 450, "currency": "INR"}}`

Both versions can read the same underlying order data. The benefits are that existing clients continue using `/v1`, new clients can adopt `/v2`, and migration happens gradually.

In ASP.NET Core, route groups such as these help keep versions separate: app.MapGroup("/v1") and app.MapGroup("/v2"). Larger projects often use dedicated API versioning libraries, but the basic idea stays the same.

## Tell Clients Before Removing a Version

API versions should not remain forever. When a version is going away, tell clients directly in API responses.

Common headers include:

| Header | Purpose |
|---|---|
| `Deprecation` | Indicates that the version is deprecated |
| `Sunset` | Specifies when support ends |
| `Link` | Points to the replacement version |

Example headers: `Deprecation: @1782864000`, `Sunset: Fri, 01 Jan 2027 00:00:00 GMT`, and `Link: </v2/orders>; rel="successor-version"`

This tells the client that the current version is deprecated, that support will end on a specific date, and that a newer version exists.

Track which clients still use deprecated versions. Contact them well before the Sunset date, so that they have time to move safely.

## Versioning with Headers

Some APIs keep the same URL and let clients choose a version through a header.

Example header: `X-Api-Version: 2`

Possible behavior: with no header, the API uses Version 1. With `X-Api-Version: 2`, it uses Version 2. With an unknown version, it returns `400 Bad Request`.

### URL Versioning

Examples: `/v1/orders` and `/v2/orders`

Advantages: it is easy to see, easy to document, and easy to test.

### Header Versioning

Example: `X-Api-Version: 2`

Advantages: the URLs are cleaner, and the routes are easier to manage.

Choose one approach and apply it consistently across the API.

## Detect Breaking Changes Automatically

Developers often forget that a change is breaking. Automated tools help identify dangerous changes before release.

Typical checks include removed fields, renamed fields, type changes, removed endpoints, and changed request requirements.

Example report: `BREAKING: customerName removed`, `BREAKING: total changed from number to object`, `SAFE: phone added`, and `SAFE: email added`

Automated checks reduce the risk of accidentally breaking client applications.

Adding a required request field is also a breaking change. Older clients do not send the new field, so their requests start failing at once. Prefer optional fields with sensible default values.

## Migration Strategy

A typical version migration follows these steps:

1. Create a new API version.
2. Keep the old version running.
3. Add deprecation notices.
4. Monitor usage.
5. Help clients migrate.
6. Remove the old version after the Sunset date.

This approach gives clients enough time to update their applications without service interruptions.

## Where You See This

- Payment APIs often support old versions for many years.
- Mobile applications frequently depend on versioning because old app versions remain installed on devices.
- Public APIs publish migration guides and deprecation schedules.
- Large organizations use automated compatibility checks before deployment.

## Common mistakes

| Symptom | Cause | Fix |
|---|---|---|
| The app of a partner shows blank fields | A field was renamed in place | Keep the old name in v1, and rename it in v2 |
| Old app versions crash after a release | A field type changed | Treat type changes as breaking |
| Requests suddenly fail with `400` | There is a new required request field | Make new fields optional |
| A version is turned off and partners are angry | There was no warning | Send Deprecation and Sunset headers early |

## Exercises

## What to Remember

- **Breaking** changes remove, rename or retype. **Safe** changes add.
- Ship breaking changes in a new version, and keep the old one working.
- Warn clients with the **Deprecation**, **Sunset** and **Link** headers.
- Check every change automatically.

**Next:** API Authentication. You will make sure that only the right callers can reach each endpoint.

var app = builder.Build();

var orders = new List<OrderDto> { new(101, "Asha Rao", "Latte", 440) };

// Version 1 sent "customerName". The team renamed it to "name" to match the app.
app.MapGet("/orders/{id:int}", (int id) => Results.Ok(orders.First(o => o.Id == id)));

app.Run();

record OrderDto(int Id, string Name, string Item, int Total);`;

type OrderDto = { id: number; name: string; item: string; total: number };
const orders: OrderDto[] = [{ id: 101, name: "Asha Rao", item: "Latte", total: 440 }];

const app = new Hono();

// Version 1 sent "customerName". The team renamed it to "name" to match the app.
app.get("/orders/:id", (c) => c.json(orders.find((o) => o.id === Number(c.req.param("id"))), 200));

var app = builder.Build();

var orders = new List<Order> { new(101, "Asha Rao", "Latte", 440) };

// Version 1 keeps its old shape forever (or until it is retired).
var v1 = app.MapGroup("/v1");
v1.MapGet("/orders/{id:int}", (int id) =>
{
    var o = orders.First(x => x.Id == id);
    return Results.Ok(new OrderV1(o.Id, o.Customer, o.Item, o.Total));
});

// Version 2 has the new shape. Old clients never see it unless they ask.
var v2 = app.MapGroup("/v2");
v2.MapGet("/orders/{id:int}", (int id) =>
{
    var o = orders.First(x => x.Id == id);
    return Results.Ok(new OrderV2(o.Id, o.Customer, o.Item, new Money(o.Total, "INR")));
});

app.Run();

// One model inside...
record Order(int Id, string Customer, string Item, int Total);

// ...two contracts outside.
record OrderV1(int Id, string CustomerName, string Item, int Total);
record Money(int Amount, string Currency);
record OrderV2(int Id, string Name, string Item, Money Total);`;

// One model inside...
type Order = { id: number; customer: string; item: string; total: number };
const orders: Order[] = [{ id: 101, customer: "Asha Rao", item: "Latte", total: 440 }];
const find = (id: string) => orders.find((o) => o.id === Number(id))!;

// Version 1 keeps its old shape forever (or until it is retired).
const v1 = new Hono();
v1.get("/orders/:id", (c) => {
  const o = find(c.req.param("id"));
  return c.json({ id: o.id, customerName: o.customer, item: o.item, total: o.total }, 200);
});

// Version 2 has the new shape. Old clients never see it unless they ask.
const v2 = new Hono();
v2.get("/orders/:id", (c) => {
  const o = find(c.req.param("id"));
  return c.json({ id: o.id, name: o.customer, item: o.item, total: { amount: o.total, currency: "INR" } }, 200);
});

// ...two contracts outside.
const app = new Hono();
app.route("/v1", v1);
app.route("/v2", v2);

###
GET /v2/orders/101`;

var app = builder.Build();

// Tell v1 clients that v1 is going away, and when.
app.Use(async (context, next) =>
{
    if (context.Request.Path.ToString().StartsWith("/v1/"))
    {
        context.Response.Headers["Deprecation"] = "@1782864000";            // since 1 Jul 2026 (RFC 9745)
        context.Response.Headers["Sunset"] = "Fri, 01 Jan 2027 00:00:00 GMT"; // switched off after this (RFC 8594)
        context.Response.Headers["Link"] = "</v2/orders>; rel=\\"successor-version\\"";
    }
    await next();
});

app.MapGet("/v1/orders", () => Results.Ok(new[] { new { id = 101, customerName = "Asha Rao" } }));
app.MapGet("/v2/orders", () => Results.Ok(new[] { new { id = 101, name = "Asha Rao" } }));

app.Run();`;

const app = new Hono();

// Tell v1 clients that v1 is going away, and when.
app.use("/v1/*", async (c, next) => {
  await next();
  c.header("Deprecation", "@1782864000"); // since 1 Jul 2026 (RFC 9745)
  c.header("Sunset", "Fri, 01 Jan 2027 00:00:00 GMT"); // switched off after this (RFC 8594)
  c.header("Link", '</v2/orders>; rel="successor-version"');
});

app.get("/v1/orders", (c) => c.json([{ id: 101, customerName: "Asha Rao" }], 200));
app.get("/v2/orders", (c) => c.json([{ id: 101, name: "Asha Rao" }], 200));

###
GET /v2/orders`;

var app = builder.Build();

// Another way: one URL, and the client picks the version in a header.
app.MapGet("/orders/{id:int}", (int id, HttpRequest request) =>
{
    string version = request.Headers["X-Api-Version"].ToString();
    if (version == "" || version == "1")
        return Results.Ok(new { id, customerName = "Asha Rao" });
    if (version == "2")
        return Results.Ok(new { id, name = "Asha Rao" });
    return Results.Problem(statusCode: 400, title: "Unknown API version", detail: "Supported versions: 1, 2.");
});

app.Run();`;

const app = new Hono();

// Another way: one URL, and the client picks the version in a header.
app.get("/orders/:id", (c) => {
  const id = Number(c.req.param("id"));
  const version = c.req.header("X-Api-Version") ?? "";
  if (version === "" || version === "1") return c.json({ id, customerName: "Asha Rao" }, 200);
  if (version === "2") return c.json({ id, name: "Asha Rao" }, 200);
  const problem = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.1", title: "Unknown API version", status: 400, detail: "Supported versions: 1, 2." };
  return c.body(JSON.stringify(problem), 400, { "Content-Type": "application/problem+json" });
});

###
GET /orders/101
X-Api-Version: 2

###
GET /orders/101
X-Api-Version: 7`;

var v1 = new Dictionary<string, string> { ["id"] = "number", ["customerName"] = "string", ["item"] = "string", ["total"] = "number" };
var v2 = new Dictionary<string, string> { ["id"] = "number", ["name"] = "string", ["item"] = "string", ["total"] = "object", ["notes"] = "string" };

foreach (var (field, type) in v1)
{
    if (!v2.ContainsKey(field)) Console.WriteLine("BREAKING: " + field + " was removed");
    else if (v2[field] != type) Console.WriteLine("BREAKING: " + field + " changed from " + type + " to " + v2[field]);
}
foreach (var field in v2.Keys.Where(f => !v1.ContainsKey(f)))
{
    Console.WriteLine("safe: " + field + " was added");
}`;

const v1: Record<string, string> = { id: "number", customerName: "string", item: "string", total: "number" };
const v2: Record<string, string> = { id: "number", name: "string", item: "string", total: "object", notes: "string" };

for (const [field, type] of Object.entries(v1)) {
  if (!(field in v2)) console.log("BREAKING: " + field + " was removed");
  else if (v2[field] !== type) console.log("BREAKING: " + field + " changed from " + type + " to " + v2[field]);
}
for (const field of Object.keys(v2).filter((f) => !(f in v1))) {
  console.log("safe: " + field + " was added");
}`;

  {
    prompt: "Classify each change as \"breaking\" or \"safe\" for apps that already use the API.",
    hint: "Adding is safe. Removing, renaming or changing a type is breaking.",
    explain: "Old clients ignore new things, but they fail when things that they use disappear or change.",
    csharp: { starter: `// Is each change "breaking" or "safe" for apps that already use the API?
static string Classify(string change)
{
    return "safe";
}

foreach (string change in new[] { "add a new field", "rename a field", "remove a field", "add a new endpoint", "change a number field to text" })
{
    Console.WriteLine(change + ": " + Classify(change));
}`, solution: `// Is each change "breaking" or "safe" for apps that already use the API?
static string Classify(string change)
{
    return change switch
    {
        "add a new field" => "safe",
        "add a new endpoint" => "safe",
        _ => "breaking"
    };
}

foreach (string change in new[] { "add a new field", "rename a field", "remove a field", "add a new endpoint", "change a number field to text" })
{
    Console.WriteLine(change + ": " + Classify(change));
}`, mode: "console" },
    react: { starter: `// Is each change "breaking" or "safe" for apps that already use the API?
function classify(change: string): string {
  return "safe";
}

for (const change of ["add a new field", "rename a field", "remove a field", "add a new endpoint", "change a number field to text"]) {
  console.log(change + ": " + classify(change));
}`, solution: `// Is each change "breaking" or "safe" for apps that already use the API?
function classify(change: string): string {
  const safe = ["add a new field", "add a new endpoint"];
  return safe.includes(change) ? "safe" : "breaking";
}

for (const change of ["add a new field", "rename a field", "remove a field", "add a new endpoint", "change a number field to text"]) {
  console.log(change + ": " + classify(change));
}`, mode: "console" }
  },
  {
    prompt: "Serve the old shape at /v1/orders/{id} (customerName) and the new shape at /v2/orders/{id} (name).",
    hint: "Make two groups, /v1 and /v2, each with its own GET endpoint.",
    explain: "Both versions read the same data, so the partner keeps working while new apps use v2.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var orders = new List<OrderDto> { new(101, "Asha Rao", "Latte", 440) };

// The partner still needs { id, customerName, item, total } at /v1/orders/{id}.
// The app wants { id, name, item, total } at /v2/orders/{id}.
// Serve both.
app.MapGet("/orders/{id:int}", (int id) => Results.Ok(orders.First(o => o.Id == id)));

app.Run();

record OrderDto(int Id, string Name, string Item, int Total);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var orders = new List<Order> { new(101, "Asha Rao", "Latte", 440) };

// The partner still needs { id, customerName, item, total } at /v1/orders/{id}.
// The app wants { id, name, item, total } at /v2/orders/{id}.
// Serve both.
app.MapGet("/v1/orders/{id:int}", (int id) =>
{
    var o = orders.First(x => x.Id == id);
    return Results.Ok(new OrderV1(o.Id, o.Customer, o.Item, o.Total));
});

app.MapGet("/v2/orders/{id:int}", (int id) =>
{
    var o = orders.First(x => x.Id == id);
    return Results.Ok(new OrderV2(o.Id, o.Customer, o.Item, o.Total));
});

app.Run();

record Order(int Id, string Customer, string Item, int Total);
record OrderV1(int Id, string CustomerName, string Item, int Total);
record OrderV2(int Id, string Name, string Item, int Total);`, mode: "aspnet", requests: `GET /v1/orders/101

###
GET /v2/orders/101` },
    react: { starter: `import { Hono } from "hono";

type OrderDto = { id: number; name: string; item: string; total: number };
const orders: OrderDto[] = [{ id: 101, name: "Asha Rao", item: "Latte", total: 440 }];

const app = new Hono();

// The partner still needs { id, customerName, item, total } at /v1/orders/:id.
// The app wants { id, name, item, total } at /v2/orders/:id.
// Serve both.
app.get("/orders/:id", (c) => c.json(orders.find((o) => o.id === Number(c.req.param("id"))), 200));

type Order = { id: number; customer: string; item: string; total: number };
const orders: Order[] = [{ id: 101, customer: "Asha Rao", item: "Latte", total: 440 }];
const find = (id: string) => orders.find((o) => o.id === Number(id))!;

// The partner still needs { id, customerName, item, total } at /v1/orders/:id.
// The app wants { id, name, item, total } at /v2/orders/:id.
// Serve both.
const app = new Hono();

app.get("/v1/orders/:id", (c) => {
  const o = find(c.req.param("id"));
  return c.json({ id: o.id, customerName: o.customer, item: o.item, total: o.total }, 200);
});

app.get("/v2/orders/:id", (c) => {
  const o = find(c.req.param("id"));
  return c.json({ id: o.id, name: o.customer, item: o.item, total: o.total }, 200);
});

###
GET /v2/orders/101` },
    checks: `GET /v2/orders/101?fields=all

###
GET /v1/orders/101?partner=zomato`
  },
  {
    prompt: "v1 already sends Deprecation and Link. Add the Sunset header: Fri, 01 Jan 2027 00:00:00 GMT.",
    hint: "In the middleware, set one more response header when the path starts with /v1.",
    explain: "Sunset tells every client the exact date when v1 stops working.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// v1 already says it is deprecated. Add a Sunset header that says it will be
// switched off after Fri, 01 Jan 2027 00:00:00 GMT.
app.Use(async (context, next) =>
{
    if (context.Request.Path.ToString().StartsWith("/v1/"))
    {
        context.Response.Headers["Deprecation"] = "@1782864000";            // since 1 Jul 2026 (RFC 9745)
        context.Response.Headers["Link"] = "</v2/orders>; rel=\\"successor-version\\"";
    }
    await next();
});

app.MapGet("/v1/orders", () => Results.Ok(new[] { new { id = 101, customerName = "Asha Rao" } }));
app.MapGet("/v2/orders", () => Results.Ok(new[] { new { id = 101, name = "Asha Rao" } }));

app.Run();`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// v1 already says it is deprecated. Add a Sunset header that says it will be
// switched off after Fri, 01 Jan 2027 00:00:00 GMT.
app.Use(async (context, next) =>
{
    if (context.Request.Path.ToString().StartsWith("/v1/"))
    {
        context.Response.Headers["Deprecation"] = "@1782864000";            // since 1 Jul 2026 (RFC 9745)
        context.Response.Headers["Sunset"] = "Fri, 01 Jan 2027 00:00:00 GMT"; // switched off after this (RFC 8594)
        context.Response.Headers["Link"] = "</v2/orders>; rel=\\"successor-version\\"";
    }
    await next();
});

app.MapGet("/v1/orders", () => Results.Ok(new[] { new { id = 101, customerName = "Asha Rao" } }));
app.MapGet("/v2/orders", () => Results.Ok(new[] { new { id = 101, name = "Asha Rao" } }));

app.Run();`, mode: "aspnet", requests: `GET /v1/orders` },
    react: { starter: `import { Hono } from "hono";

const app = new Hono();

// v1 already says it is deprecated. Add a Sunset header that says it will be
// switched off after Fri, 01 Jan 2027 00:00:00 GMT.
app.use("/v1/*", async (c, next) => {
  await next();
  c.header("Deprecation", "@1782864000"); // since 1 Jul 2026 (RFC 9745)
  c.header("Link", '</v2/orders>; rel="successor-version"');
});

app.get("/v1/orders", (c) => c.json([{ id: 101, customerName: "Asha Rao" }], 200));
app.get("/v2/orders", (c) => c.json([{ id: 101, name: "Asha Rao" }], 200));

const app = new Hono();

// v1 already says it is deprecated. Add a Sunset header that says it will be
// switched off after Fri, 01 Jan 2027 00:00:00 GMT.
app.use("/v1/*", async (c, next) => {
  await next();
  c.header("Deprecation", "@1782864000"); // since 1 Jul 2026 (RFC 9745)
  c.header("Sunset", "Fri, 01 Jan 2027 00:00:00 GMT"); // switched off after this (RFC 8594)
  c.header("Link", '</v2/orders>; rel="successor-version"');
});

app.get("/v1/orders", (c) => c.json([{ id: 101, customerName: "Asha Rao" }], 200));
app.get("/v2/orders", (c) => c.json([{ id: 101, name: "Asha Rao" }], 200));

    checks: `GET /v2/orders

###
GET /v1/orders?page=2`
  }
];
