Intermediate to senior

Backend Interview Prep

Fourteen chapters on HTTP and API design, SQL, indexing and transactions, NoSQL, authentication, caching, concurrency, messaging, resilience, deployment and observability, with tested SQL and Python.

Chapter 2 of 14Foundations · HTTP, REST and API Design

HTTP, REST and API Design

An API is a contract that outlives its implementation. Interviewers use API design questions to check that you understand HTTP semantics, can model resources, design for failure (retries, idempotency, errors) and plan for change (pagination, versioning). This chapter covers the protocol essentials and a checklist for designing a good HTTP API, with runnable examples for the tricky parts.

1. HTTP essentials

A request has a method, a URL, headers and an optional body. A response has a status code, headers and an optional body. HTTP is stateless: each request carries everything the server needs (credentials, parameters); any state lives on the client or in a store the server consults.

Methods

MethodMeaningSafe (no side effects)Idempotent (repeating has the same effect)
GETread a resourceyesyes
HEADheaders onlyyesyes
POSTcreate, or run an actionnono
PUTreplace a resource entirelynoyes
PATCHpartially updatenonot guaranteed
DELETEremovenoyes
OPTIONScapabilities (CORS preflight)yesyes

Idempotent means the end state is the same after one call or many, which is what makes retries safe. DELETE /orders/7 twice leaves the order deleted both times (the second response may be 404, but the state is unchanged). POST /orders twice may create two orders, so unsafe retries need an idempotency key (below).

Status codes

RangeMeaningCommon codes
2xx success200 OK, 201 Created (with Location), 202 Accepted (queued, not done), 204 No Content
3xx redirection301 permanent, 302/307 temporary, 304 Not Modified (cache validation)
4xx client errorthe request is wrong400 Bad Request, 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found, 405 Method Not Allowed, 409 Conflict, 410 Gone, 415 Unsupported Media Type, 422 Unprocessable Content (validation), 429 Too Many Requests
5xx server errorthe server failed500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout

The 401 versus 403 distinction is a classic: 401 means "I do not know who you are", 403 means "I know who you are and you may not do this". Use 4xx when the client should change the request and 5xx when the server is at fault; clients typically retry 5xx and 429 (with backoff) but not most 4xx.

Headers worth knowing

Content-Type and Accept (media types), Authorization, Cache-Control, ETag and If-None-Match / If-Match (conditional requests), Location, Retry-After, Idempotency-Key (a convention), X-Request-ID (tracing), CORS headers, Set-Cookie.

2. REST: resources, not actions

REST is an architectural style: resources identified by URLs, manipulated through a uniform interface (HTTP methods), with representations (usually JSON), stateless interactions, and cacheability. In practice, "RESTful" mostly means resource-oriented URLs with the right methods and status codes.

Naming

  • Nouns, plural, lowercase: /orders, /orders/42, /orders/42/items.
  • Hierarchy shows ownership: /users/7/orders. Avoid nesting deeper than two levels; use query parameters or top-level resources instead.
  • No verbs in the path for CRUD: POST /orders, not POST /createOrder.
  • Actions that are not CRUD can be modelled as a sub-resource or a state change: POST /orders/42/cancellation, or POST /payments/42/refunds, or PATCH /orders/42 { "status": "cancelled" }. Pick a convention and use it consistently.
  • Use query parameters for filtering, sorting, searching and pagination: GET /orders?status=open&sort=-created_at.
TaskRequest
ListGET /books?author=asha&limit=20
Read oneGET /books/42
CreatePOST /books returns 201 and Location: /books/42
ReplacePUT /books/42
Update partPATCH /books/42
DeleteDELETE /books/42 returns 204

3. Idempotency keys

For non-idempotent operations such as payments, a client that times out cannot know if the server processed the request. It retries with the same Idempotency-Key. The server stores the key with the result; a repeat returns the stored result instead of acting again.

import threading

