Versioning Strategies and HTTP Headers

๐Ÿ“‹ Table of Contents โ–พ
  1. URL Path Versioning
  2. Response Headers
  3. Common Mistakes

APIs evolve โ€” new features are added, existing behaviour changes, and some things are deprecated. Without versioning, any breaking change forces all clients to update simultaneously, which is rarely practical. API versioning allows the server to offer multiple API versions in parallel, letting clients migrate at their own pace. HTTP headers and response metadata (like X-Request-Id) provide operational transparency โ€” clients can log correlation IDs and inspect rate limits without extra API calls.

URL Path Versioning

// dotnet add package Asp.Versioning.Mvc

// โ”€โ”€ Program.cs โ€” register API versioning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
builder.Services.AddApiVersioning(opts =>
{
    opts.DefaultApiVersion = new ApiVersion(1, 0);
    opts.AssumeDefaultVersionWhenUnspecified = true;
    opts.ReportApiVersions = true;   // adds Api-Supported-Versions response header
})
.AddMvc()
.AddApiExplorer(opts =>
{
    opts.GroupNameFormat           = "'v'VVV";
    opts.SubstituteApiVersionInUrl = true;
});

// โ”€โ”€ v1 controller โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/posts")]
public class PostsV1Controller : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<PostV1Dto>> GetById(int id, CancellationToken ct)
        => Ok(await _service.GetByIdV1Async(id, ct));
}

// โ”€โ”€ v2 controller โ€” added new fields, changed field name โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/posts")]
public class PostsV2Controller : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<PostV2Dto>> GetById(int id, CancellationToken ct)
        => Ok(await _service.GetByIdV2Async(id, ct));
}

// โ”€โ”€ Query string versioning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// GET /api/posts/42?api-version=2.0
builder.Services.AddApiVersioning(opts =>
{
    opts.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),      // /api/v2/posts
        new QueryStringApiVersionReader(),     // /api/posts?api-version=2.0
        new HeaderApiVersionReader("Api-Version")); // Api-Version: 2.0
});
Note: URL path versioning (/api/v1/posts, /api/v2/posts) is the most visible and widely understood approach. It makes the version explicit in every request log, bookmark, and API call. The trade-off is that clients must update their URLs when upgrading versions. Header versioning (Api-Version: 2.0) keeps URLs clean but requires clients to set a header on every request, which is less discoverable. For public APIs consumed by Angular clients, URL versioning is recommended โ€” it works with browser bookmarks, server logs, and API documentation tools without special configuration.
Tip: Add a X-Request-Id response header to every API response so clients and server logs can be correlated. Middleware at the top of the pipeline reads an incoming X-Request-Id header (from the client or load balancer) or generates a new GUID, attaches it to the log scope, and echoes it in the response: context.Response.Headers["X-Request-Id"] = correlationId. When an Angular client reports a bug, the support team looks up the X-Request-Id in the server logs to find the full request trace.
Warning: Never remove or rename a response field without incrementing the API version. Even “harmless” changes โ€” renaming authorId to userId, removing a field that “nobody uses,” changing a number to a string โ€” break Angular clients that expect the old schema. The rule: any change to the response shape or meaning is a breaking change requiring a new API version. Additive changes (adding new fields, new optional parameters) are non-breaking and do not require versioning.

Response Headers

// โ”€โ”€ Middleware โ€” add standard API response headers to every response โ”€โ”€โ”€โ”€โ”€โ”€โ”€
public class ApiResponseHeadersMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        // Correlation ID โ€” echoed from request or generated
        var correlationId = context.Request.Headers["X-Request-Id"].FirstOrDefault()
            ?? Guid.NewGuid().ToString("N")[..12];
        context.Response.Headers["X-Request-Id"] = correlationId;

        // API version being served (requires Asp.Versioning)
        // context.Response.Headers["X-Api-Version"] = "1.0";

        await next(context);

        // Add after โ€” response status is now known
        // Rate limit headers (if using rate limiting middleware)
        // X-RateLimit-Limit: 100
        // X-RateLimit-Remaining: 94
        // X-RateLimit-Reset: 1706745600
    }
}

// โ”€โ”€ Deprecation header โ€” warn clients using old versions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/v{version:apiVersion}/posts")]
public class PostsV1Controller : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        // Adds Sunset and Deprecation headers automatically via Asp.Versioning
        // Deprecation: true
        // Sunset: Sat, 01 Jun 2026 00:00:00 GMT
        return Ok(await _service.GetByIdAsync(id, ct));
    }
}

Common Mistakes

Mistake 1 โ€” Making breaking changes without a new version (breaks existing clients)

โŒ Wrong โ€” renaming a response field from authorId to userId in v1; Angular clients crash.

โœ… Correct โ€” create v2 with the new field name; keep v1 stable with the old name; migrate Angular clients to v2.

Mistake 2 โ€” Not setting a default version (clients without version header get random behaviour)

โŒ Wrong โ€” no DefaultApiVersion set; requests without version header fail with 400.

โœ… Correct โ€” set opts.DefaultApiVersion = new ApiVersion(1, 0) and opts.AssumeDefaultVersionWhenUnspecified = true.

🧠 Test Yourself

You add a new optional field viewCount to the GET /api/v1/posts/{id} response. Is this a breaking change requiring a new version?