Skip to content

API Versioning

Canonical guide: API route contracts, implementation patterns, OpenAPI, gateway aggregation, migration, rollout, and troubleshooting.

Standard contract: versioned URLs such as /api/v1/... and /api/v2/....

Section: 15 - API
Last Updated: 2026-06-21


Quick Start

Every HTTP API service registers the shared web stack. This registers controllers, API Versioning, API Explorer, Swagger conventions, and the compatibility middleware when enabled:

csharp
builder.Services.AddSharedWeb(builder.Configuration, builder.Environment);

var app = builder.Build();
app.ConfigureSharedWeb(builder.Configuration);

Use this standard service configuration:

json
{
  "ApiVersioning": {
    "EnableVersionedRoutePrefixConvention": true,
    "EnableLegacyUnversionedRouteCompatibility": true
  }
}

New requests use a URL version:

text
GET /api/v1/orders/42
GET /api/v2/orders/42

Do not put version strings in service startup code. Controllers use shared attributes and constants; package-owned minimal endpoints version themselves internally.

Direct Package Setup

Services that do not use AddSharedWeb can use the complete versioning package directly:

csharp
using Microtec.Web.ApiVersioning;

// Registers API Versioning, API Explorer, and version-aware Swagger.
builder.Services.AddMicrotecApiVersioning(builder.Configuration);

var app = builder.Build();

app.UseHttpsRedirection();

// Adds Swagger UI and temporary legacy /api/... compatibility before routing.
app.UseMicrotecApiVersioning(builder.Configuration);

app.UseRouting();

AddMicrotecSwagger() remains an idempotent advanced method, but normal services do not call it separately.


Ownership and Responsibilities

Package or componentResponsibility
Microtec.Web.ApiVersioningAPI-version options, route construction, controller conventions, ApiVersions constants, compatibility middleware, version-aware Swagger, and MapMicrotecVersionedGroup(...).
Microtec.Web.HostingShared-hosting convenience registration plus ERP-token and background-job endpoint mapping.
Endpoint packagesMap their own minimal endpoints through the shared versioned-group helper. Examples: Attachment, Templates, and Workflows.
Application Program.csRegisters and maps features. It does not construct versioned route strings or Swagger group names.
YARP GatewayAggregates the downstream Swagger document versions that each service declares. It does not rewrite arbitrary downstream API paths.

The public C# namespace is Microtec.Web.ApiVersioning; the versioning and Swagger implementation no longer belongs to Microtec.Web.Hosting.


Infrastructure Services

InfrastructureServices follows the same contract; it does not define a second versioning model.

ServiceVersioned surfaceImplementation
AttachmentsController APIs and package-owned attachment endpointsAddSharedWeb / ConfigureSharedWeb plus the shared v1 attachment mapper.
NotificationsController APIs and /api/v1/background-jobs/executeShared registration, the shared Swagger/compatibility pipeline, and MapBackgroundJobCallback().
TemplatesController APIs, including the legacy print pathsShared minimal-web registration and pipeline. The legacy /Template/... aliases are rewritten before API-version compatibility resolves them to v1.

Each service enables EnableVersionedRoutePrefixConvention and EnableLegacyUnversionedRouteCompatibility in its base appsettings.json. New clients call /api/v1/...; the previous unversioned API paths remain only as a temporary internal compatibility path.


Contract and Terminology

TermMeaning
API versionThe compatibility contract exposed to a caller, such as v1 or v2.
Route versionThe URL segment selected by the caller: /api/v{version}/....
Swagger document groupThe OpenAPI document name for a version, such as v1 or v2.
Default versionv1 (1.0) unless configuration changes it.
Breaking changeA change that can invalidate an existing caller. Add it in a new major API version instead of changing v1.
Legacy routeAn old unversioned URL such as /api/orders/42. It may be temporarily rewritten to /api/v1/orders/42.

The route version is the default and canonical reader. The header reader is disabled unless explicitly enabled for a controlled migration.

Version Lifecycle

  1. Add compatible behavior to the existing version when it does not change the contract.
  2. Add a v2 endpoint or controller when the contract changes incompatibly.
  3. Keep v1 live while consumers migrate.
  4. Publish and deploy the new service/package versions.
  5. Add v2 to the gateway endpoint configuration after the downstream Swagger document is available.
  6. Deprecate and later remove v1 only through a separately approved lifecycle decision.

