API Versioning — Production-Ready Strategies for Managing API Versions

Posted on: 4/25/2026 4:13:34 AM

API Versioning is one of the most critical architectural decisions when building microservices. A solid versioning strategy lets you evolve your API continuously without breaking thousands of active clients. This article provides a deep analysis of versioning patterns, compares approaches from Stripe, GitHub, and Google, and walks through practical implementation on .NET 10.

83% Public APIs use URL versioning (2026)
6-12 months — recommended deprecation window
4 common versioning strategies
24+ months GitHub supports old API versions

1. Why API Versioning Matters

As systems evolve, APIs cannot remain static. Business requirements change, data models evolve, and you need to fix early design mistakes. But every breaking change can crash thousands of client applications depending on your API.

API Versioning solves this by allowing multiple API versions to coexist simultaneously, enabling clients to transition on their own schedule rather than being forced.

What Is a Breaking Change?

Any change that causes existing clients to fail: removing a field, renaming a field, changing a data type, removing an endpoint, changing HTTP status codes, or altering business logic that produces different responses. Conversely, adding a new field to a response is typically NOT a breaking change if clients ignore unknown fields.

2. Four Common Versioning Strategies

2.1. URL Path Versioning

The version is embedded directly in the URL path. This is the most popular method, used by Twitter, Facebook, and most public APIs.

GET /api/v1/products/123
GET /api/v2/products/123

Pros: Easy to understand, testable in browsers, cache-friendly since different URLs create different cache keys, easy to route at the API Gateway level.

Cons: URLs change with new versions, violating the principle that "a URI represents a resource." Multiple versions lead to duplicated routes.

2.2. Query String Versioning

GET /api/products/123?api-version=1.0
GET /api/products/123?api-version=2.0

Pros: Base URL stays the same, easy to add version to existing requests. Clients just append a query parameter.

Cons: Easy to forget passing the version, requires default version handling. Some CDNs don't cache well with query strings.

2.3. Header Versioning

GET /api/products/123
X-Api-Version: 1.0

GET /api/products/123
X-Api-Version: 2.0

Pros: Completely clean URLs, separates resource identity from version concerns. Ideal when the API serves different clients needing different versions on the same resource.

Cons: Cannot test directly in browsers, harder to debug, clients must configure headers on every request.

2.4. Date-Based Versioning (Stripe/GitHub Style)

GET /v1/charges
Stripe-Version: 2026-04-22

GET /api/repos
X-GitHub-Api-Version: 2026-03-10

Pros: Extremely granular — each release date can be a version. Clients are pinned to a specific point in time, guaranteeing behavior won't change. Stripe automatically pins accounts to the version when first created.

Cons: More complex to implement — requires maintaining a compatibility layer for every old version. Only suitable for teams with mature API operations.

graph TD
    A["Client Request"] --> B{"Version Detection"}
    B -->|URL Path| C["/api/v1/products"]
    B -->|Query String| D["/api/products?v=1.0"]
    B -->|Header| E["X-Api-Version: 1.0"]
    B -->|Date-Based| F["Stripe-Version: 2026-04-22"]
    C --> G["Version Router"]
    D --> G
    E --> G
    F --> G
    G --> H["Controller v1"]
    G --> I["Controller v2"]
    G --> J["Compatibility Layer"]
    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#2c3e50,stroke:#fff,color:#fff
    style G fill:#16213e,stroke:#fff,color:#fff
    style H fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style I fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style J fill:#f8f9fa,stroke:#e94560,color:#2c3e50

API Version Detection flow

3. Detailed Strategy Comparison

Criteria URL Path Query String Header Date-Based
Popularity Very high (83%) Medium Low Low (specialized)
Ease of use Very easy Easy Medium Hard
Cache-friendly Good Medium Requires Vary config Requires Vary config
Clean URLs No No Yes Yes
Browser testable Easy Easy Not possible Not possible
Version granularity Major only Major.Minor Custom Very granular (daily)
Best for Public APIs Internal APIs B2B APIs Fintech, PaaS

4. Case Studies: Stripe and GitHub

4.1. Stripe — Sophisticated Date-Based Versioning

Stripe uses a date-based versioning system where each version is a date string (e.g., 2026-04-22). When a developer creates their Stripe account for the first time, the API version at that moment is automatically pinned to their account. All requests from that account use that version unless overridden with the Stripe-Version header.

This approach ensures that developer code never spontaneously breaks — even as Stripe releases hundreds of changes per year. When ready to upgrade, developers proactively bump their version via the dashboard or header.

Lessons from Stripe

Stripe maintains a massive compatibility layer — each time there's a new breaking change, they write a "version change" transformer that converts the new response into the old format for older versions. These transformers are chained: a request for v2024-01-01 passes through every transformer from the current version back to 2024-01-01. This model is extremely effective but demands high engineering discipline.

4.2. GitHub — Date-Based with 24-Month Support

GitHub's REST API uses date-based versions (e.g., 2026-03-10), passed via the X-GitHub-Api-Version header. Each version is supported for at least 24 months after a new version is released. GitHub also publishes a clear list of breaking changes for each version in their documentation.

