Cybersecurity

API Versioning Strategies: URL, Header, and Query Parameter Approaches

Choose the right API versioning strategy for your use case. Covers URL path, header, and query parameter versioning with deprecation and migration best practices.

By Inventive HQ Team

There are three practical ways to version a REST API — put the version in the URL path (/api/v1/users), in a request header (Api-Version: 2 or Accept: application/vnd.myapi.v2+json), or in a query parameter (/api/users?version=2) — and for the overwhelming majority of public APIs, URL path versioning is the right default because it is visible in logs, cacheable at the CDN, testable in a browser, and trivial to route at the gateway. Header versioning is "purer" REST and keeps URLs clean, but it costs you caching complexity (a Vary header) and testability; query-parameter versioning is the simplest to bolt on but the easiest to break, because parameters get dropped by caches and collide with real filters. The strategy you pick is far less important than picking one and applying it consistently across every endpoint.

That is the summary an AI Overview will give you. What it can't show you is the shape of the trade-off — where a request actually gets routed, which change types force a new major version, and what a version's death looks like on a calendar. The rest of this guide is those concrete assets: a decision table with a "which should I use" row, an animated diagram of how each strategy routes the same request, working Express/Kong/AWS code, and an RFC-accurate deprecation timeline.

Versioning Strategy Comparison

