API Versioning — URL, Headers, Content Negotiation, and Deprecation

Understand why public APIs must version at all; compare URL-path versioning (/v1/, /v2/) against custom-header versioning and Accept-header content-negotiation versioning; design changes that preserve backward compatibility without a version bump; and run a formal deprecation cycle using the Deprecation and Sunset response headers.

The moment an API has even one client outside your own deploy pipeline, you lose the ability to change it freely. A field you rename, a status code you tighten, a response shape you restructure -- any of it can silently break someone else's integration, and unlike a monolith's internal refactor, you often cannot see who is calling you or how. Versioning is the discipline of making room for that change: giving old clients a stable contract to keep running against while new clients get an improved one, and giving yourself a documented, time-boxed path to eventually retire the old contract instead of supporting it forever. This lesson covers the three common versioning mechanisms, what actually counts as a 'breaking' change, and how to sunset an old version without breaking everyone at once.

URL-Path Versioning

The most common and most visible scheme puts the version directly in the path: /v1/orders/42 versus /v2/orders/42. Its biggest advantage is that it needs zero explanation -- anyone reading a request log, a browser address bar, or a curl command immediately sees which version is in play, and routing to different controllers per version is a trivial prefix match at the web server or framework router level.

Its downside is conceptual: the same resource, order 42, now has two different URLs depending on version, which strictly violates the idea that a URL identifies one resource. In practice almost nobody cares -- URL versioning is what Stripe, GitHub (partially), and most public REST APIs use precisely because it is unambiguous and cache-friendly (a CDN or reverse proxy can cache /v1/... and /v2/... as completely separate entries with no extra logic).

• Version lives in the path: /v1/, /v2/ -- visible everywhere, trivial to route. • Two different URLs for conceptually the same resource -- a minor REST purity violation nobody actually minds. • Plays perfectly with HTTP/CDN caching since each version is a distinct URL. • The dominant scheme in practice: Stripe, most public REST APIs.

Header Versioning and Content-Negotiation Versioning

Custom-header versioning keeps one URL (/orders/42) and puts the version in a request header the client controls, e.g. X-API-Version: 2. This keeps URLs clean and 'resource-pure', but it is invisible in server logs and browser bars unless you explicitly log the header, and it is easy for a client to forget to send it (silently defaulting to whatever the server treats as default, which is a common source of confusing bugs).

Content-negotiation versioning goes further and folds the version into the standard Accept header using a custom media-type parameter, e.g. Accept: application/vnd.myapi.v2+json. This is the most 'correct' REST approach -- it reuses HTTP's built-in content-negotiation machinery rather than inventing a new header -- and it lets a single URL serve genuinely different representations the way Accept is designed to. GitHub's API historically used exactly this pattern for opting into preview features. The tradeoff is discoverability: almost no one guesses a vendor media type from documentation alone, and tooling (browser address bars, simple curl calls without an explicit header) defaults to whatever the server treats as its baseline version.

• Header versioning: X-API-Version: 2 on a stable URL -- clean URLs, invisible in logs unless you log headers. • Content-negotiation versioning: Accept: application/vnd.myapi.v2+json -- the most 'RESTful' approach, reuses standard HTTP negotiation. • Both require the client to remember to send the header every request -- a missing header silently falls back to a default version. • Pick ONE scheme per API; mixing URL and header versioning in the same API confuses clients about which one is authoritative.

What Actually Counts as a Breaking Change

Not every change needs a new version. Additive, backward-compatible changes -- adding a new optional field to a response, adding a new optional request parameter, adding a whole new endpoint, adding a new enum value a well-behaved client is expected to ignore if unrecognized -- should ship inside the CURRENT version with no bump, because an existing client that only reads the fields it knows about keeps working unchanged.

Breaking changes are ones an existing, correctly-written client cannot safely ignore: removing or renaming a field, changing a field's type or meaning (a total field that used to be dollars now being cents), changing a success status code, tightening validation so previously-valid requests start failing, or changing the default value of a parameter a client didn't explicitly set. Any of these requires a new version, because the old contract genuinely no longer holds.