An endpoint is not automatically carried from v1 into v2. It is available only in the version groups or controller mappings where it is explicitly declared.

Internal Service Calls

Calls between Microtec services must use the canonical versioned URL and must not depend on legacy-route compatibility. Use ApiVersionRoutes.BuildV1(...) from Microtec.Web.ApiVersioning when a client has a service base URL:

csharp
var url = ApiVersionRoutes.BuildV1(
    apiUrlOptions.Value.AttachmentServiceURL,
    "Attachment/DownloadBase64Attachment/{attachmentId}");

For a relative client route, compose it from ApiVersionRoutes.V1Prefix, for example ApiVersionRoutes.V1Prefix + "/Attachment". Service base URLs in ApiUrls remain host roots; the route helper owns the /api/v1 segment.


Complete Configuration Reference

All properties below bind from the ApiVersioning configuration section. Keep defaults unless there is a documented integration or migration need.

OptionDefaultEffect and usage
EnabledtrueMaster switch. Set false only for a service that must remain unversioned.
DefaultMajorVersion1Major component of the default version.
DefaultMinorVersion0Minor component of the default version.
AssumeDefaultVersionWhenUnspecifiedtrueUses the default when a version reader finds no version. It does not make an unversioned URL match a versioned route; use legacy compatibility for that.
ReportApiVersionstrueEmits supported/deprecated API-version response headers when the API Versioning library can report them.
EnableUrlSegmentReadertrueReads the version from /api/v1/.... Keep enabled for the standard contract.
EnableHeaderReaderfalseEnables version selection from HeaderName. Use only during a documented migration.
SubstituteApiVersionInUrltrueReplaces the route token in generated OpenAPI paths, for example {version:apiVersion} becomes 1.
EnableVersionedRoutePrefixConventionfalseAdds the versioned prefix to attribute-routed MVC controller templates. It does not change minimal endpoints.
EnableLegacyUnversionedRouteCompatibilityfalseInternally rewrites eligible legacy paths to the default version. It is not an HTTP redirect.
RoutePrefixapiRoot API route segment.
RouteVersionParameterversionName of the route token used for version selection.
RouteConstraintapiVersionASP.Versioning route-constraint name.
HeaderNameX-Api-VersionHeader read when EnableHeaderReader is enabled.
SwaggerGroupNameFormat'v'VVVAPI Explorer group format. It normally creates v1, v2, and so on.
SwaggerSubstitutionFormatVVVVersion format inserted into OpenAPI route paths.
SwaggerRouteTemplate/swagger/{documentName}/swagger.jsonRoute used to serve each generated OpenAPI document.
SwaggerEndpointTemplate../swagger/{0}/swagger.jsonRelative URL used by Swagger UI to load a document.
SwaggerTitlenullOptional title for all generated documents. The application assembly name is used when omitted.
SwaggerDescriptionnullOptional description for all generated documents. A default application description is used when omitted.
LegacyRewriteExcludedPathPrefixes/swagger, /health, /healthy, /metrics, /hangfire, /gateway/swaggerPaths that must never be rewritten by legacy compatibility.
VersionedRouteConventionExcludedPathPrefixesSame as LegacyRewriteExcludedPathPrefixesPaths that must not be changed by the MVC route-prefix convention.

Standard Configuration

json
{
  "ApiVersioning": {
    "Enabled": true,
    "DefaultMajorVersion": 1,
    "DefaultMinorVersion": 0,
    "AssumeDefaultVersionWhenUnspecified": true,
    "ReportApiVersions": true,
    "EnableUrlSegmentReader": true,
    "EnableHeaderReader": false,
    "SubstituteApiVersionInUrl": true,
    "EnableVersionedRoutePrefixConvention": true,
    "EnableLegacyUnversionedRouteCompatibility": true
  }
}

Optional Reader Migration

Do not enable multiple readers for routine application development. When an external client cannot move immediately, enable the required reader deliberately and document its removal date:

json
{
  "ApiVersioning": {
    "EnableUrlSegmentReader": true,
    "EnableHeaderReader": true,
    "HeaderName": "X-Api-Version"
  }
}

