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.
Moshiour Rahman
Advertisement
API Design Mastery Series
This is Part 4 of our comprehensive API Design series.
| Part | Topic | Level |
|---|---|---|
| 1 | HTTP & REST Fundamentals | Beginner |
| 2 | Security & Authentication | Beginner |
| 3 | Rate Limiting & Pagination | Intermediate |
| 4 | Versioning & Idempotency | Intermediate |
| 5 | Caching Strategies | Intermediate |
| 6 | GraphQL & gRPC | Intermediate |
| 7 | Resilience & Observability | Advanced |
| 8 | Production Mastery | Advanced |
API Versioning Strategies
The Complete Versioning Matrix
| Strategy | Example | Discoverability | Caching | Client Simplicity |
|---|---|---|---|---|
| URL | /v1/users | Excellent | Excellent | Excellent |
| Headers | X-API-Version: 1 | Poor | Requires Vary | Moderate |
| Content-Type | Accept: application/vnd.api+json;version=1 | Poor | Requires Vary | Poor |
| Query Param | /users?version=1 | Moderate | Moderate | Good |
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
| Phase | Status | What Happens | Duration |
|---|---|---|---|
| Development | Internal | Not publicly accessible | As needed |
| Active | Default | v2 default for new integrations, v1 fully supported | Indefinite |
| Deprecated | Warning | Deprecation headers added, migration guides published | 6-12 months |
| Sunset | Ending | Returns 410 Gone, optional proxy to v2 | 1-3 months |
| Removed | Archived | Endpoints deleted, docs archived | Permanent |
Idempotency for Reliable APIs
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:
- Client sends POST /payments
- Server processes request successfully
- Response times out (network issue)
- Client retries with same request
- Without idempotency: Double charge!
- 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:
- Key generation: Client generates UUID v4 before request, stores it locally
- 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)
- Completion: Update record with response, set TTL (24 hours typically)
- 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 Type | Breaking? | Example |
|---|---|---|
| Add optional field | No | Adding middleName?: string to response |
| Add required field | Yes | Adding taxId: string required in request |
| Remove field | Yes | Removing legacyId from response |
| Rename field | Yes | userName → username |
| Change field type | Yes | age: string → age: number |
| Add enum value | Depends | New status: 'archived' - clients must handle unknown |
| Change URL | Yes | /users → /v2/users |
| Add new endpoint | No | Adding GET /users/:id/preferences |
| Change error format | Yes | Different 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
| Mistake | Problem | Fix |
|---|---|---|
| Breaking change without version | Breaks existing clients | Always version breaking changes |
| Too many versions | Maintenance nightmare | Deprecate aggressively, max 2-3 live |
| No deprecation notice | Surprise breakage | 12-month minimum notice |
| Version in request body | Hard to route at gateway | Use URL or headers |
| Different versioning per endpoint | Confusing | Consistent strategy across API |
| No sunset date | Versions live forever | Define lifecycle upfront |
Idempotency Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Optional idempotency keys | Some requests not protected | Require for all mutations |
| Key tied to user session | Can’t retry from different session | Key should be request-scoped |
| Short TTL | Key expires before client can retry | 24 hours minimum |
| No body hash | Same key, different request accepted | Hash and compare request body |
| Storing full response | Storage bloat | Store only what’s needed to reconstruct |
| No lock mechanism | Race conditions | Use Redis SETNX or similar |
Versioning Quick Reference

What’s Next?
Now that you understand API reliability, Part 5: Caching Strategies covers multi-layer caching architectures and cache invalidation patterns.
Advertisement
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
API Design Part 1: HTTP & REST Fundamentals
Master HTTP methods, status codes, and REST maturity model. The foundation every API developer needs - from GET/POST basics to idempotency and proper status code selection.
System DesignAPI Design Mastery: Complete 8-Part Series
Master API design from HTTP fundamentals to production systems. 8-part comprehensive guide covering REST, security, caching, GraphQL, gRPC, resilience, and interview preparation.
System DesignAPI Design Part 5: Caching Strategies
Master multi-layer caching architecture, HTTP cache headers, ETags, and cache invalidation patterns. Build fast, scalable APIs with proper caching.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.