class IdempotentProcessor:
    def __init__(self):
        self.results = {}             # idempotency key -> (request fingerprint, response)
        self.lock = threading.Lock()
        self.executions = 0

    def handle(self, key, request):
        with self.lock:               # a real system uses an atomic insert in the database instead
            if key in self.results:
                fingerprint, response = self.results[key]
                if fingerprint != request:
                    return 422, "idempotency key reused with a different request"
                return 200, response  # replay: same answer, no second side effect
            self.executions += 1
            response = {"charged": request["amount"], "id": f"pay_{self.executions}"}
            self.results[key] = (request, response)
            return 201, response

p = IdempotentProcessor()
first = p.handle("k1", {"amount": 500})
retry = p.handle("k1", {"amount": 500})
assert first[0] == 201 and retry[0] == 200 and first[1] == retry[1]
assert p.executions == 1                                      # the charge happened once
assert p.handle("k1", {"amount": 900})[0] == 422             # the same key with a different payload is rejected
assert p.handle("k2", {"amount": 500})[1]["id"] == "pay_2"   # a new key is a new operation

4. Pagination

Never return unbounded lists.

StyleRequestProsCons
Offset/limit?offset=40&limit=20simple, random accessslow for large offsets (the database scans and discards), unstable when rows are inserted or deleted during paging
Page number?page=3&size=20familiarsame issues as offset
Cursor (keyset)?after=<cursor>&limit=20fast at any depth, stable under changesno jumping to page N, needs a stable sort key

Keyset pagination filters on the last seen sort key instead of skipping rows: WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20. Include a tie-breaker (the id) so the order is total, and return an opaque cursor (base64 of the key) so clients do not depend on its structure.

import base64, json

def encode_cursor(created_at, row_id):
    return base64.urlsafe_b64encode(json.dumps([created_at, row_id]).encode()).decode()

def decode_cursor(cursor):
    return tuple(json.loads(base64.urlsafe_b64decode(cursor.encode())))

rows = [(10, 2), (10, 1), (9, 3), (8, 5), (8, 4), (7, 6)]            # (created_at, id), sorted descending by both
def page_after(cursor, limit):
    last = decode_cursor(cursor) if cursor else None
    items = [r for r in rows if last is None or r < last][:limit]    # tuple comparison matches (created_at, id) < last
    next_cursor = encode_cursor(*items[-1]) if len(items) == limit else None
    return items, next_cursor

page1, c1 = page_after(None, 2)
page2, c2 = page_after(c1, 2)
page3, c3 = page_after(c2, 2)
assert page1 == [(10, 2), (10, 1)] and page2 == [(9, 3), (8, 5)] and page3 == [(8, 4), (7, 6)]
assert page1 + page2 + page3 == rows                                # no duplicates and no gaps across pages
assert c3 is not None and page_after(c3, 2)[0] == []                # past the end: an empty page
assert decode_cursor(encode_cursor(8, 5)) == (8, 5)

5. Error responses

Return a consistent, machine-readable error body with the right status code. A widely used format is Problem Details (RFC 9457, formerly 7807):

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "The request body has invalid fields.",
  "errors": [{ "field": "email", "message": "must be a valid email address" }],
  "request_id": "7f3b9c"
}

Include a request ID for support and tracing. Never leak stack traces, SQL or internal hostnames. Return all validation errors at once, not one at a time. Make error codes stable and documented, because clients branch on them.

6. Versioning and evolution

  • Prefer additive changes: new optional fields, new endpoints. Clients should ignore unknown fields (the tolerant reader principle).
  • Breaking changes (removing or renaming fields, changing types or meaning) need a new version: in the path (/v2/orders), a header, or a media type. Path versions are the easiest to route, cache and debug.
  • Deprecate with notice: a Deprecation/Sunset header, documentation, and usage metrics so you know who is still on the old version.
  • Schema contracts: OpenAPI documents, contract tests, and consumer-driven contract testing in larger organisations.