A useful discipline: document your API's compatibility policy explicitly (most public APIs do -- 'we will never remove a field or change its type within a major version; we may add fields at any time') so client authors know exactly what they can safely assume will not change under them.

• Non-breaking (no version bump needed): new optional response field, new optional request param, new endpoint, new enum value. • Breaking (needs a new version): removed/renamed field, changed field type or meaning, changed status codes, tightened validation, changed defaults. • State your compatibility policy explicitly in your docs so clients know what is safe to assume.

Version-Aware Routing in a Small PHP Router

URL versioning: the version is part of the route pattern itself. Header versioning: the route stays the same; a small resolver function reads the version and dispatches. Always define an explicit default for missing version headers -- never let it silently throw or 500.

// URL-path versioning: route to a different controller per version prefix
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if (preg_match('#^/v(\d+)/orders/(\d+)$#', $path, $m)) {
    $version = (int) $m[1];
    $orderId = (int) $m[2];

    match ($version) {
        1 => (new OrdersControllerV1())->show($orderId),
        2 => (new OrdersControllerV2())->show($orderId),
        default => http_response_code(404),
    };
}

// Header versioning: same URL, version read from a request header
function resolveVersion(): int
{
    $header = $_SERVER['HTTP_X_API_VERSION'] ?? null;
    return $header !== null ? (int) $header : 1; // default to v1 if absent
}

Code Examples

The server inspects the Accept header for a vendor media type carrying a version number, defaults sensibly when absent, and returns a materially different response shape per version (v1's float dollars vs v2's unambiguous integer cents) from the exact same URL. The Vary: Accept header is essential here -- without it, an intermediate cache could serve a v2 response to a client that asked for v1 (or vice versa), because the cache has no idea the response varies by that header.

header('Vary: Accept');

Three standard headers announce a phased retirement: Deprecation tells clients this version is officially deprecated starting now, Sunset gives the exact date it will stop working entirely, and Link points to both a human migration guide and the machine-discoverable successor endpoint. Expected result: HTTP/1.1 200 OK with Deprecation, Sunset, and Link headers alongside the normal v1 JSON body header('Sunset: Sat, 31 Jan 2027 00:00:00 GMT');

Common mistake: Deleting the old endpoint outright on release day of v2 instead of running a deprecation window -- integrations you don't control cannot migrate on your schedule, and a hard cutover with no notice reads to clients as an outage, not a planned change.

<?php
declare(strict_types=1);

function negotiateVersion(string $acceptHeader): int
{
    // Accept: application/vnd.myapi.v2+json
    if (preg_match('/application\/vnd\.myapi\.v(\d+)\+json/', $acceptHeader, $m)) {
        return (int) $m[1];
    }
    return 1; // default / legacy representation
}

$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
$version = negotiateVersion($accept);

$order = findOrder((int) ($_GET['id'] ?? 0));

$payload = match ($version) {
    2 => [
        'id' => $order->id,
        'total_cents' => $order->totalCents, // v2: integer cents, unambiguous
        'currency' => $order->currency,
    ],
    default => [
        'id' => $order->id,
        'total' => $order->totalCents / 100, // v1: float dollars, legacy shape
    ],
};

header('Content-Type: application/vnd.myapi.v' . $version . '+json');
header('Vary: Accept'); // tell caches the response depends on this header
echo json_encode($payload);
<?php
declare(strict_types=1);

// v1 controller, now deprecated in favor of v2
function ordersV1Show(int $id): void
{
    // RFC 8594-style Sunset header: exact date this version stops responding
    header('Sunset: Sat, 31 Jan 2027 00:00:00 GMT');

    // Deprecation header (draft-ietf-httpapi-deprecation-header): marks it deprecated now
    header('Deprecation: true');

    // Link header pointing to the migration guide / the replacement endpoint
    header('Link: <https://api.example.com/docs/migrating-to-v2>; rel="deprecation"');
    header('Link: </v2/orders/' . $id . '>; rel="successor-version"');

    $order = findOrder($id);
    header('Content-Type: application/json');
    echo json_encode([
        'id' => $order->id,
        'total' => $order->totalCents / 100,
    ]);
}

