Back to Blog

API Versioning Strategies: URL, Header, and Content Negotiation Compared

Compare API versioning approaches — URL versioning, header versioning, content negotiation, and GraphQL evolution. Includes implementation examples, deprecation

Viprasol Tech Team
April 4, 2026
11 min read

API Versioning Strategies: URL, Header, and Content Negotiation Compared

API versioning is about making breaking changes without breaking existing clients. Every strategy is a tradeoff between discoverability, client simplicity, and operational overhead. The "best" strategy is the one your clients can actually use.

This guide covers the four main approaches with real implementation examples and the failure modes that make each problematic in practice.


What Counts as a Breaking Change

Understanding what needs versioning prevents over-versioning (creating new versions for every minor change) and under-versioning (silently breaking clients).

Breaking changes — require versioning:

  • Removing a field from a response
  • Renaming a field
  • Changing a field's type (string → number)
  • Changing a field's semantics (price in dollars → price in cents)
  • Removing an endpoint
  • Making an optional field required
  • Changing authentication scheme

Non-breaking changes — no versioning needed:

  • Adding a new optional field to a response
  • Adding a new endpoint
  • Adding a new optional query parameter
  • Adding new values to an enum (unless clients do exhaustive matching)
  • Performance improvements
  • Bug fixes that don't change the contract

The practical rule: if an existing client that doesn't know about the change continues to work correctly, it's non-breaking.


Strategy 1: URL Path Versioning

The version appears in the URL path: /v1/users, /v2/users.

// Fastify route registration
import { FastifyInstance } from 'fastify';

export async function registerV1Routes(app: FastifyInstance) {
  app.register(async (v1) => {
    v1.get('/users/:id', async (request, reply) => {
      const user = await db.user.findUnique({
        where: { id: request.params.id },
      });
      // V1 response: returns full name as single string
      return {
        id: user.id,
        name: `${user.firstName} ${user.lastName}`,
        email: user.email,
      };
    });
  }, { prefix: '/v1' });
}

export async function registerV2Routes(app: FastifyInstance) {
  app.register(async (v2) => {
    v2.get('/users/:id', async (request, reply) => {
      const user = await db.user.findUnique({
        where: { id: request.params.id },
      });
      // V2 response: splits name into firstName/lastName
      return {
        id: user.id,
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
        createdAt: user.createdAt,  // New field added in v2
      };
    });
  }, { prefix: '/v2' });
}

// app.ts
app.register(registerV1Routes);
app.register(registerV2Routes);

Practical implementation with shared handlers:

// Rather than duplicating logic, compose from shared base
async function getUser(id: string) {
  return db.user.findUniqueOrThrow({ where: { id } });
}

// V1 serializer
function serializeUserV1(user: User) {
  return {
    id: user.id,
    name: `${user.firstName} ${user.lastName}`,
    email: user.email,
  };
}

// V2 serializer — extends V1 with new structure
function serializeUserV2(user: User) {
  return {
    id: user.id,
    firstName: user.firstName,
    lastName: user.lastName,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  };
}

// Route handlers just compose
app.get('/v1/users/:id', async (req) => serializeUserV1(await getUser(req.params.id)));
app.get('/v2/users/:id', async (req) => serializeUserV2(await getUser(req.params.id)));

