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.
| Action | URL | Method |
|---|---|---|
| List orders | /orders | GET |
| Get one | /orders/{id} | GET |
| Create | /orders | POST |
| 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 (
/getOrdersis wrong). - No file extensions (
/orders.json). - Depth ≤ 2 levels usually. Avoid
/users/{a}/orders/{b}/items/{c}/....
HTTP Verbs: Semantics
| Verb | Safe | Idempotent | Body | Caching |
|---|---|---|---|---|
| GET | ✓ | ✓ | no | yes |
| HEAD | ✓ | ✓ | no | yes |
| OPTIONS | ✓ | ✓ | no | usually no |
| POST | ✗ | ✗ (unless idempotency key) | yes | no |
| PUT | ✗ | ✓ | yes | no |
| PATCH | ✗ | ✗ (often) | yes | no |
| DELETE | ✗ | ✓ | optional | no |
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
| Code | Use |
|---|---|
| 200 | OK; standard success |
| 201 | Created; with Location: header for new resource |
| 202 | Accepted; async work scheduled |
| 204 | No 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)
| Code | Use |
|---|---|
| 400 | bad/malformed request |
| 401 | unauthenticated (not logged in) |
| 403 | authenticated but not allowed |
| 404 | resource doesn't exist |
| 405 | method not allowed on this URL |
| 409 | conflict (version mismatch, duplicate) |
| 410 | gone (used to exist, won't return) |
| 422 | semantically invalid (validation failed) |
| 429 | rate limited |
5xx: server error (your fault)
| Code | Use |
|---|---|
| 500 | unexpected error |
| 502 | bad gateway (upstream broken) |
| 503 | service unavailable (overload, maintenance) |
| 504 | gateway 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(ornext_page_url); clients shouldn't construct. - Hard cap on
limit(e.g. 100).
Filtering, Sorting, Search
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:
- Look up key in store (Redis with TTL = 24h).
- If exists → return the stored response (same body, same status).
- 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
| Strategy | Example | Notes |
|---|---|---|
| URL | /v1/orders | Most visible; easy. Hard to evolve mid-version. |
| Header | Accept: application/vnd.example.v2+json | Clean URLs; less client-friendly. |
| Query | /orders?v=2 | Not 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-Signatureheader). - 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 when | Pick GraphQL when |
|---|---|
| Server-driven, simple resources | Many UI surfaces with different needs |
| HTTP caching is important | One backend, many client types (mobile, web, admin) |
| Public API for many third-parties | Internal API for your own clients |
| Most calls touch one resource | Most calls assemble data from many sources |
| Team familiar with HTTP | Team 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)
| Level | What |
|---|---|
| 0 | one URL, one method (RPC over HTTP) |
| 1 | multiple 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"
- Clarify scope: post, fetch timeline, follow. Skip search/media for v1.
- 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
- Pagination on timelines: cursor based on tweet_id.
- Auth: bearer JWT. 401 unauthenticated, 403 for "blocked you".
- Rate limits: 300 tweets/3h, 100K reads/15min.
- Idempotency on POST /tweets with
Idempotency-Key. - Error envelope with codes (
TWEET_TOO_LONG,USER_BLOCKED,RATE_LIMITED). - Webhook for new tweets from followed users (optional).
- Versioning:
/v1/..., breaking changes →/v2. - Caching: GET /tweets/{id} → public, ETag.
Common Interview Critiques
| Smell | Fix |
|---|---|
Verbs in URL (/getOrders) | Use noun + GET |
| Same status code for everything | Pick 400/401/403/404/409/422 correctly |
| No pagination, returns full list | Cap and cursor |
| No idempotency on POST | Add Idempotency-Key for writes |
| Snake/camel/kebab inconsistent | Pick one, document it |
| Server-side filtering by arbitrary fields | Allow-list filterable fields |
| Bulk endpoints with no partial-success shape | Return [{id, status, error}] per item |
| No request_id on errors | Add it; support needs it |
| Auth + authz tangled in handlers | Auth at middleware, authz at resource |
| Breaking change in v1 | Either 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