API Versioning and Deployment Strategies: Rolling Out Breaking Changes Safely

Devops & Infrastructure, Security, and Tips & Tricks

API Versioning and Deployment Strategies: Rolling Out Breaking Changes Safely

Rolling out API changes without breaking existing clients is one of the most challenging aspects of backend development. Whether you're adding new features, deprecating old endpoints, or making breaking changes, proper versioning strategies ensure smooth deployments. In this guide, you'll learn how to implement API versioning and safely deploy breaking changes using DeployHQ.

Why API Versioning Matters

APIs are contracts with your consumers. Breaking changes can:

  • Crash mobile apps that haven't updated
  • Break integrations with third-party services
  • Frustrate developers who depend on your API
  • Damage trust with API consumers
flowchart TD
    subgraph "Without Versioning"
        A[API Change] --> B[All Clients Break]
        B --> C[Angry Users]
    end

    subgraph "With Versioning"
        D[API Change v2] --> E[v1 Still Works]
        E --> F[Clients Migrate Gradually]
        F --> G[Happy Users]
    end

Versioning Strategies

1. URL Path Versioning

The most common and visible approach:

GET /api/v1/users
GET /api/v2/users
// Express.js implementation
const express = require('express');
const app = express();

const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');

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

Pros: Clear, cache-friendly, easy to understand Cons: Duplicates route structure, longer URLs

2. Header Versioning

Version specified in request headers:

GET /api/users
Accept: application/vnd.myapi.v2+json
// Express.js middleware
function versionMiddleware(req, res, next) {
  const acceptHeader = req.headers['accept'] || '';
  const versionMatch = acceptHeader.match(/vnd\.myapi\.v(\d+)/);

  req.apiVersion = versionMatch ? parseInt(versionMatch[1]) : 1;
  next();
}

app.use(versionMiddleware);

app.get('/api/users', (req, res) => {
  if (req.apiVersion >= 2) {
    return getUsersV2(req, res);
  }
  return getUsersV1(req, res);
});

Pros: Clean URLs, follows HTTP standards Cons: Hidden versioning, harder to test

3. Query Parameter Versioning

Version in query string:

GET /api/users?version=2

Pros: Simple implementation Cons: Can be cached incorrectly, feels hacky

4. Custom Header Versioning

Most flexible approach:

GET /api/users
X-API-Version: 2
function versionMiddleware(req, res, next) {
  req.apiVersion = parseInt(req.headers['x-api-version']) || 1;
  next();
}

Implementing Backward Compatibility

Strategy 1: Additive Changes Only

Safe changes that never break clients:

// v1 response
{
  "id": 123,
  "name": "John Doe",
  "email": "john@example.com"
}

// v2 response (additive - safe)
{
  "id": 123,
  "name": "John Doe",
  "email": "john@example.com",
  "profile": {           // New field
    "avatar": "...",
    "bio": "..."
  }
}

Strategy 2: Response Transformation

Transform responses based on version:

// services/userTransformer.js
const transformers = {
  v1: (user) => ({
    id: user.id,
    name: user.fullName,
    email: user.email,
  }),

  v2: (user) => ({
    id: user.id,
    name: user.fullName,
    email: user.email,
    profile: {
      avatar: user.avatarUrl,
      bio: user.biography,
      joinedAt: user.createdAt,
    },
  }),
};

function transformUser(user, version) {
  const transformer = transformers[`v${version}`] || transformers.v1;
  return transformer(user);
}

Strategy 3: Dual-Write Pattern

Support both old and new fields during migration:

// Accept both formats during transition
app.post('/api/users', (req, res) => {
  const { name, fullName, firstName, lastName } = req.body;

  // Support legacy 'name' field and new 'firstName/lastName'
  const user = {
    fullName: fullName || name || `${firstName} ${lastName}`,
    firstName: firstName || name?.split(' ')[0],
    lastName: lastName || name?.split(' ').slice(1).join(' '),
  };

  // Save user...
});

Deployment Workflow

sequenceDiagram
    participant Dev as Development
    participant Stage as Staging
    participant Prod as Production
    participant Clients as API Clients

    Dev->>Stage: Deploy v2 alongside v1
    Stage->>Stage: Test both versions
    Stage->>Prod: Deploy v2 (v1 still active)

    Note over Prod,Clients: Transition Period
    Prod->>Clients: Announce v2 availability
    Clients->>Prod: Migrate to v2
    Prod->>Clients: Deprecation warning for v1

    Note over Prod: After migration deadline
    Prod->>Prod: Remove v1

DeployHQ Deployment Strategy

Side-by-Side Version Deployment

#!/bin/bash
# deploy.sh - Deploy new version alongside existing

VERSION=$1
DEPLOY_PATH="/var/www/api"

echo "Deploying API version $VERSION"

# Deploy new version code
rsync -avz --delete \
    --exclude 'node_modules' \
    --exclude '.env' \
    ./ "$DEPLOY_PATH/"

# Install dependencies
cd "$DEPLOY_PATH"
npm ci --production

# Run migrations (if needed)
npm run migrate

# Restart application
pm2 reload api

# Verify both versions work
echo "Testing v1..."
curl -sf http://localhost:3000/api/v1/health || exit 1

echo "Testing v2..."
curl -sf http://localhost:3000/api/v2/health || exit 1

echo "Deployment complete!"

Feature Flag for Gradual Rollout

// middleware/featureFlags.js
const flags = {
  useNewUserEndpoint: {
    enabled: process.env.NEW_USER_ENDPOINT === 'true',
    percentage: parseInt(process.env.NEW_USER_ENDPOINT_PERCENTAGE) || 0,
  },
};