The standard URL still remains /api/v1/...; a header reader is a temporary compatibility mechanism, not a replacement contract.


MVC Controllers

New v1 Controller

Use the standard ASP.Versioning attribute with the shared constant and route constant. This avoids local route or version strings:

csharp
using Asp.Versioning;
using Microtec.Web.ApiVersioning;

[ApiController]
[ApiVersion(ApiVersions.V1)]
[Route(MicrotecApiVersioningDefaults.VersionedControllerRoute)]
public sealed class OrdersController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(Guid id) => Ok();
}

The route is /api/v1/orders/{id} and Swagger places the operation in document v1.

Separate v1 and v2 Controllers

Use separate controllers when request/response types or behavior materially diverge:

csharp
using Asp.Versioning;
using Microtec.Web.ApiVersioning;

[ApiController]
[ApiVersion(ApiVersions.V1)]
[Route(MicrotecApiVersioningDefaults.VersionRoutePrefix + "/orders")]
public sealed class OrdersV1Controller : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(Guid id) => Ok();
}

[ApiController]
[ApiVersion(ApiVersions.V2)]
[Route(MicrotecApiVersioningDefaults.VersionRoutePrefix + "/orders")]
public sealed class OrdersV2Controller : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(Guid id) => Ok();
}

The same logical resource can therefore evolve independently at /api/v1/orders/{id} and /api/v2/orders/{id}.

Version-Specific Actions in One Controller

Use action mappings when most endpoints are shared but a small number differ:

csharp
using Asp.Versioning;
using Microtec.Web.ApiVersioning;

[ApiController]
[ApiVersion(ApiVersions.V1)]
[ApiVersion(ApiVersions.V2)]
[Route(MicrotecApiVersioningDefaults.VersionRoutePrefix + "/orders")]
public sealed class OrdersController : ControllerBase
{
    [HttpGet("summary")]
    [MapToApiVersion(ApiVersions.V1)]
    public IActionResult GetSummaryV1() => Ok();

    [HttpGet("summary")]
    [MapToApiVersion(ApiVersions.V2)]
    public IActionResult GetSummaryV2() => Ok();
}

ApiVersions contains compile-time constants for the common major contracts. Always use the standard ASP.Versioning attributes so the supported version is explicit at the controller and action.

v3 Controllers and Actions

Use the standard attributes in the same way for v3. It produces /api/v3/... routes and Swagger document v3:

csharp
using Asp.Versioning;
using Microtec.Web.ApiVersioning;

[ApiController]
[ApiVersion(ApiVersions.V3)]
[Route(MicrotecApiVersioningDefaults.VersionRoutePrefix + "/orders")]
public sealed class OrdersV3Controller : ControllerBase
{
    [HttpGet("summary")]
    [MapToApiVersion(ApiVersions.V3)]
    public IActionResult GetSummary() => Ok();
}

Service-Specific Minor Versions

Minor versions are owned by the service that introduces the contract. Define the compile-time constant once in that service, then use the standard attributes. This keeps the core package focused on shared major versions and prevents literals from spreading through endpoint code:

csharp
using Asp.Versioning;
using Microtec.Web.ApiVersioning;

public static class OrdersApiVersions
{
    public const string V1_1 = "1.1";
}

[ApiController]
[ApiVersion(OrdersApiVersions.V1_1)]
[Route(MicrotecApiVersioningDefaults.VersionRoutePrefix + "/orders")]
public sealed class OrdersV1_1Controller : ControllerBase
{
}

This produces /api/v1.1/... and Swagger document v1.1. For a minimal endpoint group, use the matching typed value: MicrotecApiVersions.Create(1, 1).

Existing Attribute-Routed Controllers

Controllers without explicit API-version metadata receive the default version through the shared controller convention. To migrate an existing [Route("api/...")] controller without changing every template:

  1. Enable EnableVersionedRoutePrefixConvention.
  2. Deploy the generated /api/v1/... routes.
  3. Enable EnableLegacyUnversionedRouteCompatibility only while old callers still need /api/....
  4. Remove legacy compatibility after callers have moved.

The route convention applies to attribute-routed controller templates. Minimal endpoints need the versioned-group pattern below.

Version-Neutral Controllers