7. Filtering, sorting, searching and partial responses

  • Whitelist sortable and filterable fields; reject unknown ones. Never pass raw field names into SQL.
  • A - prefix for descending (sort=-created_at) is a common convention.
  • Provide field selection (fields=id,name) or expansion (include=customer) to avoid over-fetching and avoid N+1 client calls. GraphQL addresses the same problem differently.

8. Caching and conditional requests

GET responses can be cached by browsers, CDNs and proxies. Use Cache-Control (max-age, private versus public, no-store for sensitive data) and validators: an ETag is a fingerprint of the representation. The client sends If-None-Match: "abc"; if nothing changed, the server replies 304 Not Modified with no body, saving bandwidth. For updates, If-Match gives optimistic concurrency: the update succeeds only if the resource still has the ETag the client read; otherwise 412 Precondition Failed (or 409).

import hashlib, json

def etag(resource):
    return '"' + hashlib.sha256(json.dumps(resource, sort_keys=True).encode()).hexdigest()[:12] + '"'

store = {"id": 1, "title": "Draft", "version": 1}
def put(resource, if_match):
    global store
    if if_match != etag(store):
        return 412                                   # someone else changed it since you read it
    store = {**resource, "version": store["version"] + 1}
    return 200

tag_alice = etag(store)                              # both users read the same version
tag_bob = etag(store)
assert put({"id": 1, "title": "Alice's edit"}, tag_alice) == 200
assert put({"id": 1, "title": "Bob's edit"}, tag_bob) == 412    # Bob's write would silently overwrite Alice's, so it is refused
assert store["title"] == "Alice's edit"

9. REST, GraphQL, gRPC and webhooks

StyleStrengthsTrade-offs
REST/JSONsimple, cacheable, universal toolingover- and under-fetching; many round trips for rich screens
GraphQLclient chooses fields, one request for a graphcaching harder, query cost control, N+1 resolvers, more complexity
gRPC (HTTP/2 + Protocol Buffers)compact, typed, streaming, fast service-to-servicebrowser support needs a proxy, less human-readable
WebSocket / SSEserver push, real timestateful connections, scaling
Webhooksthe server calls the client when events happenthe receiver must verify signatures, handle retries and idempotency

Public APIs lean on REST; internal service-to-service traffic often uses gRPC; client-heavy products sometimes add GraphQL as a backend-for-frontend.

10. A checklist for a good API

  • Resource-oriented URLs, correct methods and status codes.
  • Input validation and consistent error bodies.
  • Authentication, authorisation on every endpoint (check object-level access, not only roles).
  • Pagination and limits on every list; maximum request sizes.
  • Idempotency for unsafe operations; safe retries.
  • Rate limiting with 429 and Retry-After.
  • Versioning and deprecation policy; documentation (OpenAPI).
  • Timeouts and request IDs; structured logs and metrics.
  • Backwards-compatible evolution.

11. Common mistakes

  • Verbs in URLs and everything as POST.
  • 200 OK with an error in the body.
  • Using 401 for authorisation failures, or 500 for validation errors.
  • Unbounded lists and offset pagination on huge tables.
  • Non-idempotent retries (double charges).
  • Leaking internals in error messages.
  • Breaking changes in place.
  • Trusting client-supplied IDs or fields (mass assignment: letting a client set isAdmin or price).
  • Checking authentication but not object ownership (an insecure direct object reference).

12. Practice questions

  1. What is the difference between PUT and PATCH? Which methods are idempotent?
  2. 401 versus 403 versus 404 for a resource the user may not see: which do you return, and why?
  3. Design the API for a ride-booking service: resources, endpoints, status codes.
  4. How do you make POST /payments safe to retry?
  5. Offset versus cursor pagination: trade-offs and when would you use each?
  6. How would you version an API and retire the old version?
  7. How do ETags help with caching and concurrent updates?
  8. When would you choose GraphQL or gRPC over REST?
Header Logo