Skip to content
Tech Interview Prep home
Technical interview guide

API Design & REST Fundamentals

Designing HTTP APIs that are predictable to call and safe to retry — resource modeling, status codes, versioning, and idempotency.

Read
23 min
Practice MCQs
25
Interview QA
25
Edition
v3
Editorial status
Reviewed

Scope: HTTP Semantics RFC 9110 and OpenAPI Specification 3.2.

Overview

Curated: · Written: · Reviewed:

REST is a set of constraints, and most of the value is in a few of them

REST is an architectural style whose constraints — a uniform interface, statelessness, cacheability, a client–server separation, layering — produce systems that are independently evolvable and scalable. Most working "REST APIs" adopt some of these and not others, which is fine as long as it is deliberate. What is not fine is treating REST as a synonym for "JSON over HTTP", because the constraints that get dropped are usually the ones providing the value.

The practical core is: model resources, use HTTP's semantics as defined rather than as convention, and do not keep client state on the server.

Resources, not actions

A resource is a thing — an order, a user, a subscription — addressed by a URI. Methods act on it. So the URI names a noun and the method supplies the verb: POST /orders, GET /orders/123, DELETE /orders/123. POST /createOrder puts the verb in the URI and discards the semantics HTTP already provides.

Not everything is naturally a noun, and forcing it produces worse designs than accepting the exception. Cancelling an order is genuinely an action. The options are modelling the state change (PATCH /orders/123 with {"status": "cancelled"}), modelling the action as a resource (POST /orders/123/cancellations), or simply accepting an action endpoint (POST /orders/123/cancel) where those are contorted. The third is common in good APIs and is better than a noun nobody would use in conversation.

Collections and members give the natural hierarchy: /orders is a collection, /orders/123 a member, /orders/123/lines a sub-collection. Nesting beyond two levels usually signals that the nested thing deserves a top-level resource with a filter instead — /lines?orderId=123.

The resource model should expose relationships, not storage shape. /orders/123/lines is fine because order lines have no meaning outside their order; /users/45/orders is fine because ownership is a real relationship. But /orders/123/customer/addressBook/entries leaks an internal object graph — GET /address-book?customerId=… says the same thing without coupling the API to the database's foreign keys. A good test: if a rename in the datastore would force a URI change, the storage shape has leaked.

Methods and their properties

GET retrieves and is safe: the client does not request a state change. Servers may still perform incidental effects such as logging or metrics, but a GET must not be used to request a business mutation. HEAD is GET without the body, and inherits safety. PUT replaces and is idempotent. DELETE removes and is idempotent. POST creates or acts, is not safe, and is not inherently idempotent, though an API can add idempotency-key semantics. PATCH applies a partial modification and is not necessarily idempotent, though it can be designed to be.

methodsafeidempotent
GET, HEADyesyes
PUT, DELETEnoyes
POSTnono
PATCHnonot necessarily

These properties are not documentation — they are a contract intermediaries rely on. Browsers, proxies and caches may prefetch or retry a GET because it is safe. Automatic retry of a failed request is only sound for idempotent methods. A GET that changes state will eventually be triggered by a link prefetcher or a crawler, and that failure is entirely self-inflicted.

Idempotency means the end state is the same however many times the request is applied — not that the response is identical. DELETE /orders/123 returns 204 the first time and 404 afterwards; the resource is absent either way, so it is idempotent.

Idempotency as a delivered behavior

For unsafe methods, idempotency has to be built. The standard pattern is an idempotency key: the client generates a unique key per logical operation (typically a UUID), sends it in a header, and the server stores the key with the response before executing side effects.

POST /charges HTTP/1.1
Idempotency-Key: 8a2f3c1e-9b4d-4f6a-a1b2-c3d4e5f60718
Content-Type: application/json

{"amount": 1999, "currency": "usd", "source": "card_4419"}

