#Designing an API in the interview
AddedThe API section is where the interview turns from "what should it do" into "what exactly goes in and comes out". It takes three to five minutes, right after the requirements and the estimates, and it does more work than it looks: it fixes the nouns of the system (which become tables and services), the operations (which become the read and write paths you'll draw), and the promises you make to callers (what happens on retry, how lists are paged, what errors look like). An interviewer who sees a clean API knows you've understood the problem before you draw a single box.
A good mental model: the API is the menu, not the kitchen. It lists what a customer can order and what they'll get back, in terms the customer understands. It says nothing about which chef cooks it, which fridge the ingredients came from or how many ovens there are. If your API leaks the kitchen (POST /runSqlQuery, GET /shard3/users), you can never rebuild the kitchen without breaking every customer. Name things after the domain, keep the storage behind the menu.
In practice you write one line per functional requirement: the method, the path, the important parameters, what comes back, and the status codes that matter. You don't write full JSON schemas; you write enough that the data model and the hard parts (pagination, idempotency, auth) are visible.
Here's what that looks like on the whiteboard for a photo-sharing app whose requirements were "post a photo, see a feed of people I follow, like a photo":
POST /v1/photos create a photo record, get an upload URL
body { caption, contentType } → 201 { photoId, uploadUrl } + Location: /v1/photos/{photoId}
header Idempotency-Key: <uuid>
GET /v1/photos/{photoId} → 200 { photoId, ownerId, caption, url, likeCount, createdAt }
→ 404 if missing or not visible to the caller
GET /v1/feed?limit=20&cursor=<opaque> → 200 { items: [photo…], nextCursor | null }
PUT /v1/photos/{photoId}/likes/me like (idempotent: liking twice is still one like) → 204
DELETE /v1/photos/{photoId}/likes/me unlike → 204
All calls: Authorization: Bearer <token> · errors: { type, title, detail } · 429 + Retry-After when limited- Resources are nouns, operations are HTTP methods.
photos,feedandlikesare things; creating, reading and deleting them arePOST,GET,PUTandDELETE. There's no/createPhotoor/getFeed: the method already says what happens, so the path only has to say to what. POST /v1/photosdoesn't carry the image bytes. It creates the metadata row and returns a pre-signeduploadUrl, so the client uploads straight to object storage. That one line tells the interviewer you've thought about large uploads not flowing through your API servers. It returns201 Createdwith aLocationheader pointing at the new resource, and it takes anIdempotency-Keyheader, because a mobile client on a bad network will retry it (see Idempotency keys for APIs).- The feed is paged with an opaque cursor, not a page number.
limitis bounded by the server, and the response carriesnextCursor(ornullat the end). Writingcursorhere signals that you know offsets break on a feed that changes while it's read (see Pagination at the API level). - Liking is modelled as a resource you
PUT, not an action youPOST.PUT /likes/memeans "make it so that I like this photo"; doing it twice leaves the same state, so retries and double taps are harmless.POST /photos/{id}/likewould usually be implemented as "add a like", and a retry would add two. Choosing the method is choosing the retry behaviour. - Cross-cutting rules go on one line at the bottom: auth in a header (never in the URL, where it ends up in logs), one error shape for every endpoint, and
429withRetry-Afterfor rate limiting. The/v1prefix says there's a versioning story (see API versioning and backwards compatibility). - From these five lines the interviewer can already infer the data model (
photos,follows,likes(photoId, userId)with a unique pair) and the two paths worth a deep dive: the feed read path and the write path for likes on a hot photo. That's the job of the API section.
The conventions that make an API predictable, and that interviewers notice when they're missing:
- Plural nouns for collections, ids for members:
/photos,/photos/{photoId}. One style everywhere; don't mix/photo/7and/users/7. - Nest only to express ownership, and only one or two levels:
/users/{userId}/photosis fine;/users/{u}/albums/{a}/photos/{p}/comments/{c}is a sign thatcomments/{c}should be a top-level resource with its own id. - Filters, sorting and paging are query parameters, not path segments:
/photos?ownerId=7&sort=-createdAt&limit=20. - Actions that aren't CRUD are modelled as a sub-resource or a state change:
POST /orders/{id}/cancellationorPATCH /orders/{id} { status: "cancelled" }. A plain verb likePOST /orders/{id}/cancelis a common, acceptable compromise; say it's deliberate. - Ids are opaque strings (
"ord_8Hx2"), not auto-increment integers exposed to the world: integers leak your volume and invite enumeration, and they're hard to keep unique once you shard. - Timestamps in UTC with an offset (ISO 8601,
2026-10-24T14:03:00Z), money as integer minor units plus a currency ({ "amount": 4200, "currency": "cad" }), never floats.
HTTP defines two properties per method, in RFC 9110 (HTTP Semantics, 2022). Safe means the client isn't asking for any change (GET, HEAD, OPTIONS). Idempotent means sending the same request twice has the same effect on the server as sending it once (the safe methods plus PUT and DELETE). Idempotency is what makes automatic retries possible: browsers, proxies, SDKs and service meshes retry idempotent requests on a broken connection, and they must not retry POST blindly.
| Method | Safe | Idempotent | Typical use | Usual success codes |
|---|---|---|---|---|
GET | yes | yes | read a resource or a list | 200, 304 (not modified) |
POST | no | no | create in a collection, start a process | 201 + Location, 202 (async) |
PUT | no | yes | create or fully replace at a known id | 200, 204, 201 if it created |
PATCH | no | not by definition | partial update | 200, 204 |
DELETE | no | yes | remove | 204, 202 |
PATCH is idempotent or not depending on what the patch says: "set qty to 3" is, "add 1 to qty" isn't.
| Code | Meaning | When to use it |
|---|---|---|
200 / 201 / 204 | OK / Created / No Content | read or update / created (with Location) / success with no body |
202 | Accepted | work queued; return a job resource to poll, e.g. Location: /v1/exports/91 |
304 | Not Modified | conditional GET with If-None-Match matched the ETag |
400 | Bad Request | malformed input the client must fix |
401 | Unauthorized | not authenticated: no or bad credentials |
403 | Forbidden | authenticated, but not allowed |
404 | Not Found | doesn't exist, or the caller mustn't know it exists |
409 | Conflict | state conflict: duplicate, or a concurrent request with the same idempotency key |
412 | Precondition Failed | If-Match with a stale ETag: someone else updated it first (optimistic locking) |
422 | Unprocessable Content | well-formed but semantically invalid (amount negative, key reused with a different body) |
429 | Too Many Requests | rate limited; send Retry-After |
500 / 502 / 503 / 504 | server error / bad upstream / overloaded or down / upstream timeout | 503 + Retry-After for planned load shedding |
Use one error body everywhere. RFC 9457 (Problem Details for HTTP APIs, 2023, which replaced RFC 7807) defines a JSON shape with type, title, status, detail and instance; it's a good default to name in an interview.
The reason the method matters in a design interview is retries. This model applies every request on the server, then loses the first response, so the client retries exactly as a mobile app or an SDK would:
// Every request reaches the server and is applied, but the first response is lost.
// The client times out and sends the same request again. Which calls are safe to retry?
function freshServer() {
const orders = new Map([[1, { id: 1, sku: "BOOK", qty: 1 }]]);
let nextId = 2;
const handle = (method, path, body) => {
const id = Number(path.split("/")[2]);
if (method === "GET") return orders.has(id) ? 200 : 404;
if (method === "PUT") { orders.set(id, { id, ...body }); return 200; }
if (method === "DELETE") return orders.delete(id) ? 204 : 404;
if (method === "PATCH") { orders.get(id).qty += body.add; return 200; }
if (method === "POST") { orders.set(nextId, { id: nextId++, ...body }); return 201; }
};
const snapshot = () => [...orders.values()].map((o) => `#${o.id}×${o.qty}`).join(" ") || "(empty)";
return { handle, snapshot };
}
const calls = [
["GET", "/orders/1"],
["PUT", "/orders/1", { sku: "BOOK", qty: 3 }],
["DELETE", "/orders/1"],
["PATCH", "/orders/1", { add: 1 }],
["POST", "/orders", { sku: "PEN", qty: 1 }],
];
for (const [method, path, body] of calls) {
const once = freshServer();
once.handle(method, path, body);
const twice = freshServer();
const first = twice.handle(method, path, body); // response lost on the way back
const retry = twice.handle(method, path, body); // the client's retry
const same = once.snapshot() === twice.snapshot();
console.log(
`${method.padEnd(6)} once: ${once.snapshot().padEnd(14)} with retry: ${twice.snapshot().padEnd(14)}` +
` statuses ${first}→${retry} ${same ? "safe to retry" : "NOT safe"}`,
);
}GET once: #1×1 with retry: #1×1 statuses 200→200 safe to retry
PUT once: #1×3 with retry: #1×3 statuses 200→200 safe to retry
DELETE once: (empty) with retry: (empty) statuses 204→404 safe to retry
PATCH once: #1×2 with retry: #1×3 statuses 200→200 NOT safe
POST once: #1×1 #2×1 with retry: #1×1 #2×1 #3×1 statuses 201→201 NOT safe- Each row builds two fresh servers holding order
#1withqty1. On the first it sends the request once; on the second it sends it, "loses" the response, and sends it again. If the two final states match, a blind retry is harmless. GETchanges nothing, so it's trivially safe.PUTwith{ qty: 3 }says "the order is this", so applying it twice still leaves#1×3. That's whyPUTto a client-chosen id is the classic way to make a create idempotent.DELETEends in the same state (empty) either way, so it's idempotent, but notice the status:204then404. Idempotency is about the effect on the server, not the response. A client that treats404on a retriedDELETEas an error will report a failure for something that succeeded; treat it as success.PATCH { add: 1 }is a relative change: once givesqty2, with a retry it's 3. It's the same bug as "increment a counter" over an unreliable network. The fix is either an absolute patch ({ qty: 2 }) or a condition (If-Matchwith theETagyou read, so the retry gets412).POSTcreates a new order every time: one call gives#2, a retry gives#2and#3. For orders or payments that's a double charge. This is the gap idempotency keys close: the client sends the same key with the retry, and the server returns the first result instead of acting again.
- Write the API before the boxes. It's cheap, it forces the nouns, and every later box has to serve one of these lines. If a box serves none of them, ask why it's there.
- Mark the dangerous endpoints. Point at the
POSTs that move money or send messages and say "this needs an idempotency key". Point at the list endpoints and say "cursor-paginated, max 100 per page". - Async work returns
202. Video transcoding, exports and bulk imports return a job resource immediately; the client polls it, gets a webhook, or listens on a stream. - Optimistic concurrency with
ETag+If-Match(→412) prevents lost updates when two editors save the same document; mention it whenever two people can edit one thing. - Don't over-design it. No HATEOAS links, no full schemas, no 30 endpoints. Five to eight lines covering the functional requirements is right for a 45-minute round.
A client calls POST /v1/exports { videoId }. The export takes about four minutes. What should the API return, and how does the client find out it's finished?
Show answer
202 Accepted with a job resource: Location: /v1/exports/exp_91 and a body like { "exportId": "exp_91", "status": "queued" }.
201 Createdwould claim the export exists and is complete; it isn't.200with a four-minute wait would hold a connection and an HTTP worker for four minutes and hit proxy timeouts (60 s by default on many load balancers).202 Acceptedmeans "I've taken the request; the work happens later". TheLocationheader gives the client a resource to check.- The client then learns about completion in one of three ways: it polls
GET /v1/exports/exp_91(simple; useRetry-Afterto pace it), it registers a webhook that firesexport.completed, or a connected UI listens on an SSE or WebSocket stream for the job's status. - Because the create is a
POST, it should also take anIdempotency-Key, so a retry after a timeout doesn't start a second four-minute export.