Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

REST API Versioning and Idempotency: In-Depth with .NET Examples

API versioning and idempotency: URL, header, Idempotency-Key, and .NET implementation.

services
Read the article

Introduction

This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).

Production-grade APIs need versioning (to evolve without breaking clients) and idempotency (so repeat requests have the same effect as one). This article covers both in depth: what they are, when and why to use them, common strategies, and full .NET examples. For architects and tech leads, applying versioning and idempotency from day one keeps contracts clear and avoids duplicate orders or inconsistent state.

If you are new to the topic, start with Topics covered and jump to the section you need.

For a deeper overview of this topic, explore the full API Architecture guide.

Decision Context

  • System scale: Varies by context; the approach in this article applies to the scales and scenarios described in the body.
  • Team size: Typically small to medium teams; ownership and clarity matter more than headcount.
  • Time / budget pressure: Applicable under delivery pressure; I’ve used it in both greenfield and incremental refactors.
  • Technical constraints: .NET and related stack where relevant; constraints are noted in the article where they affect the approach.
  • Non-goals: This article does not optimize for every possible scenario; boundaries are stated where they matter.

What is API versioning?

API versioning is the practice of identifying and serving different versions of your API so that you can change behavior, contracts, or endpoints over time without breaking existing clients. As you add features, fix bugs, or introduce breaking changes (e.g. rename a field, remove an endpoint, change response shape), old clients continue to call the version they were built against (e.g. v1), while new clients use the latest version (e.g. v2). Without versioning, every change is a gamble: adding a required field or changing a type can break callers.

When you need it: When your API will evolve and you have or expect multiple consumers (different apps, mobile vs web, third parties) that may upgrade at different times. Versioning gives you a contract per version and a clear deprecation path.

When you can defer it: Internal-only APIs with a single consumer that you control and can deploy in lockstep might get by with backward-compatible changes only; even then, versioning often pays off as soon as a second client appears.


API versioning strategies at a glance

Strategy Example Pros Cons
URL path /api/v1/orders, /api/v2/orders Clear, cache-friendly, easy to route URL changes per version
Query string /api/orders?api-version=1 Easy to add, no route change Less visible, caching quirks
Header Api-Version: 1 or X-Api-Version: 1 Clean URLs, one resource Clients must send header; not in browser address bar
Content negotiation Accept: application/vnd.api+json;version=1 RESTful, flexible More complex; less common

Below we go through each strategy in detail, then show how to implement versioning in .NET.


URL path versioning

What it is and when to use it

URL path versioning embeds the version in the path: /api/v1/orders, /api/v2/orders. Each version is a distinct URL. Clients that want v1 call v1; clients that want v2 call v2. Caches, gateways, and logs see the version explicitly, and routing is straightforward.

Pros: Very clear for humans and tools; cache keys naturally include the version; easy to document and test. Cons: The URL changes when you add a version; some prefer “one resource, one URL” and use headers instead.

When to use: Default choice for most APIs—especially when you want version visibility in logs, caching, and documentation. Use when you have few versions and want maximum clarity.

Example

GET /api/v1/orders/123
GET /api/v2/orders/123

Query string versioning

What it is and when to use it

Query string versioning uses a query parameter: /api/orders?api-version=1 or /api/orders?version=2. The path stays the same; the version is supplied as a parameter. Easy to add to existing APIs without changing routes.

Pros: Simple to introduce; no route changes; easy to try different versions in a browser or Postman. Cons: Version is easy to forget; cache keys must include the query string; less visible in documentation; some proxies strip or normalize query strings.

When to use: When you need a low-friction way to add versioning to an existing API or when clients are few and you want quick experimentation. Often combined with a default version when the parameter is omitted.

Example

GET /api/orders/123?api-version=1
POST /api/orders?api-version=2

Header versioning

What it is and when to use it

Header versioning keeps the URL unchanged and passes the version in a header, e.g. Api-Version: 1 or X-Api-Version: 2. The same path can return different representations or behavior based on the header. Aligns with the idea of “one resource, multiple representations.”

Pros: Clean URLs; one path for one resource; version is explicit in the request. Cons: Not visible in the address bar or in simple GET links; every client must send the header; caching must take the header into account (Vary: Api-Version).

