Implementing API Versioning: URL, Header, and Content Negotiation Approaches

Philip Rehberger Sep 17, 2026 7 min read

Move from "we should version" to a concrete implementation. Includes routing and deprecation tooling.

You knew you should version your API. You did not, because you were small and moving fast. Now you have customers integrated against the current shape, and you want to ship a breaking change. The path forward is one of the well-trodden API versioning patterns — and the implementation details matter more than the choice between them.

This post is the three real options for transporting a version, what each one costs to implement, and the deprecation patterns that actually work in production.

The Three Mechanisms

URL path versioning. The version is in the URL itself.

GET /v1/users/me
GET /v2/users/me

Most common, easiest to reason about, easiest to debug from logs and curl commands.

Header versioning. The version is in a custom header.

GET /users/me
X-API-Version: 2

The URL stays clean across versions. Tools that look at URLs alone (web caches, browser history, status pages) cannot tell the versions apart.

Content negotiation. The version is encoded in the Accept header.

GET /users/me
Accept: application/vnd.yourcompany.v2+json

The most RESTful in theory, the most awkward in practice. Most consumers do not love writing custom media types.

The Pragmatic Pick

For 95% of APIs, URL path versioning is the right answer. Reasons:

  • It is obvious in logs, traces, and dashboards
  • Engineers can curl /v1/... vs curl /v2/... without setting headers
  • Documentation is straightforward (different URLs for different versions)
  • Browser-based tools and OpenAPI/Swagger handle it natively
  • CDN caching works without surprise (URL is the cache key)

The arguments against URL versioning — "the URL should identify the resource, not the protocol" — are technically right and practically irrelevant. Customers do not care about REST purity; they care about an API that works.

Use header versioning when you genuinely need URLs to be stable across versions (rare). Use content negotiation when you are building a HATEOAS-style API with deep media type semantics (very rare).

Implementing URL Versioning in Laravel

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('/users/me', [V1\UserController::class, 'me']);
    Route::get('/orders', [V1\OrderController::class, 'index']);
});

Route::prefix('v2')->group(function () {
    Route::get('/users/me', [V2\UserController::class, 'me']);
    Route::get('/orders', [V2\OrderController::class, 'index']);
});

Each version has its own controllers. They share services and models underneath but differ in:

  • Request validation rules
  • Response shape (resource transformers)
  • Authentication requirements
  • Rate limit policies

Sharing the underlying business logic is essential. Reimplementing the entire stack per version is a maintenance disaster.

namespace App\Http\Controllers\Api\V2;

class UserController
{
    public function __construct(private UserService $service) {}

    public function me(Request $request)
    {
        $user = $this->service->getCurrentUser($request->user()->id);
        return new V2UserResource($user); // v2 shape
    }
}

The controller is mostly a translation layer. The service does the real work.

Resource Transformers

The cleanest way to differ response shapes per version is through resource classes.

namespace App\Http\Resources\V1;

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->full_name,
            'email' => $this->email,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

namespace App\Http\Resources\V2;

class UserResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => [
                'first' => $this->first_name,
                'last' => $this->last_name,
            ],
            'contact' => [
                'email' => $this->email,
                'phone' => $this->phone,
            ],
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

The model is the same. The shape returned to the client is per-version. Adding v3 means adding a new resource, not rewriting the model.

Deprecation Headers

A version is deprecated when it is still working but will not be supported forever. Communicate this with response headers.

HTTP/1.1 200 OK
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Deprecation: Mon, 12 May 2026 00:00:00 GMT
Link: </v2/users/me>; rel="successor-version"
  • Deprecation is the date the version was marked deprecated
  • Sunset is the date the version will be removed
  • Link with rel="successor-version" points to the replacement

Most clients ignore these headers, but mature integrations log them and surface warnings. Stripe and GitHub both use this pattern.

Telling Users (the Real Part)

Headers are necessary but not sufficient. To actually move customers off deprecated versions:

  • Email every API user with their current version, the sunset date, and a migration guide
  • Track which customers hit deprecated endpoints; reach out personally to large ones
  • Provide a migration tool, sample code, or both
  • Publish a clear changelog
  • Offer extended support for enterprise customers if needed

The technical mechanics of deprecation are easy. The communication and migration support are the hard part.

What Counts as a Breaking Change

Some changes require a new version; others can ship in the existing version:

Requires new version (breaking):

  • Removing a field from a response
  • Renaming a field
  • Changing a field's type
  • Removing an endpoint
  • Adding a required parameter
  • Tightening validation
  • Changing authentication requirements
  • Changing error response shapes

Does not require new version (additive):

  • Adding a new field to a response
  • Adding a new optional parameter
  • Adding a new endpoint
  • Loosening validation
  • Adding new optional headers

For "adding a field," clients should be tolerant — code that breaks when a new field appears is broken code. Documentation should explicitly state this expectation.

Long-Term Maintenance

The realistic version lifecycle:

  • v1 ships
  • v1 + v2 coexist when v2 ships, with v1 marked stable
  • v1 is deprecated when v3 ships (overlap with v2)
  • v1 is sunset 12-18 months after deprecation
  • v2 + v3 coexist
  • And so on

At any given time, you have one or two active versions plus a deprecation. Three or more active versions is a maintenance problem; the support burden compounds.

Stripe's approach is the gold standard for long-term versioning: every customer has a "default API version" set at signup, frozen until they explicitly upgrade. New customers get the current version. The provider can deprecate aggressively because each customer is on their own version.

For most teams, that level of versioning sophistication is overkill. URL versioning with two active versions and clear deprecation cycles handles most needs.

Versioning the Webhooks

API versioning often forgets webhooks. If you send OrderCreated events to integrators, the payload format is a contract too. Version the webhook payloads the same way you version the API.

{
  "api_version": "v2",
  "type": "order.created",
  "data": { ... v2 shape ... }
}

Or version the webhook URL (/webhooks/v2/order.created). Either way, give integrators a path to opt into newer event shapes without breaking on day one.

What to Avoid

  • Versioning every minor change. Three versions per year is too many. Save versions for genuinely breaking changes.
  • Open-ended deprecation. "We might remove this someday" is not deprecation. Pick a sunset date.
  • No way to test the new version. Customers cannot upgrade without trying it first. Make the new version testable in a sandbox.
  • Different versions in different parts of the API. v2 endpoints that call v1 endpoints internally is a smell. Version the boundary, not pieces.

The Real Goal

API versioning is about giving customers a predictable upgrade path. The mechanisms matter, but the discipline matters more: ship breaking changes deliberately, communicate them clearly, and give customers a long enough window to migrate.

Done well, versioning is invisible to customers — they upgrade on their schedule, with documentation that makes the path obvious. Done badly, every API change becomes a customer support fire.


Designing or refactoring an API that has accumulated breaking changes you have not properly versioned? We help teams stabilize the surface and ship a credible migration plan. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.