FactorURL path /v1/usersHeader Api-Version: 2Query ?version=2
Ease of implementationEasiestModerateEasy
Visible in access logsYesNo (headers rarely logged)Yes
Browser-testable (paste URL)YesNoYes
CDN / proxy cacheableCleanlyNeeds Vary headerOften dropped by caches
REST "purity"Low (version isn't a resource)High (content negotiation)Low
Collision riskNoneNoneClashes with real filters
Discoverability for new devsHighLowMedium
Which should I use / whenDefault for public + internal APIs; anything CDN-cachedStrict REST shops, hypermedia APIs, when clean URLs are a hard requirementQuick internal APIs and prototypes only — avoid for anything public

How each strategy routes the same request

Three ways the same API request reaches v1 or v2 A client request is inspected by the router. URL path versioning reads the path, header versioning reads the Api-Version header, and query versioning reads the version parameter — each dispatching to the v1 or v2 handler. Client GET /users Router inspects request URL path reads /api/ v2 /users cacheable, log-visible Header reads Api-Version: 2 needs Vary; harder to test Query param reads ?version=2 dropped by caches, collides

Breaking vs Non-Breaking Changes

Change TypeBreaking?Action
Remove field from response✅ YesNew version
Remove endpoint✅ YesNew version
Rename field✅ YesNew version
Change field type (string→number)✅ YesNew version
Change authentication method✅ YesNew version
Change error format✅ YesNew version
Add new optional field❌ NoCurrent version
Add new endpoint❌ NoCurrent version
Add new optional parameter❌ NoCurrent version
Bug fix (same behavior)❌ NoCurrent version
Performance improvement❌ NoCurrent version

URL Path Versioning

Express.js Implementation

import express from 'express';

const app = express();

// Version 1 routes
const v1Router = express.Router();
v1Router.get('/users', (req, res) => {
  res.json({
    users: [
      { id: 1, name: 'John', email: 'john@example.com' }
    ]
  });
});

// Version 2 routes (different response structure)
const v2Router = express.Router();
v2Router.get('/users', (req, res) => {
  res.json({
    data: [
      {
        id: 1,
        attributes: {
          name: 'John',
          email: 'john@example.com'
        }
      }
    ],
    meta: { total: 1 }
  });
});

// Mount versioned routers
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// Redirect unversioned to latest stable
app.use('/api/users', (req, res) => {
  res.redirect(308, `/api/v2${req.path}`);
});

Shared Code Between Versions

// services/users.service.ts - Shared business logic
export class UsersService {
  async getUsers(filters: UserFilters): Promise<User[]> {
    return await prisma.user.findMany({ where: filters });
  }
}

// v1/users.controller.ts
import { UsersService } from '../services/users.service';

const usersService = new UsersService();

export async function getUsers(req: Request, res: Response) {
  const users = await usersService.getUsers(req.query);

  // V1 response format
  res.json({ users });
}

// v2/users.controller.ts
import { UsersService } from '../services/users.service';

const usersService = new UsersService();

export async function getUsers(req: Request, res: Response) {
  const users = await usersService.getUsers(req.query);

  // V2 response format (JSON:API style)
  res.json({
    data: users.map(u => ({
      id: u.id,
      type: 'user',
      attributes: { name: u.name, email: u.email }
    })),
    meta: { total: users.length }
  });
}

Header Versioning

Custom Header Implementation

// Middleware to extract version from header
function versionMiddleware(req: Request, res: Response, next: NextFunction) {
  const version = req.headers['api-version'] || req.headers['x-api-version'];

  if (!version) {
    // Default to latest stable
    req.apiVersion = 2;
  } else {
    const parsed = parseInt(version as string, 10);
    if (isNaN(parsed) || parsed < 1 || parsed > 2) {
      return res.status(400).json({
        error: 'Invalid API version',
        supported: [1, 2],
      });
    }
    req.apiVersion = parsed;
  }

  // Add version to response
  res.setHeader('Api-Version', req.apiVersion);
  next();
}

// Route handler using version
app.get('/api/users', versionMiddleware, async (req, res) => {
  const users = await usersService.getUsers(req.query);

  if (req.apiVersion === 1) {
    return res.json({ users });
  }

  // Version 2
  return res.json({
    data: users.map(formatUserV2),
    meta: { total: users.length }
  });
});

Accept Header (Content Negotiation)

// Using media type versioning
// Accept: application/vnd.myapi.v2+json

function parseAcceptVersion(accept: string | undefined): number {
  if (!accept) return 2; // Default

  const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
  return match ? parseInt(match[1], 10) : 2;
}

app.get('/api/users', (req, res) => {
  const version = parseAcceptVersion(req.headers.accept);

  // Set appropriate content type
  res.setHeader(
    'Content-Type',
    `application/vnd.myapi.v${version}+json`
  );

  // Version-specific response
  // ...
});
Advertisement

API Gateway Routing

Kong Configuration

# kong.yml
services:
  - name: api-v1
    url: http://api-v1-service:8080
    routes:
      - name: api-v1-route
        paths:
          - /api/v1

  - name: api-v2
    url: http://api-v2-service:8080
    routes:
      - name: api-v2-route
        paths:
          - /api/v2

  # Header-based routing
  - name: api-v1-header
    url: http://api-v1-service:8080
    routes:
      - name: api-v1-header-route
        paths:
          - /api
        headers:
          api-version:
            - "1"

  - name: api-v2-header
    url: http://api-v2-service:8080
    routes:
      - name: api-v2-header-route
        paths:
          - /api
        headers:
          api-version:
            - "2"

AWS API Gateway

# SAM template
Resources:
  ApiV1:
    Type: AWS::Serverless::Api
    Properties:
      StageName: v1
      DefinitionUri: ./openapi-v1.yaml

  ApiV2:
    Type: AWS::Serverless::Api
    Properties:
      StageName: v2
      DefinitionUri: ./openapi-v2.yaml

  # Custom domain with base path mappings
  ApiDomainMapping:
    Type: AWS::ApiGatewayV2::ApiMapping
    Properties:
      DomainName: api.example.com
      ApiId: !Ref ApiV2
      Stage: v2
      ApiMappingKey: v2

  ApiDomainMappingV1:
    Type: AWS::ApiGatewayV2::ApiMapping
    Properties:
      DomainName: api.example.com
      ApiId: !Ref ApiV1
      Stage: v1
      ApiMappingKey: v1

Deprecation Strategy

Deprecation Headers

// Middleware to add deprecation headers
function deprecationMiddleware(
  deprecationDate: string,
  sunsetDate: string,
  link: string
) {
  return (req: Request, res: Response, next: NextFunction) => {
    // RFC 9745 Deprecation header (published April 2025)
    res.setHeader('Deprecation', deprecationDate);

    // RFC 8594 Sunset header
    res.setHeader('Sunset', sunsetDate);

    // Link to migration guide
    res.setHeader('Link', `<${link}>; rel="deprecation"`);

    // Optional warning header
    res.setHeader(
      'Warning',
      '299 - "This API version is deprecated. Please migrate to v2."'
    );

    next();
  };
}

// Apply to v1 routes
app.use(
  '/api/v1',
  deprecationMiddleware(
    'Sun, 01 Jan 2025 00:00:00 GMT',  // Deprecated since
    'Mon, 01 Jul 2025 00:00:00 GMT',  // Will be removed
    'https://docs.example.com/migration-guide'
  ),
  v1Router
);

Version Lifecycle

A deprecated version does not vanish the moment v2 ships — it moves through a fixed sequence of states on a published calendar. The diagram below shows the 18-month path most teams use, though enterprise APIs frequently stretch the sunset out to 24-36 months.

API version lifecycle from current to sunset A timeline showing v1 moving from Deprecated at T+0, to monitored at T+6 months, to a sunset warning at T+12 months, to removed and returning 410 Gone at T+18 months, while v2 stays current throughout. T+0 DEPRECATED headers + migration guide v1 frozen T+6mo MONITOR contact heavy users T+12mo SUNSET WARN final notices T+18mo SUNSET removed — 410 Gone v2 CURRENT — active development and full support the entire time

Sunset Response

// After sunset date, return 410 Gone
app.use('/api/v1', (req, res) => {
  const sunsetDate = new Date('2025-07-01');

  if (new Date() > sunsetDate) {
    return res.status(410).json({
      error: 'VERSION_SUNSET',
      message: 'API v1 has been sunset. Please migrate to v2.',
      migrationGuide: 'https://docs.example.com/migration-v1-v2',
      currentVersion: 'https://api.example.com/v2',
    });
  }

  // Still active, continue with deprecation warnings
  next();
});

Version Migration Guide Template

# Migrating from API v1 to v2

## Overview
API v2 introduces a new response format following JSON:API specification,
improved error handling, and new endpoints for batch operations.

## Timeline
- **Deprecation Date**: January 1, 2025
- **Sunset Date**: July 1, 2025
- **Support Email**: api-support@example.com

## Breaking Changes

### Response Format
**v1:**
```json
{
  "users": [{"id": 1, "name": "John"}]
}

v2:

{
  "data": [{"id": 1, "type": "user", "attributes": {"name": "John"}}],
  "meta": {"total": 1}
}

Authentication

  • v1: API key in query parameter (?api_key=xxx)
  • v2: API key in header (Authorization: Bearer xxx)

Removed Endpoints

  • GET /users/search → Use GET /users?filter[name]=xxx
  • POST /users/bulk → Use POST /users with array body

Migration Steps

  1. Update authentication to use header
  2. Update response parsing for new format
  3. Update any deprecated endpoint calls
  4. Test thoroughly in staging
  5. Deploy and monitor errors

Code Examples

[SDK examples for major languages]

Support

Contact api-support@example.com for migration assistance.


## Security Considerations

```typescript
// Ensure auth/authz applies to ALL versions
function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization;

  if (!token) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Validate token (same logic for all versions)
  try {
    req.user = verifyToken(token);
    next();
  } catch (e) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Apply BEFORE version routing
app.use('/api', authMiddleware);
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// Rate limiting across versions
const rateLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  keyGenerator: (req) => {
    // Same key regardless of version
    return req.user?.id || req.ip;
  },
});