Three versioning mechanisms, one request

All three schemes resolve to a version number before dispatch; only where that number lives differs.

URL-path:      GET /v2/orders/42
Header:        GET /orders/42            X-API-Version: 2
Content-nego:  GET /orders/42            Accept: application/vnd.myapi.v2+json

                         |
                         v
                 [ version resolver ]
                         |
              -----------+-----------
              |                     |
        OrdersControllerV1   OrdersControllerV2
        (legacy shape)        (current shape)

Important Rules

Pick exactly one versioning scheme per API and apply it consistently -- never mix URL and header versioning in the same product.

Additive, backward-compatible changes (new optional field, new endpoint) do not need a new version.

Any change an existing correct client cannot safely ignore (removed/renamed field, changed type, changed status code, changed default) needs a new version.

Announce deprecation with the Deprecation header, give a concrete removal date with Sunset, and link to a migration guide -- never remove a version with zero notice.

Always include Vary: Accept (or the equivalent) when the response shape depends on a request header, so caches don't serve the wrong version.

How Stripe versions without URL prefixes at all

Stripe's API is a well-known variant: it uses no /v1/, /v2/ path prefix visible to most users at all. Instead every account has a pinned 'API version' (a date string like 2023-10-16) stored server-side, and each request can override it with a Stripe-Version header. Stripe releases new dated versions frequently for small, well-isolated breaking changes, and a request without an explicit header simply uses whichever version is pinned to that API key. This is effectively header versioning taken to its logical extreme -- granular enough that a merchant's integration keeps working unchanged for years even as the API evolves underneath newer integrations.

Bumping to v2 for every single change, including additive ones like a new optional field.

Reserve version bumps for genuinely breaking changes; ship additive changes into the current version with no bump.

Version-churning on every change forces every client to re-test and re-migrate constantly, which defeats the entire purpose of having a stable versioned contract in the first place.

Silently removing a deprecated v1 endpoint the day v2 ships, with no Sunset header or advance notice.

Publish Deprecation and Sunset headers well before removal, keep v1 running through the sunset date, and only remove it after that date passes.

Integrations outside your control migrate on their own schedule; a surprise removal is functionally indistinguishable from an outage to anyone still depending on that version.

Which of these changes to an existing API response is NOT considered a breaking change? A. Renaming a field from total to amount B. Adding a new optional field to the response that old clients simply ignore C. Changing a field's type from string to integer D. Changing the HTTP status code returned on success from 200 to 201

B. Adding a new optional field to the response that old clients simply ignore — Adding a new optional field is additive and backward-compatible -- a well-written client that only reads the fields it recognizes keeps working unchanged, so it does not require a new version.

What is the purpose of the Sunset HTTP response header? A. It compresses the response body to reduce bandwidth B. It tells caches the response varies by a request header C. It announces the exact date a deprecated resource/version will stop being available D. It redirects clients to the new version automatically

C. It announces the exact date a deprecated resource/version will stop being available — Sunset (RFC 8594) communicates a concrete future date after which the resource is expected to no longer be available, giving clients a firm deadline to migrate by.

Design (in comments/pseudocode) a version-negotiation function for a PHP endpoint that supports BOTH URL-path versioning (/v1/, /v2/) as the primary mechanism AND falls back to an X-API-Version header when the URL has no version prefix, defaulting to v1 if neither is present. Then add Deprecation and Sunset headers for any request that resolves to v1.

function resolveVersion(string $path, ?string $headerVersion): int { if (preg_match('#^/v(\\d+)/#', $path, $m)) return (int)$m[1]; if ($headerVersion !== null) return (int)$headerVersion; return 1; } Then: if (resolveVersion(...) === 1) { header('Deprecation: true'); header('Sunset: ...'); }

Check the URL path first with a regex; if it doesn't match a /vN/ prefix, fall back to reading the header; only after both checks fail, default to 1.