Back to Notes

API Design Principles

API Design Principles

Tested in: System Design rounds, backend interviews, "design this endpoint" questions.


REST Constraints (verbal must-know)

ConstraintWhat it means
StatelessServer holds no client session. Every request carries all context (JWT in header).
Uniform interfaceResources identified by URL. Standard HTTP verbs. Consistent response format.
Client-serverUI and backend evolve independently.
CacheableGET responses should be cacheable. Use Cache-Control headers.
Layered systemClient doesn't know if it's hitting server or load balancer.

HTTP Verbs + Idempotency

VerbUseIdempotentSafe
GETFetch resource
POSTCreate resource
PUTReplace entire resource
PATCHPartial update❌ (usually)
DELETERemove resource

Idempotent = same request N times = same result as 1 time. Why POST isn't idempotent: calling POST /orders twice creates two orders.

Idempotency key for POST (fintech critical):

POST /payments
Idempotency-Key: uuid-here

Server stores key + result. Duplicate request returns same result, doesn't charge twice.


URL Design

# Good — noun-based resources, hierarchy
GET    /users/{id}
GET    /users/{id}/orders
POST   /users/{id}/orders
DELETE /users/{id}/orders/{orderId}

# Bad — verb in URL (RPC style, not REST)
GET /getUserOrders
POST /createOrder
DELETE /deleteOrder

# Query params for filtering/sorting (not path)
GET /orders?status=active&sort=created_at&order=desc
GET /orders?page=2&limit=20

HTTP Status Codes (must know cold)

2xx Success:
  200 OK             — GET, PUT, PATCH success
  201 Created        — POST success (include Location header)
  204 No Content     — DELETE success, no body

4xx Client errors:
  400 Bad Request    — malformed request, invalid params
  401 Unauthorized   — not authenticated (no/invalid token)
  403 Forbidden      — authenticated but no permission
  404 Not Found      — resource doesn't exist
  409 Conflict       — duplicate (email already registered)
  422 Unprocessable  — valid JSON but fails validation (Pydantic)
  429 Too Many Req   — rate limited

5xx Server errors:
  500 Internal Error — unexpected server error
  502 Bad Gateway    — upstream service failed
  503 Unavailable    — server overloaded or down
  504 Gateway Timeout — upstream timed out

401 vs 403: 401 = "who are you?" (no auth). 403 = "I know who you are, you can't do this."


Pagination

Offset pagination (simple, but slow at scale)

GET /orders?offset=1000&limit=20
→ SQL: SELECT * FROM orders LIMIT 20 OFFSET 1000
Problem: OFFSET 1000 scans and discards 1000 rows. Gets slower with large offsets.

Cursor pagination (senior answer — O(1) per page)

GET /orders?cursor=eyJpZCI6MTAwfQ&limit=20
→ SQL: SELECT * FROM orders WHERE id > {decoded_cursor} LIMIT 20
Response: { "data": [...], "next_cursor": "eyJpZCI6MTIwfQ" }

Pros: consistent, fast, no duplicates if data inserts between pages
Cons: can't jump to arbitrary page

Use cursor for: feeds, activity logs, large datasets. Use offset for: admin UIs where user jumps to page 50.


Versioning Strategies

# URL path versioning (most common, easy to route)
GET /v1/users
GET /v2/users

# Header versioning (cleaner URLs, harder to test in browser)
GET /users
Accept-Version: v2

# Query param (avoid — caching issues)
GET /users?version=2

URL path versioning is the standard. Use it unless you have a reason not to.


Error Response Format

Always consistent. Never return HTML for errors in an API.

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid email format",
    "field": "email",
    "request_id": "req_abc123"
  }
}

request_id is critical for debugging in production — correlates logs, traces, and user reports.


Authentication Patterns

JWT (most common in interviews)

Header: Authorization: Bearer <token>

Token = base64(header) + "." + base64(payload) + "." + signature
Payload: { "user_id": 123, "exp": 1716000000, "roles": ["admin"] }

Stateless — server verifies signature, no DB lookup needed.
Expiry: short-lived (15 min access token) + long-lived refresh token (7 days).

API Key (service-to-service)

Header: X-API-Key: your-key-here
Or query param: ?api_key=...  (avoid — appears in logs)

OAuth 2.0 (third-party auth — "Login with Google")

Authorization Code flow:
1. User redirected to Google
2. Google redirects back with code
3. Backend exchanges code for access token
4. Backend gets user info from Google

Rate Limiting Headers (standard)

X-RateLimit-Limit: 1000       # max requests per window
X-RateLimit-Remaining: 950    # remaining in current window
X-RateLimit-Reset: 1716000000 # UTC epoch when window resets
Retry-After: 30               # seconds until retry (on 429)

Interview Questions to Prepare

  • "How would you design a paginated API for a feed?" → Cursor-based, return next_cursor, client sends cursor on next call

  • "How do you make a payment API idempotent?" → Idempotency-Key header, store key+result in Redis with TTL

  • "What's the difference between 401 and 403?" → 401: not authenticated. 403: authenticated, not authorized.

  • "How do you version an API without breaking clients?" → URL versioning. Keep v1 alive. Deprecate with sunset header + migration docs.

  • "How would you design rate limiting for a public API?" → Token bucket per API key in Redis. Return 429 with Retry-After. See [[System Design/Problem Designs/Rate Limiter]]


Related

  • [[System Design/Problem Designs/Rate Limiter]] — rate limiting implementation
  • [[API Gateway]] — gateway-level concerns
  • [[Python/Libraries/FastAPI]] — FastAPI implements these patterns