Advantages:

  • Obvious and self-documenting — the URL tells you which version you're using
  • Easy to test in a browser or curl
  • Easy to route with nginx/CDN (route /v1/* to old cluster, /v2/* to new)
  • Simple caching — different URLs, different cache entries

Disadvantages:

  • URLs should identify resources, not protocol versions (REST purists dislike this)
  • Clients must update URLs to migrate, not just headers
  • Easy for clients to "forget" to upgrade (still calling /v1 years later)

Best for: Public APIs, third-party integrations, APIs with external developer communities.


🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript — production-grade stack
  • Fixed-price contracts — no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Strategy 2: Header Versioning

The version travels in a custom request header: API-Version: 2026-03-01 or X-API-Version: 2.

// Middleware to extract and validate version
export async function versionMiddleware(
  request: FastifyRequest,
  reply: FastifyReply
) {
  const version = request.headers['api-version'] as string;
  const SUPPORTED_VERSIONS = ['2024-01-01', '2025-01-01', '2026-01-01'];
  const LATEST_VERSION = '2026-01-01';
  const DEPRECATED_VERSIONS = ['2024-01-01'];

  const resolvedVersion = version ?? LATEST_VERSION;

  if (version && !SUPPORTED_VERSIONS.includes(version)) {
    return reply.code(400).send({
      error: `Unsupported API version: ${version}. Supported: ${SUPPORTED_VERSIONS.join(', ')}`,
    });
  }

  if (DEPRECATED_VERSIONS.includes(resolvedVersion)) {
    reply.header('Deprecation', 'true');
    reply.header('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
    reply.header('Link', '</docs/migration/v2026>; rel="deprecation"');
  }

  // Attach resolved version to request for handlers to use
  (request as any).apiVersion = resolvedVersion;
  reply.header('API-Version', resolvedVersion);
}

// Handler that branches on version
app.get('/users/:id', { preHandler: [versionMiddleware] }, async (request, reply) => {
  const user = await getUser(request.params.id);
  const version = (request as any).apiVersion;

  if (version >= '2026-01-01') {
    return serializeUserV2(user);
  }
  return serializeUserV1(user);
});

Stripe's date-based version approach (the gold standard for header versioning):

// Stripe-style: version is an API date, not a number
// Clients pin to a specific date; breaking changes only affect clients that upgrade

const VERSIONS = {
  '2026-01-01': { // Latest
    userSerializer: serializeUserV2,
    paginationStyle: 'cursor',
  },
  '2025-06-01': {
    userSerializer: serializeUserV1,
    paginationStyle: 'offset',
  },
  '2024-01-01': {  // Deprecated, sunset 2026-12-31
    userSerializer: serializeUserLegacy,
    paginationStyle: 'offset',
  },
} as const;

type ApiVersion = keyof typeof VERSIONS;

Advantages:

  • Clean URLs — the resource URL doesn't change
  • Clients can pin to a version and only upgrade deliberately
  • Industry standard for API-first companies (Stripe, Twilio, Plaid all use this)

Disadvantages:

  • Not visible in the URL — harder to debug in browser or curl
  • CDN caching requires Vary: API-Version header (some CDNs handle this poorly)
  • Less obvious to junior developers who forget to send the header

Best for: API-first products with sophisticated API consumers. Stripe's implementation is the canonical reference.


Strategy 3: Content Negotiation (Accept Header)

Use the HTTP Accept header with a vendor media type: Accept: application/vnd.myapi.v2+json.

app.get('/users/:id', async (request, reply) => {
  const accept = request.headers.accept ?? '';
  const user = await getUser(request.params.id);

  if (accept.includes('application/vnd.myapi.v2+json')) {
    reply.header('Content-Type', 'application/vnd.myapi.v2+json');
    return serializeUserV2(user);
  }

  // Default to v1
  reply.header('Content-Type', 'application/vnd.myapi.v1+json');
  return serializeUserV1(user);
});

Advantages: Theoretically correct per HTTP spec. URLs are stable.

Disadvantages: Almost nobody does this correctly. Developer tooling (Postman, curl) requires explicit configuration. CDNs must vary cache on Accept header. Very few APIs outside academia use this.

Verdict: Avoid unless you have a strong reason. Header versioning gives the same URL stability with far less friction.


🚀 Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Strategy 4: GraphQL Schema Evolution (No Versioning)

GraphQL takes a different approach: instead of versioning, evolve the schema non-destructively. Fields are deprecated rather than removed.

type User {
  id: ID!
  
  # Deprecated — use firstName and lastName instead
  name: String @deprecated(reason: "Use `firstName` and `lastName` instead")
  
  firstName: String!
  lastName: String!
  email: String!
  
  # New in March 2026
  createdAt: DateTime!
}
// Resolver supports both old and new fields
const resolvers = {
  User: {
    // Deprecated field still works — existing clients unaffected
    name: (user: User) => `${user.firstName} ${user.lastName}`,
    
    // New fields
    firstName: (user: User) => user.firstName,
    lastName: (user: User) => user.lastName,
  },
};

Clients see the deprecation warning in their IDE/GraphQL tooling and can migrate at their own pace. You remove the field only after tracking that no clients are requesting it (GraphQL field usage metrics).

Works well for: Internal APIs with one team controlling both client and server. Less practical for public APIs where you can't monitor all client usage.


Deprecation Workflow

Regardless of strategy, a proper deprecation process matters:

// 1. Announce in changelog and API docs
// 2. Add deprecation headers
reply.header('Deprecation', 'true');
reply.header('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT');
reply.header('Link', '</docs/migration/v2>; rel="deprecation"');

// 3. Emit deprecation metric — know who's still calling old version
metrics.increment('api.deprecated_version_call', {
  version: resolvedVersion,
  endpoint: request.url,
  client: request.headers['user-agent'],
});

// 4. Email clients still on deprecated version (requires API key → email mapping)

// 5. Return 410 Gone after sunset date
if (resolvedVersion === '2024-01-01' && new Date() > SUNSET_DATE) {
  return reply.code(410).send({
    error: 'API version 2024-01-01 is no longer supported. Migrate to 2026-01-01.',
    migrationGuide: 'https://docs.example.com/migration/v2026',
  });
}

Minimum deprecation period: 6 months for external APIs. 3 months for internal. The longer you give, the fewer support tickets you'll get.


Strategy Comparison

FactorURL PathHeaderContent NegotiationGraphQL
Discoverability⭐⭐⭐⭐⭐⭐⭐⭐N/A
CDN cacheability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Clean URLs⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Developer UX⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Industry adoptionHighHigh (API-first)LowN/A
Routing simplicity⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐N/A

Our recommendation by context:

  • Public REST API, external developers → URL versioning (easiest to debug)
  • API-first SaaS product → Header versioning, Stripe-style dates
  • GraphQL API → Schema evolution with deprecation (no versioning needed)
  • Internal services → URL versioning or none (just coordinate the migration)

Working With Viprasol

We design API versioning strategies as part of API architecture engagements — covering the initial versioning approach, deprecation workflows, client migration tooling, and the internal processes that ensure new versions ship cleanly. We've helped teams migrate from v1 to v2 without breaking existing integrations.

Talk to our API team about your versioning strategy.


See Also

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms — we build it all with Next.js and React.

Free consultation • No commitment • Response within 24 hours

Viprasol · Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.