When to use: When you want clean URLs and all your clients are capable of sending custom headers (e.g. server-to-server, mobile apps). Less ideal for browser-only or “shareable link” scenarios.

Example

GET /api/orders/123
Api-Version: 1

Content negotiation (Accept header)

What it is and when to use it

Content negotiation uses the Accept header (and sometimes Content-Type) to request a specific version or format, e.g. Accept: application/vnd.myapi.v1+json. The server interprets the media type and returns the appropriate representation. Very RESTful and flexible.

Pros: Aligns with HTTP content negotiation; can combine version with format (JSON, XML). Cons: More complex to parse and document; less common than path or simple header; clients and tools need to set Accept correctly.

When to use: When you value REST purity and already use custom media types or multiple formats. Often used in APIs that stress HATEOAS or strict REST.


Implementing versioning in .NET

What you need

In .NET you typically use Microsoft.AspNetCore.Mvc.Versioning (or the newer Asp.Versioning.Mvc / Asp.Versioning.Http packages). You register versioning in Program.cs, configure the strategy (path, query, header), and apply [ApiVersion("1.0")] (and optionally [ApiVersion("2.0")]) on controllers or actions.

Full working example: URL path versioning

1. Add the package

dotnet add package Asp.Versioning.Mvc

2. Register versioning in Program.cs

using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options => {
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader(); // /api/v1/...
}).AddMvc();

var app = builder.Build();
app.MapControllers();
app.Run();

3. Versioned controllers