Use the standard ASP.Versioning ApiVersionNeutral metadata only for endpoints that genuinely have no API contract version, such as a narrowly scoped operational controller. Do not use version-neutral metadata to avoid a normal API migration.


Minimal Endpoints

Minimal APIs do not pass through MVC controller conventions. They must be mapped through MapMicrotecVersionedGroup(...), either directly in a package-owned mapper or through a built-in mapper that already uses it.

Built-In Mappers

Application startup remains simple:

csharp
app.MapAttachmentEndpoints();
app.MapTemplatesEndpoints();
app.MapInternalControllers();
app.MapBusinessOwnerEndpoints();
app.MapErpTokenEndpoints();
app.MapBackgroundJobCallback();
MapperVersioned route prefixDefault Swagger group
MapAttachmentEndpoints()/api/v1/attachmentsv1
MapTemplatesEndpoints()/api/v1/templatesv1
MapInternalControllers()/api/v1/workflowsv1
MapBusinessOwnerEndpoints()/api/v1/SideMenuv1
MapErpTokenEndpoints()/api/v1/erpv1
MapBackgroundJobCallback()/api/v1/background-jobsv1

Do not pass BuildVersionedRoute(...), WithGroupName(...), or a manual Swagger group from Program.cs. The mapper owns route construction and Swagger grouping.

Built-In Mapper v2

The no-argument mappers remain v1. When an entire built-in mapper supports v2, pass a typed ApiVersion; Swagger derives v2 from that same value:

csharp
using Microtec.Web.ApiVersioning;

app.MapAttachmentEndpoints("attachments", MicrotecApiVersions.V2);

Do not pass a string such as "v2" to select a Swagger document. The API version is the single source of truth for the route, endpoint metadata, and document group.

Custom v1 and v2 Groups

Declare route and version values once in the endpoint package. Map each endpoint only into the versions that support it:

csharp
using Asp.Versioning;
using Microtec.Web.ApiVersioning;

public static class OrdersEndpointRoutes
{
    public const string Prefix = "orders";
    public const string Tag = "Orders";
    public const string GetById = "{id}";
    public const string Recalculate = "{id}/recalculate";

    public static readonly ApiVersion V1 = MicrotecApiVersions.V1;

    public static readonly ApiVersion V2 = MicrotecApiVersions.V2;
}

public static class OrdersEndpointExtensions
{
    public static IEndpointRouteBuilder MapOrdersEndpoints(
        this IEndpointRouteBuilder app)
    {
        var v1 = app.MapMicrotecVersionedGroup(
            OrdersEndpointRoutes.Prefix,
            OrdersEndpointRoutes.Tag,
            OrdersEndpointRoutes.V1);

        v1.MapGet(OrdersEndpointRoutes.GetById, GetOrderV1);

        var v2 = app.MapMicrotecVersionedGroup(
            OrdersEndpointRoutes.Prefix,
            OrdersEndpointRoutes.Tag,
            OrdersEndpointRoutes.V2);

        v2.MapGet(OrdersEndpointRoutes.GetById, GetOrderV2);
        v2.MapPost(OrdersEndpointRoutes.Recalculate, RecalculateOrder);

        return app;
    }
}

This produces:

OperationRouteSwagger document
Read order v1GET /api/v1/orders/{id}v1
Read order v2GET /api/v2/orders/{id}v2
Recalculate orderPOST /api/v2/orders/{id}/recalculatev2 only

To keep an unchanged endpoint available in v2, map the same handler into the v2 group. To make an endpoint v2-only, do not map it into the v1 group.

Service startup remains unchanged:

csharp
app.MapOrdersEndpoints();

What the Versioned Group Adds

MapMicrotecVersionedGroup(...) performs all of the following:

  1. Normalizes a prefix such as orders or api/orders.
  2. Creates /api/v{version:apiVersion}/orders when versioning is enabled.
  3. Registers the API-version set for the supplied version.
  4. Adds the endpoint tag.
  5. Assigns the matching Swagger group, such as v1 or v2.

When ApiVersioning:Enabled is false, it maps the supplied prefix without API-version metadata.

BuildVersionedRoute(...) returns a route template containing {version:apiVersion}. Use BuildVersionedPath(...) when code needs a concrete URL, such as an internal callback URL; it substitutes the configured default version and produces /api/v1/....


Swagger and OpenAPI