The design decisions that matter:

  • Key scope: the key is scoped to the operation and the account, not global. Two different accounts using the same key string must not collide.
  • Store before execute: persist the key and request hash first, so a crash mid-execution leaves a record that a retry can reconcile rather than double-charge.
  • Replay, not re-execution: on a repeat request with the same key, return the stored response, marked with a header like Idempotent-Replay: true. Do not run the handler again.
  • Mismatched payload, same key: if the body differs from the stored one, return 422 rather than silently returning the old response — the client has a bug.
  • Concurrency: two in-flight requests with the same key need a lock or a unique constraint; the second should block until the first completes (or get 409 if your contract prefers fail-fast).
  • TTL: keys expire — 24 hours is a common window — and the expiry is documented so clients know how long retry protection lasts.

Client-side, retries need exponential backoff with jitter, because a fleet of clients retrying on a fixed interval re-creates the thundering herd that caused the failure:

# Python 3.12
import random, time

def retry(fn, attempts=5, base=0.5, cap=30.0):
    for i in range(attempts):
        try:
            return fn()
        except TransientError:
            if i == attempts - 1:
                raise
            sleep = min(cap, base * 2**i) + random.uniform(0, base)
            time.sleep(sleep)

Status codes

Use the standard meanings rather than inventing conventions:

  • 200 success with a body; 201 created, with a Location header pointing at the new resource; 202 accepted for asynchronous processing; 204 success with no body.
  • 400 malformed request; 401 not authenticated; 403 authenticated but not permitted; 404 not found; 409 conflict with current state; 422 well-formed but semantically invalid; 429 rate limited, with Retry-After.
  • 500 server fault; 503 temporarily unavailable, ideally with Retry-After.

Choose by outcome, not habit. The rough split is that 4xx points at the client and 5xx at the server, but it is a heuristic, not a retry rule: 429 is a 4xx where retrying the identical request after Retry-After is exactly right, and the 404 you get from repeating a successful DELETE is fine too. What actually decides retry behavior is whether the failure is transient and whether the request is idempotent — 503 with Retry-After is retryable, 400 is not, and 429 is only after the stated delay. Two distinctions matter disproportionately. 401 versus 403: 401 means "authenticate and try again", 403 means "authenticating differently will not help". Clients behave differently, so conflating them causes retry loops. And 400 versus 422: syntactically broken versus semantically wrong. Returning 200 with an error in the body is the worst option — it defeats every intermediary, every generic client, and every monitoring system that counts non-2xx responses.

The error body should be a stable envelope a client can parse once and reuse everywhere. RFC 9457 problem details is the standard shape:

{
  "type": "https://api.example.com/errors/order-not-cancellable",
  "title": "Order cannot be cancelled",
  "status": 409,
  "detail": "Order 123 has already shipped.",
  "instance": "/orders/123/cancellation",
  "request_id": "req_7f3a9c",
  "errors": [
    {"field": "status", "code": "already_shipped"}
  ]
}

Every field earns its place: type is a stable machine-readable code the client switches on, detail is the human message, errors gives field-level detail for form rendering, request_id lets support connect a user's report to a server log, and type doubles as the documentation link. A client that can programmatically distinguish "retry in 30 seconds" from "fix your payload" needs nothing bespoke per endpoint.

Statelessness

Each request carries everything needed to process it; the server keeps no client session state between requests. That is what allows any instance to serve any request, which in turn allows horizontal scaling and painless instance replacement.

Application state — the user's data — obviously lives on the server. What must not is client interaction state, such as a multi-step wizard's progress held in server memory keyed by a session. Where progress must live on the server, model it as persistent application resource state in a datastore all instances can reach; otherwise keep interaction state in the client or a signed token.

Versioning and evolution

Every published API is a contract with clients you may not control. The goal is to avoid breaking changes rather than to version elegantly.

Additive changes are safe: new optional fields, new endpoints, new optional parameters — provided clients ignore unknown fields, which should be stated in the documentation as a requirement. Breaking changes are removing or renaming a field, changing a type, adding a required parameter, or changing semantics.

