Back to Blog

API Versioning Strategy: URL, Header, and Content-Type Approaches with Deprecation

Design a robust API versioning strategy. Compare URL, header, and content-type versioning, implement deprecation policies, OpenAPI version management, and client migration guides.

Viprasol Tech Team
July 28, 2026
12 min read

API Versioning Strategy: URL, Header, and Content-Type Approaches with Deprecation

API versioning is a promise to your consumers: I will change this API, but I will give you time to adapt. Without a versioning strategy, every breaking change is an incident. With one, breaking changes are scheduled events with migration paths.

The choice of versioning mechanism (URL path, headers, content negotiation) matters less than the discipline of the process: what constitutes a breaking change, how long old versions are supported, how deprecation notices are communicated, and how clients migrate. This post covers all of it.


What Counts as a Breaking Change

Understanding breaking vs. non-breaking changes is the foundation of any versioning strategy:

ChangeBreaking?Notes
Remove a field from responseโœ… YesClients may depend on it
Rename a fieldโœ… YesOld field name no longer works
Change field type (string โ†’ number)โœ… YesParsing will fail
Change required โ†’ optional fieldโŒ NoBackward compatible
Add a new optional request fieldโŒ NoOld clients ignore it
Add a new response fieldโŒ NoOld clients ignore it
Change HTTP status codeโœ… YesError handling logic breaks
Add new enum valueโš ๏ธ MaybeIf client validates exhaustively, it breaks
Remove an endpointโœ… YesCalls will 404
Change validation rules (stricter)โœ… YesRequests that worked now fail
Change validation rules (looser)โŒ NoPreviously rejected requests now work
Add required auth to previously open endpointโœ… Yes401 where clients expect 200

Versioning Approach Comparison

ApproachExampleProsCons
URL path/v1/ordersObvious, easy to test in browser, cacheablePollutes URL space, duplicates routes
Query parameter/orders?version=1No route duplicationInconsistent (often forgotten), poor caching
Custom headerAPI-Version: 2026-01Clean URLs, no duplicationLess discoverable, harder to test in browser
Accept headerAccept: application/vnd.myapp.v2+jsonREST-pure, content negotiationComplex to implement and document
Date-basedStripe-Version: 2024-06-20Precise, tied to changelogClient must know specific dates