Generated Documents

With Swagger enabled, each discovered API version has its own document:

text
/swagger/v1/swagger.json
/swagger/v2/swagger.json

Swagger UI is normally available at /swagger. EnableSwagger controls whether the shared Swagger middleware is enabled; it is separate from the ApiVersioning section.

How Operations Reach a Document

Endpoint sourceSwagger placement
Controller marked [ApiVersion(ApiVersions.V1)]v1
Controller marked [ApiVersion(ApiVersions.V2)]v2
Existing controller with no explicit metadataDefault document, normally v1
Minimal group created with V1v1
Minimal group created with V2v2
Ungrouped API descriptionDefault document only, normally v1

Ungrouped endpoints are intentionally not included in every version document. This prevents a default controller or endpoint from leaking into v2 simply because v2 exists.

Path Substitution

Source routes use the API-version route token:

text
/api/v{version:apiVersion}/orders/{id}

When SubstituteApiVersionInUrl is true, the generated v1 OpenAPI document shows:

text
/api/v1/orders/{id}

The version route parameter is removed from each operation after substitution, so Swagger UI does not ask callers to supply it separately.

Swagger Configuration Errors

SymptomLikely causeResolution
Endpoint is missing from v2It was mapped only to v1 or the controller has no ApiVersion(ApiVersions.V2) metadata.Add it to the v2 group or annotate/map the controller for v2.
Endpoint appears in the wrong versionManual OpenAPI group metadata conflicts with version metadata.Remove manual WithGroupName(...) usage and use the versioned-group helper.
Swagger shows {version:apiVersion}SubstituteApiVersionInUrl is disabled.Restore the default true setting.
Swagger document is not servedEnableSwagger is false or the service does not call ConfigureSharedWeb.Enable Swagger for the intended environment and use shared startup.

See Swagger / OpenAPI for UI authentication and specification export.


Legacy Unversioned Route Migration

When enabled, compatibility middleware rewrites eligible requests inside the service:

text
Incoming:  /api/orders/42
Handled as: /api/v1/orders/42

It restores the original request path after the downstream pipeline finishes. It does not send a 301, 302, or other HTTP redirect to the caller.

Migration Procedure

  1. Enable EnableVersionedRoutePrefixConvention and deploy /api/v1/... routes.
  2. Verify the v1 Swagger document and versioned requests.
  3. Enable EnableLegacyUnversionedRouteCompatibility only when active clients still call legacy URLs.
  4. Publish the date and route examples for clients to move to /api/v1/....
  5. Observe legacy traffic and migrate callers.
  6. Disable compatibility once legacy callers are gone.

Do not add /swagger, health checks, metrics, Hangfire, gateway Swagger, or static files to the rewrite path. The default exclusion lists protect these routes; extend them only for a known non-API path.

Background Job Callbacks

MapBackgroundJobCallback() maps /api/v1/background-jobs/execute. When the REST background-job provider builds the callback URL, it uses BuildVersionedPath(...) with BackgroundJobs:CallbackPrefix. Keep the configuration value as the logical prefix:

json
{
  "BackgroundJobs": {
    "CallbackPrefix": "api/background-jobs"
  }
}

The generated callback URL is versioned automatically while API versioning is enabled. Do not hard-code v1 into CallbackPrefix.


Gateway Swagger Aggregation

The YARP gateway aggregates downstream Swagger documents. It does not create downstream API versions; it only exposes the versions declared by a service.

Service Entry

Use one SwaggerEndpoints entry per service and list only the versions the downstream service actually provides:

json
{
  "ServiceName": "orders-apis",
  "Url": "https://orders-apis.example.com/swagger/{version}/swagger.json",
  "Name": "Orders API",
  "Versions": [ "v1", "v2" ]
}

The {version} token is replaced with the requested document version. A URL using /swagger/v1/swagger.json is also supported; the gateway replaces the v1 segment when another configured version is requested.

Gateway Defaults

json
{
  "GatewaySwagger": {
    "DefaultVersions": [ "v1" ],
    "GatewayVersions": [ "v1" ],
    "GatewayDisplayName": "Gateway API"
  }
}
SettingMeaning
DefaultVersionsUsed only when a service entry has no Versions array. Prefer explicit service versions.
GatewayVersionsVersions of the gateway's own OpenAPI documents. Keep this separate from downstream service versions.
GatewayDisplayNameLabel used for the gateway's own Swagger UI entries.
SwaggerEndpoints[].VersionsDownstream Swagger documents displayed for that service. Add v2 only after the downstream document is deployed.