5. Implementation on ASP.NET Core with Asp.Versioning

The Asp.Versioning library (formerly Microsoft.AspNetCore.Mvc.Versioning) is the official solution for API versioning on .NET. It supports all 4 versioning strategies and integrates deeply with ASP.NET Core.

5.1. Installation

dotnet add package Asp.Versioning.Http
dotnet add package Asp.Versioning.Mvc.ApiExplorer

5.2. Basic Configuration

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"),
        new QueryStringApiVersionReader("api-version")
    );
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

5.3. Controller Versioning

[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetProduct(int id)
    {
        return Ok(new { Id = id, Name = "Widget", Price = 29.99 });
    }
}

[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("2.0")]
public class ProductsV2Controller : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetProduct(int id)
    {
        return Ok(new
        {
            Id = id,
            Name = "Widget",
            Price = new { Amount = 29.99, Currency = "USD" },
            Category = "Electronics",
            CreatedAt = DateTime.UtcNow
        });
    }
}

5.4. Minimal API Versioning

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

app.MapGet("/api/v{version:apiVersion}/products/{id}", (int id) =>
    Results.Ok(new { Id = id, Name = "Widget" }))
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(new ApiVersion(1, 0));

app.MapGet("/api/v{version:apiVersion}/products/{id}", (int id) =>
    Results.Ok(new { Id = id, Name = "Widget",
        Price = new { Amount = 29.99, Currency = "USD" } }))
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(new ApiVersion(2, 0));

5.5. Deprecation with Sunset Header

[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}"), MapToApiVersion("1.0")]
    public IActionResult GetV1(int id) => Ok(new { Id = id });

    [HttpGet("{id}"), MapToApiVersion("2.0")]
    public IActionResult GetV2(int id) => Ok(new { Id = id, Category = "Electronics" });
}

When Deprecated = true, ASP.NET Core automatically adds the api-deprecated-versions: 1.0 header to every response. You can add middleware to inject the Sunset header (RFC 8594):

app.Use(async (context, next) =>
{
    await next();
    var feature = context.Features.Get<IApiVersioningFeature>();
    if (feature?.RequestedApiVersion?.MajorVersion == 1)
    {
        context.Response.Headers["Sunset"] = "Sat, 01 Nov 2026 00:00:00 GMT";
        context.Response.Headers["Deprecation"] = "true";
        context.Response.Headers["Link"] =
            "<https://api.example.com/docs/migration-v2>; rel=\"successor-version\"";
    }
});
sequenceDiagram
    participant C as Client
    participant GW as API Gateway
    participant R as Version Router
    participant V1 as Controller v1 (deprecated)
    participant V2 as Controller v2

    C->>GW: GET /api/v1/products/1
    GW->>R: Route to v1
    R->>V1: Handle request
    V1-->>C: 200 OK + Sunset: 2026-11-01
    Note over C: Client receives deprecation warning

    C->>GW: GET /api/v2/products/1
    GW->>R: Route to v2
    R->>V2: Handle request
    V2-->>C: 200 OK (full response)

Deprecation flow with Sunset header

6. API Versioning at the API Gateway Level

In microservices architecture, the API Gateway plays a central role in version management. Instead of each service handling versioning independently, the Gateway can route requests to the correct service version.

