System Design 9 min read

API Design Part 4: Versioning & Idempotency

Master API versioning strategies and idempotency patterns. Learn URL vs header versioning, version lifecycle management, and Stripe-style idempotency keys for reliable APIs.

MR

Moshiour Rahman

Advertisement

API Design Mastery Series

This is Part 4 of our comprehensive API Design series.

PartTopicLevel
1HTTP & REST FundamentalsBeginner
2Security & AuthenticationBeginner
3Rate Limiting & PaginationIntermediate
4Versioning & IdempotencyIntermediate
5Caching StrategiesIntermediate
6GraphQL & gRPCIntermediate
7Resilience & ObservabilityAdvanced
8Production MasteryAdvanced

API Versioning Strategies

The Complete Versioning Matrix

StrategyExampleDiscoverabilityCachingClient Simplicity
URL/v1/usersExcellentExcellentExcellent
HeadersX-API-Version: 1PoorRequires VaryModerate
Content-TypeAccept: application/vnd.api+json;version=1PoorRequires VaryPoor
Query Param/users?version=1ModerateModerateGood

Multi-Version API Architecture

// version-router.ts - Clean version routing

import { Hono } from 'hono';

// Version-specific handlers
import * as v1Users from './v1/users';
import * as v2Users from './v2/users';

const app = new Hono();

// URL-based versioning (recommended for public APIs)
const v1 = new Hono();
const v2 = new Hono();

// V1 routes
v1.get('/users', v1Users.list);
v1.get('/users/:id', v1Users.get);
v1.post('/users', v1Users.create);

// V2 routes (with breaking changes)
v2.get('/users', v2Users.list);      // Different response shape
v2.get('/users/:id', v2Users.get);
v2.post('/users', v2Users.create);
v2.get('/users/:id/profile', v2Users.getProfile); // New endpoint

app.route('/v1', v1);
app.route('/v2', v2);

// Header-based versioning (for internal APIs)
app.use('/api/*', async (c, next) => {
  const version = c.req.header('X-API-Version') || '2';
  c.set('apiVersion', version);
  await next();
});

// Deprecation middleware
app.use('/v1/*', async (c, next) => {
  c.header('Deprecation', 'true');
  c.header('Sunset', 'Sat, 31 Dec 2025 23:59:59 GMT');
  c.header('Link', '</v2>; rel="successor-version"');
  await next();
});

export default app;

Version Migration Lifecycle

PhaseStatusWhat HappensDuration
DevelopmentInternalNot publicly accessibleAs needed
ActiveDefaultv2 default for new integrations, v1 fully supportedIndefinite
DeprecatedWarningDeprecation headers added, migration guides published6-12 months
SunsetEndingReturns 410 Gone, optional proxy to v21-3 months
RemovedArchivedEndpoints deleted, docs archivedPermanent

Idempotency for Reliable APIs

Idempotency Key Request Flow

Why Idempotency Matters

Network failures happen. Clients retry. Without idempotency, a payment API retry could charge a customer twice. Idempotency ensures that making the same request multiple times has the same effect as making it once.

The Problem:

  1. Client sends POST /payments
  2. Server processes request successfully
  3. Response times out (network issue)
  4. Client retries with same request
  5. Without idempotency: Double charge!
  6. With idempotency: Server returns cached response - safe!

Production Idempotency Implementation

// idempotency.ts - Stripe-style idempotency keys

import { Redis } from 'ioredis';
import { createHash } from 'crypto';

interface IdempotencyRecord {
  status: 'processing' | 'complete';
  response?: {
    statusCode: number;
    body: string;
    headers: Record<string, string>;
  };
  requestHash: string;
  createdAt: number;
  completedAt?: number;
}

interface IdempotencyConfig {
  keyTTL: number;          // How long to keep keys (24 hours default)
  lockTTL: number;         // Max processing time (60 seconds)
  headerName: string;      // Idempotency-Key
}