Gateway Swagger UI is served at /gateway/swagger. Aggregated downstream documents are fetched through:

text
/gateway/swagger/docs/{serviceName}/{version}/swagger.json

The gateway injects the configured downstream API key and rewrites downstream paths so Swagger UI requests travel back through the gateway. If a service is offline, the gateway returns a valid placeholder document instead of breaking the entire Swagger UI.

Keep these gateway settings disabled because the gateway proxies arbitrary downstream paths:

json
{
  "ApiVersioning": {
    "EnableVersionedRoutePrefixConvention": false,
    "EnableLegacyUnversionedRouteCompatibility": false
  }
}

See Gateway Routes for the YARP path rules.


Testing Checklist

Every versioned change should prove both routing and documentation behavior.

CheckExpected result
GET /api/v1/...The intended v1 endpoint responds.
GET /api/v2/...The intended v2 endpoint responds only when it has been mapped or annotated for v2.
v2-only operation through v1It is unavailable and absent from Swagger v1.
/swagger/v1/swagger.jsonContains v1 operations and concrete /api/v1/... paths.
/swagger/v2/swagger.jsonContains only v2 operations and concrete /api/v2/... paths.
Legacy /api/... request during migrationReaches the equivalent default-version route only when compatibility is enabled.
Background-job REST callbackUses /api/v1/background-jobs/execute while versioning is enabled.
Gateway Swagger v2 entryLoads only after the downstream service has deployed /swagger/v2/swagger.json.

For minimal endpoints, assert both the route and the Swagger group. A route can look versioned while being placed in the wrong document if custom mapping bypasses MapMicrotecVersionedGroup(...).


Publishing and Rollout

When a versioning-core change affects endpoint mapping, publish the changed packages together so their shared version metadata stays aligned:

PackagePublish when
Microtec.Web.ApiVersioningRoute/group helper or option defaults change.
Microtec.Web.HostingRegistration, controller conventions, compatibility, Swagger, ERP tokens, or background callbacks change.
Microtec.Attachment.ClientAttachment endpoint mapper changes.
Microtec.Templates.ClientTemplate endpoint mapper changes.
Microtec.PublicApiWorkflow endpoint mapper changes.

After publishing, update the affected central package versions in Platforms and InfrastructureServices, restore, build, and deploy downstream services before adding their new version to gateway SwaggerEndpoints.

The Microtec.Web.ApiVersioning package is transitive for normal service applications; applications do not need to add it directly unless they intentionally use the low-level route/group API outside an existing package mapper.


Troubleshooting

SymptomCauseFix
Route is still /api/...MVC route convention is disabled, or a minimal endpoint bypasses the shared group helper.Enable the MVC convention for attribute-routed controllers; use MapMicrotecVersionedGroup(...) inside minimal mappers.
Route looks like /api/{version}1/...The route template concatenated a literal version incorrectly.Use MicrotecApiVersioningDefaults.VersionRoutePrefix or the shared group helper.
New operation is visible in v1 and v2Controller/action was explicitly mapped to both, or custom group metadata is incorrect.Keep the operation only in its intended version mapping; remove manual group metadata.
New operation is missing from v2It was never mapped/annotated for v2.Add an ApiVersion(ApiVersions.V2) controller/action mapping or map it in a V2 minimal group.
Old client receives a route failureThe client still calls /api/... after compatibility has been disabled.Move the client to /api/v1/..., or temporarily re-enable compatibility with an explicit removal plan.
Swagger shows an unversioned path tokenVersion substitution is disabled.Set SubstituteApiVersionInUrl to true.
Gateway cannot load a documentThe service is offline, its port/URL is wrong, or Versions declares a document the service has not deployed.Verify downstream connectivity and Swagger URL, then make Versions match the deployed documents.
Background worker callback failsThe service is using an outdated Hosting package or callback base URL is incorrect.Update Microtec.Web.Hosting, keep CallbackPrefix logical, and verify the service base URL.

Internal Documentation — Microtec