Skip to main content

API Design

How to design clean HTTP APIs and answer 'design the X API' questions — resources, versioning, pagination, and idempotency.

Free reference · last reviewed

A clean HTTP API models resources as nouns, uses HTTP verbs and status codes for their defined meaning, and stays predictable as it grows — versioned, paginated, and idempotent where it matters. "Design the X API" interviews reward exactly that discipline, plus the trade-offs (REST vs GraphQL, sync vs async). Below: each building block, then a full "design the Twitter API" walk-through.


REST in 60 seconds

REST = client/server, stateless, cacheable, layered, uniform interface.

In practice that boils down to:

  • Resources as URLs (nouns, not verbs).
  • HTTP verbs for actions.
  • HTTP status codes for outcome.
  • JSON body.
  • Stateless: every request carries its own auth/context. No server-side session needed.

Resource Modeling

Identify nouns first, then verbs.

ActionURLMethod
List orders/ordersGET
Get one/orders/{id}GET
Create/ordersPOST
Replace/orders/{id}PUT
Partial update/orders/{id}PATCH
Delete/orders/{id}DELETE

Nested resources (for ownership)

GET    /users/{id}/orders            list a user's orders
POST   /users/{id}/orders            create an order under that user
GET    /orders/{id}/items

Actions that aren't CRUD

When something is genuinely a verb (refund, cancel, retry), put it under the resource:

POST /orders/{id}/refund
POST /orders/{id}/cancel
POST /jobs/{id}/retry

Don't force it into PATCH. "Refund" is not "modify field."

Rules of thumb

  • Plural collection (/orders), singular item (/orders/{id}).
  • Lowercase, hyphens for multi-word (/user-sessions, not /userSessions).
  • No verbs in URLs (/getOrders is wrong).
  • No file extensions (/orders.json).
  • Depth ≤ 2 levels usually. Avoid /users/{a}/orders/{b}/items/{c}/....

HTTP Verbs: Semantics

VerbSafeIdempotentBodyCaching
GETnoyes
HEADnoyes
OPTIONSnousually no
POST✗ (unless idempotency key)yesno
PUTyesno
PATCH✗ (often)yesno
DELETEoptionalno

Idempotent: running it twice has the same effect as once. PUT replaces; PATCH modifies fields.

Predict the pattern

Which HTTP verb is NOT idempotent by default? Sending it twice may create two resources.

Predict the pattern

POST is idempotent — sending the same POST request twice always produces the same result.


Status Codes: Use Them Right

2xx: success

CodeUse
200OK; standard success
201Created; with Location: header for new resource
202Accepted; async work scheduled
204No Content; DELETE success, or PUT/PATCH with no return body

3xx: redirect

| 301/308 | permanent redirect | | 302/307 | temporary redirect | | 304 | Not Modified (conditional GET) |

4xx: client error (their fault)

CodeUse
400bad/malformed request
401unauthenticated (not logged in)
403authenticated but not allowed
404resource doesn't exist
405method not allowed on this URL
409conflict (version mismatch, duplicate)
410gone (used to exist, won't return)
422semantically invalid (validation failed)
429rate limited

5xx: server error (your fault)

CodeUse
500unexpected error
502bad gateway (upstream broken)
503service unavailable (overload, maintenance)
504gateway timeout

Predict the pattern

A client tries to create a username that already exists in the database — a uniqueness collision. What is the correct HTTP status code to return?

Common misuses to avoid

  • 200 with {"error": ...}: interferes with client error handling.
  • 400 for everything: distinguish 400/401/403/404/409/422.
  • 404 for "not authorized": pick 401 or 403; 404 is for "doesn't exist" (occasionally used to hide existence).

Request & Response Shape

Request body: JSON

{
  "user_id": "u_123",
  "items": [{"sku": "abc", "qty": 2}],
  "currency": "INR"
}

Successful response: envelope or not?

No envelope (preferred for simple APIs):

{ "id": "ord_123", "total": 199, "status": "paid" }

With envelope (paginated lists, multi-resource responses):

{
  "data": [ ... ],
  "pagination": { "next_cursor": "abc" },
  "meta": { ... }
}

Pick one and stay consistent.

Error response: standardize

RFC 7807 (application/problem+json) or a custom envelope. Whatever you pick, keep it consistent:

{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Account balance is too low for this transfer.",
    "field": "amount",
    "request_id": "req_abc123",
    "docs_url": "https://docs.example.com/errors/insufficient_funds"
  }
}

