REST API Design Best Practices: Versioning, Pagination, and Error Handling

API design questions appear in backend, platform engineering, and staff-level interviews. A poorly designed API is a permanent liability — once external clients depend on it, breaking changes require careful versioning and migration. This guide covers the decisions that distinguish a senior engineer from a junior one.

Resource Naming Conventions

# GOOD: nouns, plural, lowercase, hyphens for readability
GET  /v1/users                    # list users
GET  /v1/users/{userId}           # get one user
POST /v1/users                    # create user
PUT  /v1/users/{userId}           # replace user (full update)
PATCH /v1/users/{userId}          # partial update
DELETE /v1/users/{userId}         # delete user

# Nested resources for clear ownership
GET  /v1/users/{userId}/orders    # orders belonging to a user
POST /v1/users/{userId}/orders    # create order for user
GET  /v1/orders/{orderId}         # get order by ID (shorthand — no nesting needed)

# BAD: verbs in URL, inconsistent casing
POST /v1/createUser               # verb in path — use POST on /users instead
GET  /v1/getUserOrders/{userId}   # verb, camelCase — not REST
POST /v1/users/delete/{userId}    # DELETE method exists for this

HTTP Methods and Idempotency

Method Purpose Idempotent? Safe?
GET Retrieve resource Yes Yes (no side effects)
POST Create resource, trigger action No No
PUT Replace resource entirely Yes No
PATCH Partial update No (unless designed carefully) No
DELETE Remove resource Yes (delete again = still deleted) No

Idempotency Keys for POST

# POST is not idempotent by default — retries cause duplicate creates
# Solution: client sends a unique Idempotency-Key header

POST /v1/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "amount": 5000,
  "currency": "usd",
  "customer_id": "cust_123"
}

# Server behavior:
# First request: process payment, store result keyed by Idempotency-Key
# Retry with same key: return cached result (no duplicate charge)
# Expiry: keys expire after 24 hours

# Implementation
def create_payment(idempotency_key: str, payload: dict) -> dict:
    cached = redis.get(f"idem:{idempotency_key}")
    if cached:
        return json.loads(cached)   # return same response as original

    result = payment_processor.charge(payload)
    redis.setex(f"idem:{idempotency_key}", 86400, json.dumps(result))
    return result

Pagination Strategies

Offset Pagination

GET /v1/users?limit=20&offset=40    # page 3 of 20 items/page

Response:
{
  "data": [...],
  "total": 10000,
  "limit": 20,
  "offset": 40,
  "has_more": true
}

# SQL: SELECT * FROM users LIMIT 20 OFFSET 40
# Problem: expensive at large offsets (DB scans 40 rows to skip them)
# Problem: rows inserted during pagination cause items to be skipped or repeated
# Use for: admin UIs, small datasets where users need to jump to page N

Cursor-Based Pagination (Recommended for APIs)

GET /v1/users?limit=20&cursor=eyJpZCI6MTAwfQ    # base64-encoded cursor

Response:
{
  "data": [...],
  "next_cursor": "eyJpZCI6MTIwfQ",   # null if last page
  "has_more": true
}

# SQL: SELECT * FROM users WHERE id > 100 ORDER BY id ASC LIMIT 20
# cursor = base64({"id": 100}) — encodes position, not page number

# Advantages:
# O(1) regardless of position (uses index seek, not scan+skip)
# Stable: new rows inserted during pagination do not cause duplicates/skips
# Disadvantages:
# Cannot jump to arbitrary page
# Cursor ties you to the sort order

# Use for: infinite scroll, real-time feeds, large datasets

Versioning Strategies

Strategy Example Pros Cons
URL versioning /v1/users, /v2/users Explicit, easy to test, cacheable URL changes, must update all clients
Header versioning Accept: application/vnd.api+json;version=2 Clean URLs Hidden from URL, harder to test in browser
Query parameter /users?version=2 Easy to test Pollutes query string, cache key issues

Recommendation: URL versioning for public APIs. It is explicit, easily visible, and works well with CDN caching (version is in the cache key). Keep v1 alive alongside v2 with a deprecation timeline announced well in advance.

# Breaking changes require a new version:
# - Removing a field from a response
# - Changing a field type (string → int)
# - Renaming a field
# - Changing validation rules more strictly

# Non-breaking changes (no version bump needed):
# - Adding new optional fields to responses
# - Adding new optional request parameters
# - Adding new endpoints
# - Relaxing validation rules

Error Response Design

# Use appropriate HTTP status codes
200 OK                 — success
201 Created            — resource created (with Location header)
204 No Content         — success, no body (DELETE)
400 Bad Request        — client error: validation, malformed JSON
401 Unauthorized       — not authenticated (wrong/missing token)
403 Forbidden          — authenticated but not authorized
404 Not Found          — resource does not exist
409 Conflict           — state conflict (duplicate, concurrent modification)
422 Unprocessable      — well-formed request but semantic validation failed
429 Too Many Requests  — rate limited (include Retry-After header)
500 Internal Server Error — our bug
503 Service Unavailable   — overloaded, maintenance

# Consistent error response format (RFC 7807 Problem Details)
{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "Request body is invalid",
  "errors": [
    { "field": "email", "message": "Must be a valid email address" },
    { "field": "age", "message": "Must be at least 18" }
  ],
  "request_id": "req_abcd1234"   # for correlation with server logs
}

Authentication and Authorization

# JWT Bearer token (most common for REST APIs)
GET /v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJ1c2VyIn0...

