Skip to content

API Versioning & REST Contracts

Key Points

  • Pick a versioning strategy early: URL segment (/v1/orders), query string (?api-version=1.0), header (api-version: 1.0), or media-type (application/vnd.myapi.v1+json). URL-segment is most operator-friendly.
  • Use Asp.Versioning (the official .NET API versioning library) over hand-rolling. Handles all four schemes plus deprecation policies.
  • Idempotency keys for non-idempotent operations (POST that creates state). Caller passes Idempotency-Key; server stores and dedupes.
  • ETag + If-Match for optimistic concurrency. Caller GETs, gets ETag; subsequent PUT includes If-Match: <etag>.
  • RFC 9457 ProblemDetails is the standard error response format. ASP.NET Core has built-in support via AddProblemDetails.
  • Deprecation: Sunset and Deprecation headers signal end-of-life dates to clients. Couple with version-discovery responses.

Concepts (deep dive)

Versioning schemes

Scheme Example Pros Cons
URL segment /v1/orders Visible; cache-friendly; routing-easy URL changes break links
Query string /orders?api-version=1.0 URLs stable Easy to forget
Header api-version: 1.0 Clean URLs Hidden; harder ops
Media type Accept: application/vnd.myapi.v1+json RESTful purist Tooling overhead

Pragmatic recommendation: URL segment for public-facing APIs (debuggable, log-friendly), header for internal microservices (clean URLs).

Asp.Versioning setup

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;        // adds api-supported-versions / api-deprecated-versions headers
    o.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("api-version"),
        new QueryStringApiVersionReader("api-version"));
}).AddApiExplorer(o =>
{
    o.GroupNameFormat = "'v'VVV";
    o.SubstituteApiVersionInUrl = true;
});

Versioning Minimal APIs

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

var v1 = app.MapGroup("/v{version:apiVersion}")
    .WithApiVersionSet(versionSet)
    .HasApiVersion(1, 0);

v1.MapGet("/orders", () => /* v1 logic */);

var v2 = app.MapGroup("/v{version:apiVersion}")
    .WithApiVersionSet(versionSet)
    .HasApiVersion(2, 0);

v2.MapGet("/orders", () => /* v2 logic */);

Versioning controllers