Why this matters:

  • code (machine-readable): clients branch on it. Stable across versions.
  • message (human-readable): for logs/UI.
  • field: when applicable, for inline form errors.
  • request_id: lets users hand you something specific in support tickets.

Pagination

Offset (don't, for large data)

GET /orders?limit=20&offset=10000

Problems:

  • DB does OFFSET 10000 = scans+discards 10K rows.
  • New rows inserted during paging shift offsets → skipped/duplicate rows.

Cursor (preferred)

GET /orders?limit=20&cursor=eyJpZCI6MTIzfQ==

Response:

{ "data": [...], "next_cursor": "..." }

Cursor is opaque to the client, usually base64 of last sort key (id + created_at).

Server query:

SELECT * FROM orders WHERE (created_at, id) < ($last_ts, $last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;

Page + size (smallest API surface; fine for small data)

GET /orders?page=3&page_size=20

Always

  • Return next_cursor (or next_page_url); clients shouldn't construct.
  • Hard cap on limit (e.g. 100).

GET /orders?status=paid&min_total=100&created_after=2025-01-01
GET /orders?sort=-created_at,total       # - prefix = desc
GET /orders?q=foo                        # free-text search
GET /orders?fields=id,total              # sparse fieldsets
GET /orders?expand=user,items            # include related

Document allowed fields; don't let arbitrary user input drive ORM filters → injection / DoS.


Idempotency

For non-idempotent operations (POST), let the client send a key:

POST /charges
Idempotency-Key: 4f3c-...

Server logic:

  1. Look up key in store (Redis with TTL = 24h).
  2. If exists → return the stored response (same body, same status).
  3. If not → process, then store (key → response) before returning.

Why: client retries on network failure won't double-charge.

Stripe-style: idempotency keys for all writes. Document the TTL.


Versioning

StrategyExampleNotes
URL/v1/ordersMost visible; easy. Hard to evolve mid-version.
HeaderAccept: application/vnd.example.v2+jsonClean URLs; less client-friendly.
Query/orders?v=2Not recommended; mixes with filters.

Versioning rule

  • Add fields → not a breaking change.
  • Rename / remove / change type → breaking → new version.
  • Default to backwards-compatible. Old versions live until N% of clients migrate.

Date-based versioning (Stripe)

Each request carries Stripe-Version: 2025-04-30. Server holds N versions and translates. Heavy infra but lets every account stick on its known version forever.


Auth

Tokens

  • Bearer JWT in Authorization: Bearer <token> for stateless auth.
  • API key in header (X-API-Key:) or auth header for server-to-server.
  • OAuth 2.0 when third-party apps act on user's behalf.

Patterns

  • Auth at the gateway/middleware, not per-handler.
  • Authz checks at the handler (resource-level: "does this user own this order?").
  • 401 vs 403: 401 = no/invalid token, 403 = valid token but not allowed.

Predict the pattern

A request arrives with a missing or expired bearer token. Which status code should the server return?

Don't

  • Pass tokens in URLs (?token=...); they end up in logs.
  • Long-lived tokens with no refresh; issue short access + long refresh tokens.
  • Store tokens unencrypted server-side. Hash them.

Rate Limiting

Return:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700000000     # epoch seconds
Retry-After: 30                    # seconds

Algorithms:

  • Token bucket: burst + sustained rate.
  • Sliding window log/counter: smoother.
  • Per-API-key + per-IP + per-endpoint (different limits).

Caching

Server tells client/proxies what to cache:

Cache-Control: public, max-age=3600
ETag: "v1-abc"
Last-Modified: Tue, 15 Nov 2025 12:00:00 GMT

Client revalidates:

GET /orders/1
If-None-Match: "v1-abc"
→ 304 Not Modified                 (no body)

For private data: Cache-Control: private, no-cache. For mutating endpoints: Cache-Control: no-store.


CORS

Browser-only concern. If a browser app on app.example.com calls API on api.example.com:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600

Preflight: OPTIONS request before non-simple requests.

Never Access-Control-Allow-Origin: * with Allow-Credentials: true.


Webhooks (outbound from your service)

When you call out to a customer's URL on events:

  • Sign payload (HMAC of body with shared secret in X-Signature header).
  • Retry on non-2xx with exponential backoff.
  • Time out fast (5-30s).
  • Idempotency: include event_id; customers dedupe.
  • Failure dashboard for customers to see delivery status.

Async / Long-running Operations

When work takes > 30s, don't block.

Pattern:

POST /reports                       → 202 Accepted
                                       Location: /jobs/abc

GET /jobs/abc                       → { "status": "running" }
GET /jobs/abc                       → { "status": "done", "result_url": "/reports/abc" }

Optional: notify via webhook or SSE/WebSocket when done.


GraphQL vs REST: When to pick which

Pick REST whenPick GraphQL when
Server-driven, simple resourcesMany UI surfaces with different needs
HTTP caching is importantOne backend, many client types (mobile, web, admin)
Public API for many third-partiesInternal API for your own clients
Most calls touch one resourceMost calls assemble data from many sources
Team familiar with HTTPTeam OK with new tooling & schema discipline

GraphQL pros: client picks fields, fewer round trips, single endpoint, strong types via schema. GraphQL cons: harder caching, N+1 query foot-guns (need dataloader), no HTTP status nuance, harder rate limiting (every query is one POST).


REST Maturity (Richardson)

LevelWhat
0one URL, one method (RPC over HTTP)
1multiple URLs (resources)
2+ HTTP verbs + status codes (most APIs)
3+ HATEOAS (hypermedia links), rare in practice

Aim for Level 2. Level 3 is academically pretty, practically rare.


Design Interview Walk-through: "Design the Twitter API"

  1. Clarify scope: post, fetch timeline, follow. Skip search/media for v1.
  2. Resources & verbs
POST   /tweets                       create
GET    /tweets/{id}
DELETE /tweets/{id}
GET    /users/{id}/tweets            user timeline
GET    /timeline/home                home feed (auth required)
POST   /users/{id}/follow
DELETE /users/{id}/follow
  1. Pagination on timelines: cursor based on tweet_id.
  2. Auth: bearer JWT. 401 unauthenticated, 403 for "blocked you".
  3. Rate limits: 300 tweets/3h, 100K reads/15min.
  4. Idempotency on POST /tweets with Idempotency-Key.
  5. Error envelope with codes (TWEET_TOO_LONG, USER_BLOCKED, RATE_LIMITED).
  6. Webhook for new tweets from followed users (optional).
  7. Versioning: /v1/..., breaking changes → /v2.
  8. Caching: GET /tweets/{id} → public, ETag.

Common Interview Critiques

SmellFix
Verbs in URL (/getOrders)Use noun + GET
Same status code for everythingPick 400/401/403/404/409/422 correctly
No pagination, returns full listCap and cursor
No idempotency on POSTAdd Idempotency-Key for writes
Snake/camel/kebab inconsistentPick one, document it
Server-side filtering by arbitrary fieldsAllow-list filterable fields
Bulk endpoints with no partial-success shapeReturn [{id, status, error}] per item
No request_id on errorsAdd it; support needs it
Auth + authz tangled in handlersAuth at middleware, authz at resource
Breaking change in v1Either version up or add field non-breakingly

API Design Checklist

Before shipping a new endpoint:

  • Resource URL, plural, lowercase, kebab-case
  • Correct HTTP verb
  • Correct status codes for all outcomes
  • Validation errors → 422 with field info
  • Auth + authz
  • Rate limit headers
  • Cursor pagination if returns a list
  • Idempotency key for non-idempotent writes
  • Cache headers (or explicit no-cache)
  • Consistent error envelope with code, message, request_id
  • OpenAPI/Swagger doc updated
  • Backward compatible (or properly versioned)
  • Logged with structured fields (no PII)
  • Metrics (latency, status code) emitted
  • Integration test for happy path + 2 errors