export class IdempotencyService {
  private redis: Redis;
  private config: IdempotencyConfig;

  constructor(redis: Redis, config?: Partial<IdempotencyConfig>) {
    this.redis = redis;
    this.config = {
      keyTTL: 86400,           // 24 hours
      lockTTL: 60,             // 60 seconds
      headerName: 'Idempotency-Key',
      ...config
    };
  }

  // Hash request body to detect changed requests with same key
  private hashRequest(body: unknown): string {
    return createHash('sha256')
      .update(JSON.stringify(body))
      .digest('hex')
      .slice(0, 16);
  }

  async processRequest(
    idempotencyKey: string,
    requestBody: unknown,
    handler: () => Promise<Response>
  ): Promise<Response> {
    const redisKey = `idempotency:${idempotencyKey}`;
    const requestHash = this.hashRequest(requestBody);

    // Try to acquire lock or get existing record
    const existingRecord = await this.getOrLock(redisKey, requestHash);

    if (existingRecord) {
      // Request already processed or in progress
      if (existingRecord.status === 'complete' && existingRecord.response) {
        // Return cached response
        return new Response(existingRecord.response.body, {
          status: existingRecord.response.statusCode,
          headers: {
            ...existingRecord.response.headers,
            'Idempotency-Replayed': 'true'
          }
        });
      }

      if (existingRecord.status === 'processing') {
        // Another request is processing - conflict
        return new Response(
          JSON.stringify({
            success: false,
            error: {
              code: 'IDEMPOTENCY_CONFLICT',
              message: 'A request with this idempotency key is already being processed'
            }
          }),
          { status: 409 }
        );
      }

      // Check if request body changed (misuse of idempotency key)
      if (existingRecord.requestHash !== requestHash) {
        return new Response(
          JSON.stringify({
            success: false,
            error: {
              code: 'IDEMPOTENCY_MISMATCH',
              message: 'Request body changed for existing idempotency key'
            }
          }),
          { status: 422 }
        );
      }
    }

    // Process the request
    try {
      const response = await handler();

      // Cache the response
      await this.cacheResponse(redisKey, requestHash, response.clone());

      return response;
    } catch (error) {
      // Release the lock on error
      await this.releaseLock(redisKey);
      throw error;
    }
  }

  private async getOrLock(
    key: string,
    requestHash: string
  ): Promise<IdempotencyRecord | null> {
    // Atomic check-and-set using Lua script
    const script = `
      local key = KEYS[1]
      local request_hash = ARGV[1]
      local lock_ttl = tonumber(ARGV[2])
      local now = tonumber(ARGV[3])

      local existing = redis.call('GET', key)

      if existing then
        return existing
      end

      -- Create processing record
      local record = cjson.encode({
        status = 'processing',
        requestHash = request_hash,
        createdAt = now
      })

      redis.call('SET', key, record, 'EX', lock_ttl, 'NX')

      return nil
    `;

    const result = await this.redis.eval(
      script,
      1,
      key,
      requestHash,
      this.config.lockTTL,
      Date.now()
    );

    return result ? JSON.parse(result as string) : null;
  }

  private async cacheResponse(
    key: string,
    requestHash: string,
    response: Response
  ): Promise<void> {
    const body = await response.text();
    const headers: Record<string, string> = {};
    response.headers.forEach((value, name) => {
      headers[name] = value;
    });

    const record: IdempotencyRecord = {
      status: 'complete',
      requestHash,
      createdAt: Date.now(),
      completedAt: Date.now(),
      response: {
        statusCode: response.status,
        body,
        headers
      }
    };

    await this.redis.set(
      key,
      JSON.stringify(record),
      'EX',
      this.config.keyTTL
    );
  }

  private async releaseLock(key: string): Promise<void> {
    await this.redis.del(key);
  }
}