[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class OrdersV1Controller : ControllerBase {
    [HttpGet("{id}")]
    public IActionResult Get(string id) =>
        Ok(new { Id = id, Version = 1, Total = 100m });
}

[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("2.0")]
public class OrdersV2Controller : ControllerBase {
    [HttpGet("{id}")]
    public IActionResult Get(string id) =>
        Ok(new { Id = id, Version = 2, Total = 100m, Currency = "USD" });
}

How this fits together: The UrlSegmentApiVersionReader reads the version from the path (e.g. v1 or v2). The route api/v{version:apiVersion}/[controller] maps /api/v1/orders to OrdersV1Controller and /api/v2/orders to OrdersV2Controller. ReportApiVersions = true adds an api-supported-versions header so clients know which versions exist. New versions are added by new controllers (or new actions) with a new [ApiVersion]; old clients keep using the old URL.

When to use URL path in .NET: Use UrlSegmentApiVersionReader for path versioning. Use QueryStringApiVersionReader("api-version") for query string, or HeaderApiVersionReader("Api-Version") for header versioning. You can combine readers (e.g. try header, then query) if needed.


What is idempotency?

Idempotency means that performing the same operation one or more times has the same effect as performing it once. For HTTP: sending the same request again (e.g. after a timeout and retry, or a user double-clicking “Submit”) should not create duplicate side effects. GET is idempotent (reading again does not change state). PUT and DELETE are idempotent when applied to the same resource (same URL and body for PUT; same URL for DELETE). POST is not idempotent by default: each POST typically creates a new resource, so a retry can create duplicates (e.g. two orders, two payments). For POST (and sometimes PATCH), you need a mechanism so the server can recognize “this is the same logical request” and return the same result instead of applying the operation again. That mechanism is the Idempotency-Key header.

When you need it: Whenever POST (or other non-idempotent operations) can be retried (network retries, mobile retry buttons, user double-submit) and duplicate execution would be harmful (orders, payments, sign-ups). Idempotency is standard for payment and order APIs.


Idempotency by HTTP method

Method Idempotent? Why
GET Yes No side effects; same request → same response.
PUT Yes (same URI) Replacing resource at same URI; same request → same state.
DELETE Yes (same URI) Deleting same resource again → same state (e.g. 404).
POST No Each request typically creates a new resource; retry → duplicate.
PATCH Depends If PATCH is “apply diff once,” same key can make it idempotent.

So the main focus for implementing idempotency is POST (and similar non-idempotent operations).


Idempotency-Key for POST

What it is and when to use it

The Idempotency-Key (or Idempotency-Key) is a client-generated value (e.g. UUID) sent in a header. The client sends the same key for the same logical request (e.g. “create this order”). The server stores the key and either:

  • First time: Process the request, store the key with the response (or a “processed” marker), return the response.
  • Same key again: Do not process again; return the stored response (or 200 with same body), so the client gets the same result and no duplicate resource is created.

Storage can be in-memory (single instance), Redis, or database, with a TTL (e.g. 24 hours) so keys do not live forever. Key scope is usually per endpoint or per resource type (e.g. order creation) so that the same key used for different operations does not collide.

When to use: For any POST (or non-idempotent) endpoint where retries or double-submit are possible and duplicates are unacceptable (orders, payments, registrations).

Flow (high level)

  1. Client sends POST /api/orders with body and Idempotency-Key: <uuid>.
  2. Server checks store for that key (e.g. in Redis/DB).
  3. If key is new: Create order, store key → order id (or full response), return 201 + response.
  4. If key exists: Return 200 + stored response (or 201 with same body); do not create another order.
  5. Optionally expire keys after a configured time.

Implementing idempotency in .NET

What you need

You need a store (e.g. IDistributedCache with Redis, or a database table) that maps Idempotency-Key → response (or “processed” + resource id). Then either:

  • Middleware that runs early, reads the key, checks the store, and short-circuits with the stored response if the key was already processed; or
  • Action filter or endpoint filter that does the same around the action.

The action itself should be deterministic for the same key: same request body + same key → same response. Store the status code and response body (or a reference) so you can replay them.

Full working example: Idempotency filter with in-memory store

1. Idempotency store (in-memory for illustration)

public interface IIdempotencyStore {
    Task<(bool exists, byte[] response, int statusCode)> TryGetAsync(string key, CancellationToken ct = default);
    Task SetAsync(string key, byte[] response, int statusCode, TimeSpan ttl, CancellationToken ct = default);
}

public class MemoryIdempotencyStore : IIdempotencyStore {
    private readonly ConcurrentDictionary<string, (byte[] response, int statusCode, DateTimeOffset until)> _store = new();
    public Task<(bool exists, byte[] response, int statusCode)> TryGetAsync(string key, CancellationToken ct) {
        if (_store.TryGetValue(key, out var v) && v.until > DateTimeOffset.UtcNow)
            return Task.FromResult((true, v.response, v.statusCode));
        return Task.FromResult((false, Array.Empty<byte>(), 0));
    }
    public Task SetAsync(string key, byte[] response, int statusCode, TimeSpan ttl, CancellationToken ct) {
        _store[key] = (response, statusCode, DateTimeOffset.UtcNow.Add(ttl));
        return Task.CompletedTask;
    }
}

2. Endpoint filter that checks and stores by Idempotency-Key

public class IdempotencyFilter : IEndpointFilter {
    private readonly IIdempotencyStore _store;
    private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(60);
    public IdempotencyFilter(IIdempotencyStore store) => _store = store;
    public async ValueTask<object> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) {
        if (context.HttpContext.Request.Method != "POST") return await next(context);
        var key = context.HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
        if (string.IsNullOrEmpty(key)) return await next(context);
        var (exists, response, statusCode) = await _store.TryGetAsync(key, context.HttpContext.RequestAborted);
        if (exists) {
            context.HttpContext.Response.StatusCode = statusCode;
            context.HttpContext.Response.ContentType = "application/json";
            await context.HttpContext.Response.Body.WriteAsync(response);
            return null;
        }
        var originalBody = context.HttpContext.Response.Body;
        using var ms = new MemoryStream();
        context.HttpContext.Response.Body = ms;
        var result = await next(context);
        context.HttpContext.Response.Body = originalBody;
        var status = context.HttpContext.Response.StatusCode;
        var body = ms.ToArray();
        await _store.SetAsync(key, body, status, Ttl, context.HttpContext.RequestAborted);
        await originalBody.WriteAsync(body);
        return result;
    }
}

3. Registration and usage

builder.Services.AddSingleton<IIdempotencyStore, MemoryIdempotencyStore>();