app.use('/api', rateLimiter);

Monitoring Versions

// Track version usage for deprecation decisions
app.use('/api/:version', (req, res, next) => {
  const version = req.params.version;
  const clientId = req.user?.id || 'anonymous';

  // Log to analytics
  analytics.track({
    event: 'api_request',
    properties: {
      version,
      clientId,
      endpoint: req.path,
      method: req.method,
    },
  });

  // Prometheus metric
  apiRequestsTotal.inc({
    version,
    endpoint: req.path,
    method: req.method,
  });

  next();
});

Best Practices

  1. Choose one strategy - Consistency across your API
  2. Version early - Start with /v1 even if no v2 planned
  3. Global versioning - All endpoints at same version
  4. Semantic meaning - Major version for breaking changes
  5. Long deprecation periods - 12-24 months minimum
  6. Clear documentation - Migration guides for each version
  7. Monitor usage - Data-driven sunset decisions
  8. Security parity - Same security controls across versions
  9. Deprecation headers - Machine-readable warnings
  10. No silent changes - Even non-breaking changes need docs

Next Steps

Frequently Asked Questions

Why do I need API versioning?

API versioning allows you to make breaking changes without disrupting existing clients. Without versioning, changes to request/response formats, removed fields, or changed behavior would break all clients simultaneously. Versioning lets you evolve your API while maintaining backwards compatibility for clients that haven't updated, then deprecate old versions on a schedule.