// Middleware usage
export function idempotencyMiddleware(
  service: IdempotencyService,
  methods: string[] = ['POST', 'PUT', 'PATCH']
) {
  return async (req: Request, handler: () => Promise<Response>): Promise<Response> => {
    if (!methods.includes(req.method)) {
      return handler();
    }

    const idempotencyKey = req.headers.get('Idempotency-Key');

    if (!idempotencyKey) {
      return handler(); // Optional: require key for certain endpoints
    }

    // Validate key format (UUID recommended)
    if (!/^[a-zA-Z0-9-_]{16,64}$/.test(idempotencyKey)) {
      return new Response(
        JSON.stringify({
          success: false,
          error: {
            code: 'INVALID_IDEMPOTENCY_KEY',
            message: 'Idempotency key must be 16-64 alphanumeric characters'
          }
        }),
        { status: 400 }
      );
    }

    const body = await req.clone().json().catch(() => ({}));
    return service.processRequest(idempotencyKey, body, handler);
  };
}

Interview Question: “How would you implement idempotency for a payment processing API?”

Strong Answer: “I’d use client-provided idempotency keys stored in Redis or a database. Here’s the approach:

  1. Key generation: Client generates UUID v4 before request, stores it locally
  2. Request processing:
    • Check if key exists in storage
    • If exists with ‘complete’ status, return cached response
    • If exists with ‘processing’ status, return 409 Conflict
    • If new, create record with ‘processing’ status (acts as lock)
  3. Completion: Update record with response, set TTL (24 hours typically)
  4. Safety: Hash request body - if client reuses key with different body, reject with 422

Critical edge cases:

  • Network timeout during processing: client retries, sees ‘processing’, waits and retries
  • Server crash during processing: record expires after lock TTL, client can retry
  • Response body change detection: prevents accidental misuse of idempotency keys”

Breaking vs Non-Breaking Changes

Understanding what breaks clients is essential for API evolution:

Change TypeBreaking?Example
Add optional fieldNoAdding middleName?: string to response
Add required fieldYesAdding taxId: string required in request
Remove fieldYesRemoving legacyId from response
Rename fieldYesuserNameusername
Change field typeYesage: stringage: number
Add enum valueDependsNew status: 'archived' - clients must handle unknown
Change URLYes/users/v2/users
Add new endpointNoAdding GET /users/:id/preferences
Change error formatYesDifferent error response structure

Additive Changes (Safe)

// v1 response
{ "id": "123", "name": "John" }

// v2 response (backward compatible)
{ "id": "123", "name": "John", "createdAt": "2025-01-01T00:00:00Z" }

// Clients ignoring unknown fields will work fine

Breaking Changes (Require New Version)

// v1: Returns array
GET /users → [{ id: "1", name: "John" }]

// v2: Returns object with pagination (BREAKING)
GET /users → { data: [...], meta: { total: 100 } }

// Must be a new version - v1 clients expect array

Common Versioning Mistakes

MistakeProblemFix
Breaking change without versionBreaks existing clientsAlways version breaking changes
Too many versionsMaintenance nightmareDeprecate aggressively, max 2-3 live
No deprecation noticeSurprise breakage12-month minimum notice
Version in request bodyHard to route at gatewayUse URL or headers
Different versioning per endpointConfusingConsistent strategy across API
No sunset dateVersions live foreverDefine lifecycle upfront

Idempotency Common Mistakes

MistakeProblemFix
Optional idempotency keysSome requests not protectedRequire for all mutations
Key tied to user sessionCan’t retry from different sessionKey should be request-scoped
Short TTLKey expires before client can retry24 hours minimum
No body hashSame key, different request acceptedHash and compare request body
Storing full responseStorage bloatStore only what’s needed to reconstruct
No lock mechanismRace conditionsUse Redis SETNX or similar

Versioning Quick Reference

Versioning & Idempotency Guide


What’s Next?

Now that you understand API reliability, Part 5: Caching Strategies covers multi-layer caching architectures and cache invalidation patterns.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.