Recommendation for most teams: URL path versioning for public-facing APIs (simple, discoverable, works with every HTTP client), date-based header versioning for API products with many integrations (Stripe's approach โ€” every client pins to a specific version date).


๐ŸŒ 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

URL Path Versioning with Fastify

// src/api/v1/orders.ts
import type { FastifyPluginAsync } from 'fastify';

export const ordersV1: FastifyPluginAsync = async (app) => {
  app.get<{ Params: { id: string } }>('/orders/:id', async (req, reply) => {
    const order = await orderService.findById(req.params.id);

    // V1 response shape
    return reply.send({
      id: order.id,
      status: order.status,
      total: order.total,
      created_at: order.createdAt.toISOString(), // snake_case in V1
    });
  });
};

// src/api/v2/orders.ts
export const ordersV2: FastifyPluginAsync = async (app) => {
  app.get<{ Params: { id: string } }>('/orders/:id', async (req, reply) => {
    const order = await orderService.findById(req.params.id);

    // V2 response shape: camelCase, richer data
    return reply.send({
      id: order.id,
      status: order.status,
      subtotal: order.subtotal,
      tax: order.tax,
      total: order.total,
      currency: order.currency,
      createdAt: order.createdAt.toISOString(), // camelCase in V2
      customer: {
        id: order.customerId,
        email: order.customerEmail,
      },
    });
  });
};

// src/server.ts
app.register(ordersV1, { prefix: '/v1' });
app.register(ordersV2, { prefix: '/v2' });

Shared Service Layer (Never Duplicate Business Logic)

// src/services/orders.ts โ€” single source of truth
export class OrderService {
  async findById(id: string): Promise<OrderEntity> {
    return db.order.findUniqueOrThrow({
      where: { id },
      include: { customer: true, items: true },
    });
  }
  // ... all business logic here, version-agnostic
}

// Route handlers only handle shape transformation
// Service layer handles business logic
// This is critical: don't duplicate business logic per version

Date-Based Header Versioning (Stripe Pattern)

// src/middleware/version.ts
const SUPPORTED_VERSIONS = [
  '2026-01-01',
  '2026-04-15',
  '2026-07-01', // Current
] as const;

type ApiVersion = typeof SUPPORTED_VERSIONS[number];

const CURRENT_VERSION: ApiVersion = '2026-07-01';
const SUNSET_VERSIONS: Record<string, Date> = {
  '2026-01-01': new Date('2027-01-01'), // Sunset Jan 2027
  '2026-04-15': new Date('2027-07-01'),
};

export async function versionMiddleware(
  request: FastifyRequest,
  reply: FastifyReply,
): Promise<void> {
  const requestedVersion = request.headers['myapp-version'] as string | undefined;

  // Default to current version if not specified
  if (!requestedVersion) {
    request.apiVersion = CURRENT_VERSION;
    reply.header('MyApp-Version', CURRENT_VERSION);
    return;
  }

  // Validate version format and existence
  if (!SUPPORTED_VERSIONS.includes(requestedVersion as ApiVersion)) {
    reply.status(400).send({
      error: 'invalid_api_version',
      message: `API version '${requestedVersion}' is not supported.`,
      supported_versions: SUPPORTED_VERSIONS,
      current_version: CURRENT_VERSION,
    });
    return;
  }

  // Warn if version is deprecated (within 90 days of sunset)
  const sunsetDate = SUNSET_VERSIONS[requestedVersion];
  if (sunsetDate) {
    const daysToSunset = Math.ceil(
      (sunsetDate.getTime() - Date.now()) / 86400000,
    );

    reply.header('Deprecation', `true`);
    reply.header('Sunset', sunsetDate.toUTCString());
    reply.header('Link', `<https://docs.myapp.com/api/migration/${requestedVersion}>; rel="successor-version"`);

    if (daysToSunset < 30) {
      reply.header('Warning', `299 - "API version ${requestedVersion} sunsets in ${daysToSunset} days"`);
    }
  }

  request.apiVersion = requestedVersion as ApiVersion;
  reply.header('MyApp-Version', requestedVersion);
}

// Augment FastifyRequest type
declare module 'fastify' {
  interface FastifyRequest {
    apiVersion: ApiVersion;
  }
}

Version-Aware Route Handler

// src/routes/orders.ts
app.get('/orders/:id', async (req, reply) => {
  const order = await orderService.findById(req.params.id);

  // Transform response based on API version
  const response = req.apiVersion >= '2026-07-01'
    ? transformOrderV3(order)     // Latest: includes fulfillment data
    : req.apiVersion >= '2026-04-15'
    ? transformOrderV2(order)     // V2: camelCase, customer embed
    : transformOrderV1(order);    // V1: snake_case, minimal fields

  return reply.send(response);
});

function transformOrderV1(order: OrderEntity) {
  return {
    id: order.id,
    status: order.status,
    total: order.total,
    created_at: order.createdAt.toISOString(),
  };
}

function transformOrderV2(order: OrderEntity) {
  return {
    id: order.id,
    status: order.status,
    subtotal: order.subtotal,
    tax: order.tax,
    total: order.total,
    currency: order.currency,
    createdAt: order.createdAt.toISOString(),
    customer: { id: order.customerId, email: order.customerEmail },
  };
}

๐Ÿš€ 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

OpenAPI: Multi-Version Documentation

# openapi/v2.yaml
openapi: 3.1.0
info:
  title: MyApp API
  version: "2.0.0"
  description: |
    ## API Versioning
    
    This is **API v2**. See also:
    - [API v1 documentation](/docs/api/v1) โ€” supported until 2027-01-01
    
    ## Breaking Changes from v1
    - All field names changed from `snake_case` to `camelCase`
    - `created_at` โ†’ `createdAt`
    - Response now includes nested `customer` object
    - New required field `currency` on all money amounts
  
  x-api-version: "2026-07-01"
  
  contact:
    email: api-support@myapp.com
    url: https://docs.myapp.com

servers:
  - url: https://api.myapp.com/v2
    description: Production
  - url: https://api.staging.myapp.com/v2
    description: Staging

paths:
  /orders/{id}:
    get:
      summary: Get order by ID
      deprecated: false
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Order details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderV2"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  schemas:
    OrderV2:
      type: object
      required: [id, status, total, currency, createdAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [pending, confirmed, shipped, delivered, cancelled]
        subtotal:
          type: number
          format: float
        tax:
          type: number
          format: float
        total:
          type: number
          format: float
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
          example: "USD"
        createdAt:
          type: string
          format: date-time
        customer:
          $ref: "#/components/schemas/CustomerEmbed"

Deprecation Policy and Communication

Standard Deprecation Timeline

API Deprecation Policy

Timeline

  1. Announce โ€” 6 months before sunset:

    • Blog post with migration guide
    • Email to all API consumers
    • Deprecation and Sunset headers added to responses
  2. Warning period โ€” 90 days before sunset:

    • Warning: 299 header added
    • Increased monitoring for traffic on old version
    • Proactive outreach to high-traffic consumers
  3. Final warning โ€” 30 days before sunset:

    • Weekly emails to consumers still on old version
    • Dashboard notification for active API keys
  4. Sunset โ€” Old version returns 410 Gone:

    • Response: { "error": "version_sunset", "migrate_to": "v2", "docs": "..." }
    • Keep 410 for 30 days, then remove endpoint entirely

What's Never a Breaking Change

  • Adding new optional fields to responses
  • Adding new optional request fields
  • Adding new endpoints
  • Adding new enum values (document that clients should handle unknown values)
  • Improving error messages (same error code, better message)

### Programmatic Deprecation Notice (Response Header)

```typescript
// Sunset header middleware (RFC 8594)
function addDeprecationHeaders(
  reply: FastifyReply,
  version: string,
  sunsetDate: Date,
  successorUrl: string,
): void {
  reply.headers({
    'Deprecation':   'true',
    'Sunset':        sunsetDate.toUTCString(),                   // RFC 8594
    'Link':          `<${successorUrl}>; rel="successor-version"`,
    'Warning':       `299 api.myapp.com "Sunset: ${sunsetDate.toDateString()}"`,
  });
}

Version Sunset: 410 Gone Handler

// When a version is fully sunset
app.register(async (app) => {
  app.addHook('onRequest', async (req, reply) => {
    reply.status(410).send({
      error: 'version_sunset',
      message: 'API v1 was sunset on January 1, 2027. Please migrate to v2.',
      documentation: 'https://docs.myapp.com/api/migration/v1-to-v2',
      current_version: 'https://api.myapp.com/v2',
    });
  });
}, { prefix: '/v1' });

Client SDK Versioning

For teams that publish SDKs, use semantic versioning with explicit API version pinning:

// SDK initialization: pin API version
const client = new MyAppClient({
  apiKey: 'sk_live_...',
  apiVersion: '2026-07-01', // Explicitly pin; never silently upgrade
  // SDK major version bump when minimum supported API version changes
});

Working With Viprasol

We design and implement API versioning strategies for SaaS products โ€” from initial versioning architecture through deprecation policies and client migration tooling.

What we deliver:

  • Versioning strategy selection with rationale for your use case
  • Fastify/Express middleware for header-based versioning
  • OpenAPI spec management (separate specs per major version)
  • Deprecation notice implementation (Sunset/Deprecation headers, RFC 8594)
  • Migration guides and changelog automation

โ†’ Discuss your API architecture โ†’ Web development and API services


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.