graph LR
    C["Client"] --> GW["API Gateway
(YARP / Kong)"] GW -->|v1/*| S1A["Service A - v1"] GW -->|v2/*| S1B["Service A - v2"] GW -->|v1/*| S2["Service B - v1"] GW -->|v2/*| S2B["Service B - v2"] S1A --> DB1["Database"] S1B --> DB1 S2 --> DB2["Database"] S2B --> DB2 style GW fill:#e94560,stroke:#fff,color:#fff style C fill:#2c3e50,stroke:#fff,color:#fff style S1A fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style S1B fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style S2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style S2B fill:#f8f9fa,stroke:#e94560,color:#2c3e50

API Gateway routing by version

6.1. YARP (Yet Another Reverse Proxy) on .NET

{
  "Routes": {
    "products-v1": {
      "ClusterId": "products-v1",
      "Match": { "Path": "/api/v1/products/{**catch-all}" }
    },
    "products-v2": {
      "ClusterId": "products-v2",
      "Match": { "Path": "/api/v2/products/{**catch-all}" }
    }
  },
  "Clusters": {
    "products-v1": {
      "Destinations": {
        "primary": { "Address": "https://products-v1.internal:5001/" }
      }
    },
    "products-v2": {
      "Destinations": {
        "primary": { "Address": "https://products-v2.internal:5002/" }
      }
    }
  }
}

6.2. Two Multi-Version Deployment Strategies

Strategy Description When to Use
Same codebase All versions in one project, using [ApiVersion] attributes to differentiate Small changes, few breaking changes
Separate services Each version is a separate deployment, Gateway routes by version Major changes, completely different schemas

7. Managing Breaking Changes

7.1. Additive Change Strategy

The golden rule: always add, never remove or rename. If you follow this rule, you almost never need to create a new version.

  • Adding a new field to response — NOT breaking
  • Adding an optional parameter to request — NOT breaking
  • Adding a new endpoint — NOT breaking
  • Removing a field from response — BREAKING
  • Renaming a field — BREAKING
  • Changing data type (string → object) — BREAKING
  • Changing HTTP status code — BREAKING

7.2. Compatibility Layer Pattern (Stripe-style)

Instead of maintaining separate codebases for each version, you write the latest code and create transformers that convert responses back to older formats:

public interface IVersionTransformer
{
    ApiVersion TargetVersion { get; }
    object Transform(object currentResponse);
}

public class ProductV1Transformer : IVersionTransformer
{
    public ApiVersion TargetVersion => new(1, 0);

    public object Transform(object currentResponse)
    {
        if (currentResponse is ProductV2Response v2)
        {
            return new ProductV1Response
            {
                Id = v2.Id,
                Name = v2.Name,
                Price = v2.Price.Amount // v2 has Price object, v1 only has decimal
            };
        }
        return currentResponse;
    }
}
graph LR
    A["Request v1"] --> B["Latest Controller"]
    B --> C["Response v3 format"]
    C --> D["Transformer v3→v2"]
    D --> E["Transformer v2→v1"]
    E --> F["Response v1 format"]
    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#2c3e50,stroke:#fff,color:#fff
    style F fill:#4CAF50,stroke:#fff,color:#fff
    style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Compatibility Layer — chaining transformers from newest to oldest version

8. Professional Deprecation Roadmap

Month 0 — Announce
Announce the new version, add Deprecated = true to the old version. Email developers, update changelog.
Month 1-3 — Grace Period
Old version responses include Sunset header with a specific date. Monitor traffic — what percentage of clients still use the old version?
Month 3-6 — Active Migration
Send reminders to clients who haven't migrated. Provide detailed migration guides. Enable warning logs for all old version requests.
Month 6-12 — Final Notice
Announce the exact shutdown date for the old version. Reduce rate limits for the old version to encourage migration.
Month 12+ — Sunset
Return 410 Gone for the old version, with a link to documentation guiding the upgrade.

9. Versioning for GraphQL APIs

GraphQL follows a "versionless" philosophy — there are no explicit versions. Instead, the schema evolves continuously through:

  • Adding new fields — clients only query the fields they need, new fields don't affect them
  • Deprecating fields using the @deprecated(reason: "Use priceV2 instead") directive
  • Never removing fields — only deprecate and keep them indefinitely or until no client queries them
type Product {
  id: ID!
  name: String!
  price: Float @deprecated(reason: "Use priceV2 for currency support")
  priceV2: Money!
  category: String!
}

type Money {
  amount: Float!
  currency: String!
}

GraphQL vs REST Versioning

With GraphQL, you rarely need versioning because clients self-select fields. But if you have behavior changes (not just schema changes), you still need a versioning mechanism — for example, using a X-Schema-Version header or a separate endpoint like /graphql/v2.

10. Anti-Patterns to Avoid

Common Mistakes

1. Too many versions: Maintaining 5+ versions simultaneously is a nightmare. Stripe is the exception because they have a dedicated team. Most teams should limit to 2-3 active versions.

2. Versioning when unnecessary: Don't create v2 just to add a new field. Additive changes don't need a new version.

3. Ignoring default version: When AssumeDefaultVersionWhenUnspecified = false, legacy clients not sending a version will get 400 Bad Request. Always set to true during the transition period.

4. Not tracking usage: If you don't know how many clients are using v1, you don't know when it's safe to shut it down.

5. Unannounced breaking changes: Changing behavior without bumping the version is the most serious contract violation.

11. Monitoring and Observability

Tracking usage per API version is essential for deprecation decisions. Integrate OpenTelemetry to record metrics:

app.Use(async (context, next) =>
{
    var version = context.GetRequestedApiVersion()?.ToString() ?? "unspecified";
    var meter = context.RequestServices.GetRequiredService<Meter>();
    var counter = meter.CreateCounter<long>("api.requests.by_version");
    counter.Add(1, new KeyValuePair<string, object?>("api.version", version));
    await next();
});

With this metric, you can create a Grafana dashboard showing:

  • Traffic distribution by version (% v1 vs v2)
  • Migration trend — v1 traffic decreasing over time
  • Alerts when a deprecated version still has high traffic past its deadline

12. Conclusion

API Versioning isn't just a technical decision — it's a commitment to developers using your API that their code won't spontaneously break. Choose the strategy that fits your team size and client audience:

  • Public API, small team → URL Path Versioning + limit to 2-3 versions
  • Internal microservices API → Header Versioning + API Gateway routing
  • Large fintech/platform API → Date-Based Versioning (Stripe model)
  • GraphQL → Schema evolution + @deprecated directive

Regardless of which strategy you choose, always prioritize additive changes, implement Sunset headers when deprecating, and monitor usage with OpenTelemetry. A great API is one that developers trust — and versioning is the foundation of that trust.

References