[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("v{version:apiVersion}/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpGet, MapToApiVersion("1.0")]
    public IActionResult GetV1() => Ok(/* ... */);

    [HttpGet, MapToApiVersion("2.0")]
    public IActionResult GetV2() => Ok(/* ... */);
}

Idempotency keys

For POST/PATCH that creates or modifies state, support an Idempotency-Key header:

app.MapPost("/orders", async (CreateOrderRequest req, HttpContext ctx, IIdempotencyStore store) =>
{
    var key = ctx.Request.Headers["Idempotency-Key"].ToString();
    if (string.IsNullOrEmpty(key))
        return Results.BadRequest("Idempotency-Key header required");

    var existing = await store.GetAsync(key);
    if (existing is not null) return Results.Json(existing.Response,
        statusCode: existing.StatusCode);

    var order = await CreateAsync(req);
    await store.SetAsync(key, order, statusCode: 201, ttl: TimeSpan.FromHours(24));
    return Results.Created($"/orders/{order.Id}", order);
});

The store keeps (key, response, status) for some TTL. Repeats return the original response. Client retries are safe.

💡 Idempotency keys are typically client-generated UUIDs. Document max length and retention.

ETag + If-Match (optimistic concurrency)

app.MapGet("/orders/{id:int}", async (int id, IRepo r, HttpContext ctx) =>
{
    var order = await r.GetAsync(id);
    if (order is null) return Results.NotFound();
    var etag = $"\"{order.Version}\"";
    ctx.Response.Headers.ETag = etag;
    return Results.Ok(order);
});

app.MapPut("/orders/{id:int}", async (int id, UpdateRequest req, IRepo r, HttpContext ctx) =>
{
    var ifMatch = ctx.Request.Headers["If-Match"].ToString().Trim('"');
    var order = await r.GetAsync(id);
    if (order is null) return Results.NotFound();
    if (order.Version.ToString() != ifMatch) return Results.StatusCode(412);   // Precondition Failed
    /* update */
    return Results.NoContent();
});

Server includes ETag on GET; client sends If-Match on PUT/PATCH. If versions don't match → 412 Precondition Failed — client refetches and retries with the new version.

ProblemDetails (RFC 7807 / 9457)

builder.Services.AddProblemDetails();

app.UseExceptionHandler();   // produces ProblemDetails for unhandled exceptions
app.UseStatusCodePages();    // ProblemDetails for non-success without bodies

Standard error shape:

{
  "type": "https://example.com/probs/order-not-found",
  "title": "Order not found",
  "status": 404,
  "detail": "Order 12345 does not exist.",
  "instance": "/orders/12345",
  "traceId": "00-abc123..."
}

Returning manually:

return Results.Problem(detail: "...", statusCode: 422, type: "https://example.com/probs/validation");

See ProblemDetails & Error Handling for the full treatment.

Deprecation signals

[ApiVersion("1.0", Deprecated = true)]
public class OrdersV1Controller : ControllerBase { ... }

Asp.Versioning adds api-deprecated-versions: 1.0 header to responses. For finer control, set:

ctx.Response.Headers.Append("Deprecation", "Wed, 11 Nov 2026 23:59:59 GMT");
ctx.Response.Headers.Append("Sunset", "Wed, 11 Feb 2027 23:59:59 GMT");
ctx.Response.Headers.Append("Link", "</docs/migration-v2>; rel=\"deprecation\"");

REST conventions / status codes

Code Meaning
200 OK Success with body
201 Created Resource created; include Location header
202 Accepted Async; include polling URL
204 No Content Success, no body
400 Bad Request Client error in request
401 Unauthorized Auth failed
403 Forbidden Auth ok, no permission
404 Not Found Resource doesn't exist
409 Conflict Idempotent retry conflict
412 Precondition Failed If-Match mismatch
422 Unprocessable Entity Semantic validation failure
429 Too Many Requests Rate limited
500 Internal Server Error Bug
503 Service Unavailable Temporary; include Retry-After

Pagination

GET /orders?cursor=eyJpZCI6MTAwfQ&limit=50

200 OK
{
  "items": [...],
  "next": "/orders?cursor=eyJpZCI6MTUwfQ&limit=50",
  "prev": "/orders?cursor=eyJpZCI6NTB9&limit=50"
}

Cursor-based pagination scales with arbitrary data sizes; offset-based degrades on later pages. Encode cursor opaquely (Base64-encoded JSON of the relevant key).

Richardson Maturity Model — REST levels 0–3

A useful ladder for judging "how RESTful is this API really?" Most APIs that call themselves REST are actually Level 2 — and that's fine. Senior point: aim for Level 2 + good versioning + ProblemDetails. Don't chase Level 3.

Level 3  Hypermedia / HATEOAS         <-- theoretical pinnacle
            ^                              (almost no service-to-service APIs do this)
            |
Level 2  HTTP verbs + status codes    <-- where modern APIs land
            ^                              GET/POST/PUT/PATCH/DELETE used semantically
            |                              201 Created, 204 No Content, 409 Conflict, etc.
Level 1  Resources                    <-- 90% of "REST" APIs in the wild
            ^                              meaningful URLs, often single verb (POST /orders/create)
            |
Level 0  The Swamp of POX             <-- single endpoint, single verb, payload-driven
                                          (think SOAP-over-HTTP: POST /api with action in body)
Level What you get Example
0 One URL, one verb, RPC-in-disguise POST /api { "action": "GetOrder", "id": 1 }
1 Resource URLs, still verb-poor POST /orders/1/cancel
2 Verbs + status codes used correctly DELETE /orders/1204 No Content
3 Responses include links to next valid actions { "id": 1, "_links": { "cancel": "/orders/1/cancel" } }
  • Level 0 — "POX" = Plain Old XML (or JSON now). Single endpoint, single verb, behavior in the payload. SOAP and most pre-2010 enterprise integrations live here.
  • Level 1 — meaningful resource URLs, but the verbs are smuggled into the path (POST /orders/create, POST /orders/cancel). Easy to ship, easy to grow into a mess.
  • Level 2HTTP verbs + status codes used semantically. GET is safe + idempotent, PUT is idempotent, POST creates, DELETE removes. 201 Created includes Location, 409 Conflict for idempotency clashes, 412 Precondition Failed for ETag mismatches. Most modern .NET APIs land here.
  • Level 3 — HATEOAS: the response embeds links the client follows to discover next actions. Theoretically powerful (the API drives the client), but tooling is poor for service-to-service and clients almost always hard-code URLs anyway. 💡 Where hypermedia does shine in 2026: HTML responses for UIs — see HTMX with ASP.NET Core. HTMX brings Level-3 ergonomics to server-rendered apps without forcing it on JSON APIs.

⚠️ Don't over-index on the model. It's a rhetorical tool, not a checklist. "We're at Level 2" is a fine answer in interviews; nobody seriously builds Level 3 JSON APIs.


Code: correct vs wrong

❌ Wrong: breaking changes without versioning

// v1 of the API:
public record OrderDto(int Id, string Status);

// "v2" change drops Status:
public record OrderDto(int Id);   // ❌ existing v1 clients break

✅ Correct: separate versions

public record OrderDtoV1(int Id, string Status);
public record OrderDtoV2(int Id, OrderState State);

// Both endpoints live; deprecation policy retires v1 over time

❌ Wrong: 200 OK with error in body

return Results.Ok(new { error = "Not found" });   // ❌ 200 lies about success

✅ Correct: proper status code

return Results.Problem(statusCode: 404, title: "Not found");

❌ Wrong: PUT without If-Match on a concurrent resource

// Two clients GET same order; both PUT; last one wins (silently)

✅ Correct: ETag + 412

See "ETag + If-Match" above.


Design patterns for this topic

Pattern 1 — "URL-segment versioning for public APIs"

  • Intent: visible in logs and URLs.

Pattern 2 — "Idempotency-Key header for POST"

  • Intent: safe retries.

Pattern 3 — "ETag/If-Match for state mutations"

  • Intent: optimistic concurrency.

Pattern 4 — "ProblemDetails for all errors"

  • Intent: standardized error shape.

Pattern 5 — "Cursor-based pagination"

  • Intent: scales arbitrarily.

Pros & cons / trade-offs

Aspect Pros Cons
URL versioning Visible URL churn
Header versioning Clean URLs Hidden
Idempotency keys Safe retries Server-side store cost
ETag concurrency Predictable Client must support
Cursor pagination Scales Stateless cursor design
ProblemDetails Standard New tooling

When to use / when to avoid

  • Always version APIs from day one.
  • Always support idempotency for state-modifying POSTs.
  • Always use ProblemDetails for errors.
  • Avoid breaking changes within a major version — only between.
  • Avoid offset pagination for large datasets.

Interview Q&A

Q1. What versioning schemes does Asp.Versioning support? URL segment, query string, header, media-type, or any combination via ApiVersionReader.Combine.

Q2. When do you increment the major version of an API? On any breaking change to public contract: removing fields, renaming, changing types, changing semantics.

Q3. What's an idempotency key? A client-generated unique identifier (typically UUID) sent on a non-idempotent request. Server stores (key → response); subsequent retries with same key return original result.

Q4. How does ETag concurrency work? GET returns ETag: "v1". Client edits and PUTs with If-Match: "v1". If server's current version is still v1, accept; else 412 Precondition Failed.

Q5. Difference between 422 and 400? 400: malformed request (bad JSON, missing required field). 422: well-formed but semantically invalid (business rule violation).

Q6. Why prefer cursor-based over offset-based pagination? Offset degrades: SQL OFFSET 100000 LIMIT 50 scans 100k rows. Cursor: SQL WHERE id > 100050 LIMIT 50 uses index. Also cursor pagination is stable across inserts.

Q7. What's the Sunset HTTP header? Signals end-of-life date for an API endpoint. Combined with Deprecation and Link, gives clients fair warning to migrate.

Q8. How do you discover which versions an API supports? Asp.Versioning adds api-supported-versions: 1.0, 2.0 and api-deprecated-versions: 1.0 response headers when ReportApiVersions = true. Or expose a versions-discovery endpoint.

Q9. Should you version internal microservice APIs? Yes, but the friction can be lower (header versioning + small team coordinating deprecations). Don't skip versioning entirely.

Q10. What's the standard error response shape in modern .NET? RFC 9457 ProblemDetails: type, title, status, detail, instance, plus extensions like traceId, errors. ASP.NET Core has built-in support via AddProblemDetails().


Gotchas / common mistakes

  • ⚠️ Breaking changes mid-version — clients break.
  • ⚠️ No idempotency key on POST — retries duplicate.
  • ⚠️ 200 OK with error body — lies about success.
  • ⚠️ Offset pagination on large data — slows over time.
  • ⚠️ Forgetting Location header on 201 — clients can't navigate to new resource.
  • ⚠️ Mixing version schemes inconsistently — surprising behavior.

Further reading