What is the best API versioning strategy?

URL path versioning (/v1/users) is the most common and easiest to implement, cache, and debug. Header versioning (Accept: application/vnd.api+json;version=1) is more RESTful but harder to test. Query parameter versioning (?version=1) is simple but can conflict with other params. Choose URL path for most cases, header for strict REST compliance, query param for simple internal APIs.

What is a breaking change vs non-breaking change?

Breaking changes require a new version: removing fields/endpoints, renaming fields, changing field types, changing authentication, altering error formats, or changing endpoint URLs. Non-breaking changes can go into the current version: adding new optional fields, adding new endpoints, adding new optional parameters, or bug fixes that don't change behavior. Always add, never remove or modify.

How long should I support old API versions?

Industry standard is 12-24 months deprecation notice before sunsetting a version. Announce deprecation with timeline, add Deprecation and Sunset headers to responses, notify developers via email/dashboard, monitor usage to identify stragglers, and provide migration guides. Enterprise APIs may need longer support (2-3 years). Consider usage-based decisions—sunset when traffic drops below threshold.

Should I version my entire API or individual endpoints?

Version the entire API (global versioning) for consistency—all endpoints at /v1/ or /v2/. Per-endpoint versioning (/users/v2) creates confusion and maintenance burden. If only some endpoints need breaking changes, keep them in the new version and have unchanged endpoints respond identically in both versions. Clients should know "I'm on v2" not "I'm on users v2 but orders v1".

What is semantic versioning for APIs?

Semantic versioning (SemVer) uses MAJOR.MINOR.PATCH format. For APIs: MAJOR for breaking changes requiring client updates, MINOR for backwards-compatible new features, PATCH for backwards-compatible bug fixes. Most public APIs only version by MAJOR (v1, v2) since clients primarily care about breaking changes. Use full SemVer internally or in SDK versioning.

How do I implement URL path versioning?

Include version in the URL path: /api/v1/users. In Express: app.use('/api/v1', v1Router); app.use('/api/v2', v2Router). In API gateway, route /v1/* to one backend and /v2/* to another. Each version can be a separate deployment or code branch. Use middleware to extract version and set context for shared code paths.

How do I implement header versioning?

Use custom headers (Api-Version: 2) or Accept header with vendor MIME type (Accept: application/vnd.myapi.v2+json). Extract in middleware, default to latest stable if missing. Headers are cleaner URLs but harder to test (can't paste URL in browser), harder to cache (Vary header needed), and less visible in logs. Consider hybrid—support both header and URL.

How should I handle unversioned requests?

Options: default to latest stable version (can break clients on updates), default to oldest supported version (conservative), or require version (return 400/404). Best practice: default to latest stable for new APIs, keep defaulting to v1 forever for established APIs with many clients. Document the default behavior clearly. Never silently change the default version.

What security considerations apply to API versioning?

Older versions may have unfixed security vulnerabilities—establish security support windows and force migration for critical fixes. Don't let version negotiation bypass authentication/authorization checks. Ensure rate limits apply across versions (not per-version). Deprecate versions with known vulnerabilities aggressively. Log which versions clients use for security audit.

API designAPI versioningREST APIbackwards compatibilitydeprecation
Advertisement