When a breaking change is unavoidable: version in the URI (/v2/orders) — ugly and completely unambiguous, which is why it is the most common; or in a header, which is cleaner and harder to debug and cache. Either way, run both versions concurrently, communicate a deprecation timeline, and measure actual usage of the old version rather than assuming clients migrated when they said they would.

Pagination, filtering, and partial responses

Collections must be paginated from the start, because adding it later is itself a breaking change. Cursor-based pagination is preferable to offsets for anything large or frequently changing: offsets get slower with depth — OFFSET 100000 makes the database scan and discard 100,000 rows — and skip or duplicate rows when items are inserted between requests. Return the page and an opaque cursor for the next one:

{
  "data": [/* up to 50 orders */],
  "next_cursor": "eyJvcmRlcmVkX2F0IjoxNzAwMDAwMDAwLCJpZCI6MTIzfQ"
}

The cursor encodes the sort key of the last item — here, a base64 of (ordered_at, id) — so the next query is WHERE (ordered_at, id) < (last.ordered_at, last.id) ORDER BY ordered_at DESC, id DESC LIMIT 50. The composite key matters: ordered_at alone is not stable, because two orders can share a timestamp, and an unstable sort key makes cursors skip or repeat rows. That is the keyset pattern, and it costs an index on (ordered_at, id) — the trade-off for page-constant latency at any depth.

Offset pagination is fine for small, static, admin-facing collections where "jump to page 7" is a real requirement; cursors cannot do random access. Know which you need before defaulting.

Filtering, sorting and field selection belong in query parameters — GET /orders?status=paid&sort=-ordered_at&fields=id,status,total&limit=50 — and bounds on all of them belong in the server, so no client can request something unbounded. Field selection (fields=) costs per-response work but saves payload for mobile clients; on hot datasets it can be worth caching the projection logic rather than computing it per request.

What makes an API good in practice

Consistency — of naming, of pagination, of error shape, of date formats — matters more than any individual choice, because a client that learns one endpoint should be able to predict the next. A machine-readable description (OpenAPI) keeps documentation, validation and generated clients derived from one source rather than drifting apart. And every endpoint should have limits: page sizes, request body sizes, and rate limits, with 429 and Retry-After so a well-behaved client can back off correctly.

What interviewers probe

The REST question is usually a design exercise — "design an API for X" — and the interviewer is grading whether your choices are reasoned rather than recited. Expect these follow-ups:

  • "Why not just POST everything?" They want the safety/idempotency matrix and what intermediaries do with it. A weak answer describes methods as naming conventions; a strong one says who depends on the property — caches prefetch GETs, retry logic needs idempotency, proxies won't cache unsafe responses.
  • "Your client's POST timed out. Did the charge happen?" This is the idempotency-key question. A weak answer says "the client can check"; a strong one says the client can't know, which is why the key is sent before the ambiguity, and how the server stores the response for replay.
  • "A crawler triggered your GET and deleted data. Whose fault is it?" They want you to say the API designer's, without hedging.
  • "How do you paginate a feed with heavy concurrent writes?" They want offsets' failure modes named concretely — duplicates and skips under insertion — and the keyset fix with a stable composite sort key.
  • "How do you deprecate a field without breaking clients?" They want additive-first thinking, unknown-field tolerance, usage measurement, and a timeline.

A weak answer overall sounds like a style guide recited from memory: correct verbs, correct codes, no trade-offs. A strong answer names the constraint, the value it buys, what it costs, and when you would drop it deliberately.

Worked example: POST /charges without an idempotency key

Client times out after the server charged card 4419, then retries the same POST.

clientcharges
no Idempotency-Key2
Idempotency-Key charge-4419, stored before capture1
GET /charges/4419 after 2011 (safe replay of a GET)

POST is not idempotent. The key is the business identity of the attempt, not the HTTP connection.