// On the order creation endpoint
app.MapPost("/api/orders", async (OrderRequest req) => Results.Created("/api/orders/1", new { Id = "1" }))
   .AddEndpointFilter<IdempotencyFilter>();

How this fits together: For POST requests that include Idempotency-Key, the filter first checks the store. If the key was already processed, it replays the stored status and body and skips the handler. If the key is new, it runs the handler, captures the response, stores it with the key and TTL, then writes the response to the client. Retries with the same key get the same response and no second order. In production, replace MemoryIdempotencyStore with Redis or DB and scope the key (e.g. by path or user) as needed.

When to use: Add this (or similar) to any POST endpoint where duplicate submission would create duplicate resources. Use a distributed store (Redis/DB) when you have multiple instances.


Comparison: when to use which

Concern Recommendation
Versioning strategy Prefer URL path for clarity and caching; use header if you need clean URLs and all clients send headers.
Version in .NET Use Asp.Versioning.Mvc with UrlSegmentApiVersionReader (or query/header reader); put [ApiVersion] on controllers.
Idempotency Use Idempotency-Key for all POST (and non-idempotent) endpoints that can be retried or double-submitted.
Idempotency storage Use Redis or DB for multi-instance apps; in-memory only for single instance. Set a TTL (e.g. 24–48 hours).

Common pitfalls

  • Versioning: Assuming “no version in URL” means no versioning—you still need a strategy (header or query). Document the default version and deprecation policy.
  • Versioning: Breaking backward compatibility within the same version (e.g. renaming a field in v1). Use a new version for breaking changes.
  • Idempotency: Not sending the same Idempotency-Key on retries (client bug)—document that the key must be the same for the same logical request.
  • Idempotency: Storing keys forever—use TTL to avoid unbounded growth. Optionally scope keys by user or endpoint.
  • Idempotency: Returning 201 on replay—prefer 200 with the same body for repeated idempotent POST so clients can treat retries as success.

Summary

API versioning lets you evolve your API (new versions) without breaking existing clients; use URL path (/api/v1/...) or header (Api-Version) and implement in .NET with Asp.Versioning.Mvc and [ApiVersion]. Idempotency ensures that repeating the same request has the same effect as once; use Idempotency-Key for POST and store key → response with a TTL. This article covered both topics with strategies, .NET examples, and when to use which. Apply versioning from day one if you expect multiple clients or evolution; add idempotency for any POST that can be retried or double-submitted.


Position & Rationale

I version the API from day one when we have (or expect) multiple clients or evolution—URL path (/api/v1/...) or header (Api-Version), consistently applied. I add idempotency (Idempotency-Key header, store key → response with TTL) for every POST (or PUT/PATCH that creates or updates) that can be retried or double-submitted. I reject overloading versioning or skipping idempotency for mutating operations that clients retry. I use Asp.Versioning.Mvc in .NET and document both versioning and idempotency in OpenAPI.


Trade-Offs & Failure Modes

  • What this sacrifices: Some simplicity, extra structure, or operational cost depending on the topic; the article body covers specifics.
  • Where it degrades: Under scale or when misapplied; early warning signs include drift from the intended use and repeated workarounds.
  • How it fails when misapplied: Using it where constraints don’t match, or over-applying it. The “When I Would Use This Again” section below reinforces boundaries.
  • Early warning signs: Team confusion, bypasses, or “we’re doing X but not really” indicate a mismatch.

What Most Guides Miss

Most material covers URL vs header versioning and the definition of idempotency, then stops. What they skip: when to actually version—not every change needs a new version, and over-versioning burns clients. Idempotency keys in practice: who generates them (client? proxy?), where they’re stored, and what happens on retries when the first request is still in flight. And backward compatibility: additive changes vs breaking; most guides don’t tie versioning to a real compatibility policy. The hard part is the policy and the failure modes, not the syntax.


Decision Framework

  • If the context matches the assumptions in this article → Apply the approach as described; adapt to your scale and team.
  • If constraints differ → Revisit Decision Context and Trade-Offs; simplify or choose an alternative.
  • If you’re under heavy time pressure → Use the minimal subset that gives the most value; expand later.
  • If ownership is unclear → Clarify before scaling the approach; unclear ownership is an early warning sign.