# API key (for server-to-server)
GET /v1/payments
X-API-Key: sk_live_abcdef123456

# OAuth 2.0 scopes in JWT claims
{
  "sub": "user_123",
  "scopes": ["read:users", "write:payments"],
  "exp": 1750000000
}

# Middleware pattern
def require_auth(scope: str):
    def decorator(func):
        def wrapper(request, *args, **kwargs):
            token = request.headers.get("Authorization", "").removeprefix("Bearer ")
            claims = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
            if scope not in claims.get("scopes", []):
                raise ForbiddenError(f"Scope {scope} required")
            request.user = claims
            return func(request, *args, **kwargs)
        return wrapper
    return decorator

@require_auth("read:users")
def get_user(request, user_id: str):
    ...

Rate Limiting Response Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000         # requests allowed per window
X-RateLimit-Remaining: 947      # remaining in current window
X-RateLimit-Reset: 1750000000   # Unix timestamp when window resets

# If rate limited (429):
HTTP/1.1 429 Too Many Requests
Retry-After: 30                  # seconds to wait before retrying

Webhook Design

# Event-driven alternative to polling
# Server pushes events to client callback URL when state changes

POST https://client-app.com/webhooks/payments
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...   # HMAC signature for verification

{
  "event": "payment.completed",
  "id": "evt_789",
  "created": 1750000000,
  "data": {
    "payment_id": "pay_123",
    "amount": 5000,
    "currency": "usd",
    "status": "succeeded"
  }
}

# Client verifies signature to prevent spoofed webhooks
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)

Frequently Asked Questions

What is the difference between PUT and PATCH in REST APIs?

PUT replaces the entire resource — you send the full representation and the server overwrites everything. If you omit a field, it is cleared or reset to default. PUT is idempotent: calling it twice produces the same result. PATCH applies a partial update — you send only the fields to change, and the server merges them with the existing resource. PATCH is more efficient (less data over the wire) and safer (you cannot accidentally clear fields you did not mean to change). PATCH is technically not guaranteed idempotent, though it often is in practice. Use PUT when the client manages the full resource state; use PATCH for typical "update this field" operations.

What is cursor-based pagination and when should you use it?

Cursor-based pagination uses an opaque pointer (cursor) to your position in the result set instead of a page number. The server encodes the position in the cursor (e.g., base64 of the last seen ID), and the client sends it with each request to get the next page. Advantages over offset pagination: O(1) performance regardless of position (uses an index seek rather than scan-and-skip), and stable results when new records are inserted (no items skipped or repeated). Disadvantages: you cannot jump to an arbitrary page number, and the sort order is tied to the cursor encoding. Use cursor pagination for infinite scroll UIs, real-time feeds, and any large dataset. Use offset pagination when users need to jump to specific pages (admin tables, search results with page numbers).

How do you version a REST API without breaking existing clients?

The safest versioning strategy is URL versioning (/v1/, /v2/) with a deprecation policy: keep both versions alive for at least 6-12 months after announcing a new version, provide a migration guide, and send Deprecation headers in v1 responses to prompt clients to upgrade. Non-breaking changes (adding optional fields, new endpoints, relaxing validation) never require a version bump. Breaking changes (removing fields, changing types, adding required fields, changing semantics) always require a new version. Track client usage by version via logs and metrics — you need this data to decide when it is safe to sunset old versions. Consider providing a changelog and an upgrade SDK to reduce client migration friction.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is the difference between PUT and PATCH in REST APIs?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “PUT replaces the entire resource — you send the full representation and the server overwrites everything. If you omit a field, it is cleared or reset to default. PUT is idempotent: calling it twice produces the same result. PATCH applies a partial update — you send only the fields to change, and the server merges them with the existing resource. PATCH is more efficient (less data over the wire) and safer (you cannot accidentally clear fields you did not mean to change). PATCH is technically not guaranteed idempotent, though it often is in practice. Use PUT when the client manages the full resource state; use PATCH for typical “update this field” operations.”
}
},
{
“@type”: “Question”,
“name”: “What is cursor-based pagination and when should you use it?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Cursor-based pagination uses an opaque pointer (cursor) to your position in the result set instead of a page number. The server encodes the position in the cursor (e.g., base64 of the last seen ID), and the client sends it with each request to get the next page. Advantages over offset pagination: O(1) performance regardless of position (uses an index seek rather than scan-and-skip), and stable results when new records are inserted (no items skipped or repeated). Disadvantages: you cannot jump to an arbitrary page number, and the sort order is tied to the cursor encoding. Use cursor pagination for infinite scroll UIs, real-time feeds, and any large dataset. Use offset pagination when users need to jump to specific pages (admin tables, search results with page numbers).”
}
},
{
“@type”: “Question”,
“name”: “How do you version a REST API without breaking existing clients?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The safest versioning strategy is URL versioning (/v1/, /v2/) with a deprecation policy: keep both versions alive for at least 6-12 months after announcing a new version, provide a migration guide, and send Deprecation headers in v1 responses to prompt clients to upgrade. Non-breaking changes (adding optional fields, new endpoints, relaxing validation) never require a version bump. Breaking changes (removing fields, changing types, adding required fields, changing semantics) always require a new version. Track client usage by version via logs and metrics — you need this data to decide when it is safe to sunset old versions. Consider providing a changelog and an upgrade SDK to reduce client migration friction.”
}
}
]
}

Scroll to Top