REST
03 / 04

API Design Best Practices

REST API Design Best Practices

A well-designed API is intuitive, consistent, and future-proof. These conventions are drawn from Stripe, GitHub, Twilio, and other industry-standard APIs.

URL Naming & Structure

# Use nouns (resources), not verbs
# Plural nouns for collections
/users              # All users
/users/42           # User 42
/users/42/orders    # Orders for user 42
/orders/99/items    # Items in order 99

# Use kebab-case for multi-word resources
/blog-posts         # ✓
/blog_posts         # ✗
/blogPosts          # ✗

# Actions on resources that don't fit CRUD: use sub-resources or action names
POST /orders/42/cancel     # Cancel order (action sub-resource)
POST /users/42/password-reset  # Trigger password reset
POST /videos/7/publish     # Publish a video

# Filtering, sorting, searching as query params (not new endpoints)
GET /products?category=shoes&minPrice=50&maxPrice=200
GET /users?role=admin&verified=true
GET /orders?status=pending&sort=createdAt&order=desc
GET /products?q=running+shoes        # Full-text search
GET /users?fields=id,name,email      # Sparse fieldsets (GraphQL-like)

# Consistency in naming:
# id fields: "id" not "userId", "orderId" (context is the resource)
# Timestamps: ISO 8601 UTC: "2025-03-15T10:00:00Z"
# Money: integers in smallest unit (cents): 1099 = $10.99
# Booleans: "isActive" not "active", "hasOrders" not "orders"

Versioning Strategies

# Strategy 1: URL path versioning (most common, most visible)
/api/v1/users
/api/v2/users
# Pros: obvious, easy to test in browser, CDN-cacheable per version
# Cons: ugly URLs, forces breaking URL changes
# Used by: Stripe, GitHub, Twitter/X

# Strategy 2: Header versioning
GET /users
API-Version: 2025-03-01
Accept: application/vnd.myapi.v2+json
# Pros: clean URLs, flexible
# Cons: not visible in browser address bar, harder to test
# Used by: Stripe (date-based), GitHub (Accept header)

# Strategy 3: Query parameter versioning (least preferred)
GET /users?version=2
# Pros: simple
# Cons: versioning mixed with resource params, bad practice

# Date-based versioning (Stripe approach - highly recommended)
# Client pins to a version date; API changes don't break existing clients
Stripe-Version: 2023-10-16
# Old clients keep old version behavior; you can deprecate gracefully

# Version lifecycle:
# v1 → current (supported)
# v2 → new version with breaking changes
# After 6-12 months: deprecate v1 (Sunset header)
# After another 6 months: sunset v1
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Deprecation: true
Link: </api/v2/users>; rel="successor-version"

Pagination

// Offset-based pagination (simple, but performance degrades at large offsets)
// GET /users?limit=20&offset=40
{
  "data": [...],
  "pagination": {
    "total": 1250,
    "limit": 20,
    "offset": 40,
    "hasMore": true
  }
}
// Problem: inconsistent if items are inserted/deleted between pages

// Cursor-based pagination (preferred for feeds, infinite scroll)
// GET /posts?limit=20&after=cursor_eyJpZCI6MTAwfQ
{
  "data": [...],
  "pagination": {
    "limit": 20,
    "hasNextPage": true,
    "hasPrevPage": true,
    "nextCursor": "cursor_eyJpZCI6MTIwfQ",
    "prevCursor": "cursor_eyJpZCI6MTAxfQ"
  }
}
// Cursor is an opaque base64-encoded value (e.g., the last item's id+timestamp)
// Stable across insertions/deletions; efficient (uses indexed seek, no OFFSET)
// Used by: Facebook Graph API, GitHub, Stripe

// Page-based pagination (simple, good for small datasets)
// GET /products?page=3&perPage=20
{
  "data": [...],
  "meta": {
    "currentPage": 3,
    "totalPages": 63,
    "perPage": 20,
    "totalCount": 1250
  },
  "links": {
    "first": "/products?page=1&perPage=20",
    "prev": "/products?page=2&perPage=20",
    "next": "/products?page=4&perPage=20",
    "last": "/products?page=63&perPage=20"
  }
}

Rate Limiting & Idempotency Keys

# Rate limit response headers (expose limits to clients)
X-RateLimit-Limit: 1000       # Requests allowed per window
X-RateLimit-Remaining: 734    # Requests left in current window
X-RateLimit-Reset: 1709900400 # Unix timestamp when window resets
Retry-After: 60               # Seconds to wait (sent with 429)

# Rate limit strategies:
# Fixed window: 1000 reqs per hour
# Sliding window: 1000 reqs in any 60-minute period (smoother)
# Token bucket: tokens replenish at rate X, burst up to N
# Leaky bucket: queue requests, process at fixed rate

# Different limits per tier:
# Free: 100 req/min
# Pro: 1000 req/min
# Enterprise: custom

# Idempotency Keys (make non-idempotent operations safe to retry)
# Client generates unique key per operation
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{"amount": 9999, "currency": "usd", "customerId": "cus_123"}

# Server behavior:
# - First request: process payment, store result with key
# - Retry with same key: return same stored response (no duplicate charge)
# - Key expires after 24 hours
# - Different key = treat as new operation

# Implementation sketch (Redis-based)
const key = req.headers['idempotency-key'];
if (key) {
  const cached = await redis.get(`idempotency:${userId}:${key}`);
  if (cached) return res.json(JSON.parse(cached));
}
const result = await processPayment(req.body);
if (key) await redis.setex(`idempotency:${userId}:${key}`, 86400, JSON.stringify(result));
res.status(201).json(result);

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free