You can also explore more patterns in the API Architecture resource page.

Key Takeaways

  • The article body and Summary capture the technical content; this section distils judgment.
  • Apply the approach where context and constraints match; avoid over-application.
  • Trade-offs and failure modes are real; treat them as part of the decision.
  • Revisit “When I Would Use This Again” when deciding on a new project or refactor.

If you’re building or modernizing API platforms, I provide API architecture consulting covering API-first design, governance, and integration strategy.

When I Would Use This Again — and When I Wouldn’t

I would use API versioning again for any API that has or will have multiple clients or that will evolve—URL or header versioning from day one so we never break existing clients. I would add idempotency again for every POST (or mutating PUT/PATCH) that can be retried—payment, order, sign-up—so we never double-charge or double-create. I wouldn’t skip versioning to “simplify” and then change the API in a breaking way. I wouldn’t add idempotency to GET or to operations that are naturally idempotent (e.g. PUT by id); I would add it where retries are likely and the operation is not. For a single internal client and no planned evolution, minimal versioning might be enough, but I’d still document the contract; for any public or multi-client API, both versioning and idempotency are standard in my view.


services
Frequently Asked Questions

Frequently Asked Questions

What is API versioning?

API versioning lets you evolve the API (e.g. v2) without breaking existing clients (v1). You expose multiple versions (path, query, or header) and serve each client the version it requests.

What is idempotency?

Idempotency means repeating the same request has the same effect as doing it once. For POST, use an Idempotency-Key header; the server stores the key and returns the same response for the same key so retries do not create duplicates.

Which versioning strategy should I use?

URL path (/api/v1/...) is clearest and cache-friendly. Header (Api-Version) keeps URLs clean but requires clients to send the header. Query string is easy to add but less visible.

How do I implement versioning in .NET?

Use Asp.Versioning.Mvc (or Microsoft.AspNetCore.Mvc.Versioning). Call AddApiVersioning(), set ApiVersionReader (e.g. UrlSegmentApiVersionReader), and put [ApiVersion("1.0")] on controllers. Route with api/v{version:apiVersion}/[controller] for path versioning.

When should I deprecate a version?

Announce a deprecation timeline (e.g. 6–12 months). Support at least two versions during migration. Remove a version only after clients have migrated and you have stopped receiving traffic for it.

What is Idempotency-Key?

A header (e.g. Idempotency-Key: <uuid>) sent by the client for POST requests. The server stores the key with the response and, for duplicate keys, returns the stored response without re-executing the operation.

How should I store idempotency keys?

Use Redis or a database for multi-instance apps; in-memory only for a single instance. Store key → (response body, status code) and set a TTL (e.g. 24–48 hours) so keys expire after retries are no longer expected.

How long should I keep idempotency keys?

Long enough for retries (minutes to hours). Typical TTL is 24–48 hours. After that, the same key can be treated as new if you need to bound storage.

Which HTTP methods are idempotent?

GET, PUT, and DELETE are idempotent (same request → same effect). POST is not; each POST typically creates a new resource. Use Idempotency-Key for POST to make it safe to retry.

Why is POST not idempotent?

By design, POST creates a new resource. Sending the same POST twice usually creates two resources. Idempotency-Key lets the server recognize “same logical request” and return the same result without creating a duplicate.

How do I version breaking changes?

Introduce a new API version (e.g. v2). Keep v1 for existing clients and document deprecation. In v2 you can rename/remove fields, change URLs, or change behavior.

What about backward-compatible changes?

Adding optional fields, adding new endpoints, or adding new values to enums is backward compatible. Avoid removing or renaming fields, or changing types, in the same version.

How do I document multiple versions?

Use OpenAPI/Swagger per version or a single document with version-specific paths. Mark deprecated versions and fields. Expose api-supported-versions (or similar) so clients know what is available.

Semantic versioning for APIs?

You can use Major.Minor (e.g. v1, v2) where Major = breaking changes and Minor = backward-compatible. Many REST APIs use integer versions (v1, v2) for simplicity.

services
Related Guides & Resources

services
Related services