function shouldUseFeature(flagName, userId) {
  const flag = flags[flagName];
  if (!flag || !flag.enabled) return false;

  // Percentage rollout based on user ID
  const hash = hashUserId(userId);
  return hash % 100 < flag.percentage;
}

// Usage in route
app.get('/api/users/:id', (req, res) => {
  if (shouldUseFeature('useNewUserEndpoint', req.params.id)) {
    return newGetUser(req, res);
  }
  return legacyGetUser(req, res);
});

For more on feature flags, see what are feature flags.

Deprecation Workflow

Deprecation Headers

// middleware/deprecation.js
const deprecatedEndpoints = {
  'GET /api/v1/users': {
    deprecatedAt: '2024-01-01',
    sunsetAt: '2024-06-01',
    replacement: 'GET /api/v2/users',
  },
};

function deprecationMiddleware(req, res, next) {
  const key = `${req.method} ${req.path}`;
  const deprecation = deprecatedEndpoints[key];

  if (deprecation) {
    res.set('Deprecation', `date="${deprecation.deprecatedAt}"`);
    res.set('Sunset', deprecation.sunsetAt);
    res.set('Link', `<${deprecation.replacement}>; rel="successor-version"`);

    // Log deprecation usage for monitoring
    console.warn(`Deprecated endpoint accessed: ${key}`, {
      clientIp: req.ip,
      userAgent: req.headers['user-agent'],
    });
  }

  next();
}

Consumer Communication

// Track API version usage
const versionUsage = {};

app.use((req, res, next) => {
  const version = req.apiVersion || 1;
  const client = req.headers['x-client-id'] || 'unknown';

  versionUsage[client] = versionUsage[client] || {};
  versionUsage[client][version] = (versionUsage[client][version] || 0) + 1;

  next();
});

// Endpoint to check usage
app.get('/internal/api-usage', (req, res) => {
  res.json(versionUsage);
});

Contract Testing

Ensure backward compatibility with contract tests:

// tests/contracts/v1.test.js
const { expect } = require('chai');
const request = require('supertest');
const app = require('../../app');

describe('API v1 Contract', () => {
  describe('GET /api/v1/users/:id', () => {
    it('returns expected v1 schema', async () => {
      const response = await request(app)
        .get('/api/v1/users/1')
        .expect(200);

      // v1 contract: must have these fields
      expect(response.body).to.have.property('id');
      expect(response.body).to.have.property('name');
      expect(response.body).to.have.property('email');

      // v1 contract: must NOT have v2 fields
      expect(response.body).to.not.have.property('profile');
    });
  });
});

describe('API v2 Contract', () => {
  describe('GET /api/v2/users/:id', () => {
    it('returns expected v2 schema', async () => {
      const response = await request(app)
        .get('/api/v2/users/1')
        .expect(200);

      // v2 contract: must have all fields
      expect(response.body).to.have.property('id');
      expect(response.body).to.have.property('name');
      expect(response.body).to.have.property('email');
      expect(response.body).to.have.property('profile');
      expect(response.body.profile).to.have.property('avatar');
    });
  });
});

DeployHQ Build Commands with Contract Tests

# Install dependencies
npm ci

# Run unit tests
npm test

# Run contract tests (critical for versioning)
npm run test:contracts

# Build
npm run build

OpenAPI Documentation

Keep documentation in sync with versions:

# openapi/v1.yaml
openapi: 3.0.0
info:
  title: My API
  version: 1.0.0

paths:
  /users/{id}:
    get:
      summary: Get user by ID (v1)
      deprecated: true
      responses:
        200:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserV1'

# openapi/v2.yaml
openapi: 3.0.0
info:
  title: My API
  version: 2.0.0

paths:
  /users/{id}:
    get:
      summary: Get user by ID (v2)
      responses:
        200:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserV2'

Version Sunset Checklist

flowchart TD
    A[Announce Deprecation] --> B[Add Deprecation Headers]
    B --> C[Monitor v1 Usage]
    C --> D{Usage Below Threshold?}
    D -->|No| E[Reach Out to Heavy Users]
    E --> C
    D -->|Yes| F[Send Final Warning]
    F --> G[Remove v1]
    G --> H[Update Documentation]

Automated Monitoring

// Monitor version usage and alert
async function checkVersionUsage() {
  const stats = await getVersionStats();

  // Alert if v1 usage is still high before sunset
  if (stats.v1.percentage > 5 && isWithinSunsetWindow()) {
    await sendAlert({
      type: 'version-sunset-warning',
      message: `v1 still has ${stats.v1.percentage}% traffic`,
      topClients: stats.v1.topClients,
    });
  }
}

// Run daily
setInterval(checkVersionUsage, 24 * 60 * 60 * 1000);

Best Practices Summary

  1. Choose a versioning strategy and stick with it
  2. Make additive changes whenever possible
  3. Never remove fields without versioning
  4. Add deprecation headers before removing endpoints
  5. Run contract tests on every deployment
  6. Monitor version usage to plan migrations
  7. Communicate changes clearly to consumers
  8. Provide migration guides for breaking changes
  9. Set clear sunset dates and enforce them
  10. Keep documentation updated for all versions

Getting Started

Ready to implement API versioning? Here's your checklist:

  1. Choose your versioning strategy (URL path recommended)
  2. Set up version routing
  3. Implement response transformers
  4. Add contract tests to your pipeline
  5. Set up deprecation monitoring

For zero-downtime deployment strategies, see our guide on zero-downtime deployments.


Questions about API versioning? Contact support@deployhq.com or follow @deployhq.

A little bit about the author

Alex is a content specialist on the DeployHQ team, focused on helping developers streamline their deployment workflows. With a background in DevOps and technical writing, Alex enjoys breaking down complex topics into actionable guides for the DeployHQ and DeployBot communities.