Start here: the client you can't take back#
TL;DRthe 30-second version
- An API is a contract with clients you don't control. Once they integrate, every field name, status code, page size, and error shape is something you've promised to keep. Breaking it breaks them.
- Model the data as resources (nouns) and act on them with HTTP verbs. GET, PUT, and DELETE are idempotent β safe to retry β and POST is not, which is exactly why a POST that moves money needs an idempotency key.
- Paginate with a cursor, not offset/limit. Offset gets slower the deeper you go and skips or duplicates rows when the data changes underneath the reader. A cursor points at a row, so it stays correct and fast.
- Version so you can change things without breaking old clients β and accept the real cost: you now have to keep the old version running.
- Return consistent status codes plus a machine-readable error body (a stable code, not just a human message), so a client can react to the kind of error in code.
You ship an API. A mobile app integrates against it, then a partner's backend, then a script someone wrote two years ago and forgot. None of that code is yours. You can't step through it, you can't redeploy it, and you often can't even contact whoever wrote it. Whatever your API did on the day they integrated is what their code now depends on.
That's what makes API design different from designing a function. A function has one caller β you β and you can change both sides at once. An API has callers you'll never meet, and they only ever see the edge: the URLs, the request and response shapes, the status codes, the headers. Get that edge wrong and you either break clients or freeze the design forever. The whole job is to shape that edge so it survives change, retries, and scale without either breaking the client or trapping you.
Model resources, then pick the verb#
The first decision is how to name things. The style most web APIs follow is REST, which has one central idea: the things your API exposes are resources β nouns with a stable address. A user is a resource at `/users/42`. That user's orders are a resource at `/users/42/orders`. You don't invent a verb-shaped URL like `/getUserOrders`; the noun is the URL, and what you do to it is the HTTP method.
That split β noun in the path, verb in the method β is what makes a REST API predictable. A client that has never read your docs can still guess that `GET /users/42` reads user 42 and `DELETE /users/42` removes it, because the meaning lives in the method, and the methods are the same for every resource. The verbs are a small, fixed vocabulary defined by HTTP itself.
Two properties of those verbs decide how clients can treat them, and they're the ones interviewers reach for. A method is safe if calling it changes nothing on the server β a pure read. A method is idempotent if making the same call twice has the same effect as making it once. Safe implies idempotent (a read changes nothing, so any number of reads is the same as one), but not the reverse: deleting a resource changes the server, yet deleting it twice lands in the same final state as deleting it once, so DELETE is idempotent without being safe.
Here's the table every client relies on, defined by the HTTP spec (RFC 9110). It's worth knowing cold, because it's what tells a client whether a timed-out request is safe to retry.
| Method | Safe (a pure read) | Idempotent (retry-safe) | Typical use |
|---|---|---|---|
| GET | Yes | Yes | Read a resource |
| HEAD | Yes | Yes | Read just the headers |
| PUT | No | Yes | Replace the resource at a known id |
| DELETE | No | Yes | Remove a resource |
| POST | No | No | Create a resource, or trigger an action |
| PATCH | No | Not guaranteed | Partially update a resource |
Read the POST row carefully, because it's the source of a whole topic. POST is neither safe nor idempotent: `POST /charges` creates a new charge every time it runs. So if a client sends a charge, times out, and retries, a naive server charges the card twice. That's not a REST detail β it's the exact problem the Idempotency & Retries topic (/idempotency) solves, and it's why a money-moving POST carries a client-chosen idempotency key that lets the server recognize the retry and reply with the original result instead of charging again. The verb table is where you first see that a POST is the dangerous one.
Go deeperUnder the hood: HATEOAS, and why almost nobody ships it
The Richardson Maturity Model grades how RESTful an API is in three levels: level 1 introduces resources (nouns as URLs), level 2 adds correct use of HTTP verbs and status codes, and level 3 adds HATEOAS β Hypermedia As The Engine Of Application State. That last one means every response includes links to what you can do next, the way a web page has links, so a client navigates by following links instead of hard-coding URLs.
HATEOAS is the original REST vision from Roy Fielding's 2000 dissertation, the document that defined REST. In practice almost no popular API implements it fully. The promise β clients that discover the API at runtime and never break when URLs change β rarely pays off, because real clients hard-code the URLs anyway and the extra link machinery is a lot of work for little gain. So most APIs people call REST are really level 2: resources plus verbs plus status codes, and no hypermedia. Knowing that level-2 is the honest industry norm β and that the textbook definition goes one level further β is exactly the nuance an interviewer is probing for.
Pagination that survives a moving dataset#
No client wants a million rows in one response, so a list endpoint returns a page at a time. The obvious way is offset/limit: `GET /events?limit=20&offset=40` means "skip 40 rows, give me the next 20." It's easy to build and it works fine on a small, still dataset. It breaks in two ways once the dataset is large and changing.
The first break is speed. To answer `offset=1000000&limit=20`, the database can't jump straight to row 1,000,000 β it has to walk through and discard the first million rows to reach the ones you want. The cost of a page grows with how deep you are, so the last pages of a big list get slower and slower. Fetching page one is cheap; fetching page fifty-thousand scans a million rows to hand back twenty.
The second break is correctness, and it's the subtle one. Offset counts positions, but positions shift when rows are inserted or deleted. Say a reader pulls page one (rows 1β20), and before they ask for page two, someone inserts a new row at the top. Every row shifts down by one. Now `offset=20` points at the row that used to be at position 20 β the reader already saw it, so it appears again as a duplicate. Delete a row instead and the opposite happens: everything shifts up, `offset=20` skips over a row the reader never sees. On a busy feed with constant writes, offset pagination silently skips and duplicates rows.
The fix is cursor pagination (also called keyset pagination). Instead of "skip N rows," the client says "give me the rows after this specific one." The cursor names a position by the value of the row it left off at β for a feed sorted newest-first, that's the last row's sort key. The next page is a query like `WHERE (created_at, id) < (last_seen_created_at, last_seen_id) ORDER BY created_at DESC, id DESC LIMIT 20`.
Two things fall out of this. Because the query filters on an indexed column instead of counting rows, every page costs the same whether you're on page one or page fifty-thousand β the database seeks to the cursor's position and reads twenty rows. And because the anchor is a row's own value, not a moving count, inserting or deleting rows elsewhere can't shift what comes after the cursor, so no skips and no duplicates. Cursor pagination fixes both offset breaks at once.
PredictYour events feed has 50 million rows and gets thousands of inserts a minute. A client is deep in the list β logically "page 100,000" of 20. With offset/limit, what does the database do, and what can go wrong with the results? What changes with a cursor?
Hint: Offset = skip-then-read. How many rows must it walk to reach that page, and what do the constant inserts do to the row positions?
With offset/limit, page 100,000 means offset = 2,000,000. The database has to walk and discard 2,000,000 rows to reach the 20 you asked for, so the query is slow and gets slower the deeper the client goes β a full index or table scan of two million rows per page. Worse, the thousands of inserts a minute keep shifting every row's position while the client paginates: a row inserted above the reader's spot pushes everything down, so the next offset re-shows a row already seen (duplicate), and a delete pushes everything up, so the next offset jumps past a row (skip). With a cursor, the client sends "after (created_at, id) = <last row I saw>". The database uses the index to seek straight to that position and read 20 rows β the same cost on page 1 and page 100,000 β and because the anchor is the row's own value, inserts and deletes elsewhere don't move it, so the page is stable: no skips, no duplicates. That's why every high-scale feed (Stripe, Slack, Twitter-style timelines) paginates by cursor, not offset.
Go deeperUnder the hood: what's actually inside the cursor token
APIs hand back the cursor as an opaque token β a string like `eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0yMFQxMDozMCIsImlkIjo4ODEyfQ` that the client is told to treat as a black box: send it back to get the next page, don't try to read it. Inside, it's usually just the sort key of the last row β say `{created_at: '2026-07-20', id: 8812}` β encoded (often base64) into one string. Making it opaque is deliberate: clients can't come to depend on its internal shape, so you can change what you pack into it later without breaking them.
There's a safety angle too. If the cursor is plain base64, a client could decode it, change the id, and re-encode it to peek at rows it shouldn't. So some APIs sign the cursor (attach a short cryptographic tag computed from the contents and a server secret) or encrypt it, so a tampered cursor is rejected. That's the same tamper-resistance idea used elsewhere for signed tokens β the server can tell whether the token it got back is one it actually issued.
One honest limitation: a pure cursor doesn't know the total count or how to jump to an arbitrary page, because it only knows "the row after this one." That's why cursor APIs give you next/previous, not numbered pages 1..N. For an endless-scroll feed that's exactly right; if a product genuinely needs "jump to page 500 of 800," that's the rare case where offset (or a separate count query) earns its cost.
Errors clients can act on#
Half of what a client's code does is handle the request that didn't work. So the error path is part of the contract, not an afterthought. Two rules make errors usable: use the HTTP status code the way it's meant, and put a machine-readable reason in the body.
The status code is the coarse signal, and clients branch on its class. The 2xx range means success. The 4xx range means the client's request was wrong β 400 for a malformed request, 401 for "you're not authenticated," 403 for "authenticated but not allowed," 404 for "no such resource," 429 for "you're being rate-limited." The 5xx range means the server broke β 500 for an unexpected failure, 503 for "temporarily unavailable." The line between 4xx and 5xx is the one that matters most: a 4xx is the client's fault and retrying the same request won't help, while a 5xx is the server's fault and might succeed on retry. Return the wrong class and clients either retry things that will never work or give up on things that would have.
The status code alone is too coarse, though. A 400 tells the client "bad request" but not which field, or which of a dozen validation rules failed. So a well-designed API also returns a structured error body with a stable, machine-readable code. Stripe's shape is the canonical one: a `type`, a short string `code` like `card_declined`, a human-readable `message`, and often the specific `param` that was wrong.
HTTP/1.1 402 Payment Required
{
"error": {
"type": "card_error",
"code": "card_declined", // stable β branch on this in code
"param": "card_number", // which field was wrong
"message": "Your card was declined." // for humans, may change
}
}The split is the whole point. The `message` is for a human reading a log, and you're free to reword it. The `code` is for the client's code β `if (err.code === "card_declined") retryWithNewCard()` β and it must stay stable forever, because that string is now part of the contract. Never make a client parse the human message to figure out what happened; give it a code to branch on. (There's even a standard envelope for this, RFC 9457 "Problem Details," a JSON shape with `type`, `title`, `status`, and `detail` fields, for teams that want an off-the-shelf format instead of inventing their own.)
Two more headers are part of the contract, not extras. Authentication travels on every request β usually `Authorization: Bearer <token>` β because a REST API is stateless and remembers no login between calls. And rate limiting is communicated in the response: a 429 status when you've sent too many requests, plus headers like `RateLimit-Remaining` and `Retry-After` telling a well-behaved client how much it has left and when to try again. The rate-limiting mechanism that decides all this β token buckets, sliding windows β has its own simulator (/ratelimit/sim); from the API's side, the job is just to surface the limit in a way clients can obey.
Versioning: the bill you pay later#
Eventually you have to change the API in a way that would break existing clients β rename a field, drop one, change a type. You can't just do it, because the clients you don't control are still sending the old shape. Versioning is how you ship the new design while the old one keeps working. There are three common places to put the version.
| Where the version lives | Looks like | Trade |
|---|---|---|
| In the URL path | GET /v2/users/42 | Dead obvious and easy to route; but the version is baked into every URL, and REST purists dislike that a "resource" now has two addresses |
| In a request header | API-Version: 2026-07-01 | Keeps URLs clean and stable; but the version is now invisible in a browser or a log, and easy to forget to send |
| In the Accept media type | Accept: application/vnd.api.v2+json | Most in the spirit of HTTP content negotiation; but it's the most obscure and awkward for clients to use |
URL versioning (`/v1/`, `/v2/`) is the most common because it's the most obvious β you can see the version, curl it, and route on it. Header and media-type versioning keep URLs clean but hide the version somewhere easier to get wrong. There's no universally right choice; the point is to pick one deliberately and be consistent, because clients hard-code whatever you chose.
That's the discipline behind the honest interview answer: design for backward compatibility so most changes are additive and free, and treat a version bump as the expensive, last-resort option β because each live version is one more thing you'll maintain long after you've forgotten why. Some APIs (Stripe among them) go further and pin each client to the version that was current when they signed up, translating internally, so a client never has to migrate at all β trading heavy internal complexity for a promise that nothing breaks.
REST vs RPC vs GraphQL
REST is one shape of API, not the only one. The three you'll be asked to compare differ in one thing: what shape the interface takes. REST is resource-shaped (nouns you act on with verbs). RPC is action-shaped (you call named procedures like functions). GraphQL is client-shaped (the client writes a query for exactly the fields it wants). Each is a good answer to a different question.
| REST | RPC (e.g. gRPC) | GraphQL | |
|---|---|---|---|
| Shape | Resources + HTTP verbs | Named procedure calls | One endpoint, client-written query |
| You ask for | A resource (fixed fields) | A specific action | Exactly the fields you name |
| Payload | JSON (text, readable) | Often binary (compact) | JSON (text) |
| Over/under-fetching | Common β fixed responses | N/A β you call what you need | Solved β client picks fields |
| Best at | Public APIs, CRUD, HTTP caching | Internal service-to-service, speed | Many clients each needing a different slice |
The problem GraphQL is built to kill is over- and under-fetching. With REST, `GET /users/42` returns a fixed set of fields. A mobile screen that needs only a name and avatar still downloads the whole user (over-fetching), and a screen that needs the user plus their last three orders has to make several calls (under-fetching). GraphQL lets the client send one query naming exactly the fields it wants across related objects, and get back exactly that β one round trip, no waste. The cost is that the flexibility moves complexity onto the server.
RPC β most commonly gRPC β is the action-shaped option, built for services inside your own backend calling each other on the hot path, where a compact binary contract and speed matter more than being readable in a browser. It's a full topic of its own (/grpc): the `.proto` contract, generated stubs, and when to reach for it over REST. The one-line split: REST for public, resource-shaped, cacheable APIs; gRPC for fast internal service-to-service calls; GraphQL when many different clients each need a different slice of the data.
Go deeperUnder the hood: GraphQL's N+1 problem and query-cost limits
GraphQL's flexibility has a sharp edge. Because the client writes the query, it can ask for something that's cheap to write but brutal to serve. A query for 100 users, each with their 10 posts, each with the author's name, can make the server run one database query for the users, then one per user for posts, then one per post for the author β the N+1 query problem, where one request explodes into hundreds of database hits. The standard fix is a batching layer (Facebook's DataLoader is the original) that collects all the per-item lookups in a tick and runs them as one batched query.
A related risk is that a malicious or careless client sends a deeply nested query designed to be expensive β a denial-of-service by query shape. So production GraphQL servers add query-cost analysis: assign each field a cost, compute a query's total before running it, and reject anything over a budget, plus a max depth limit. This is the tax for letting the client choose the query: a REST endpoint has a fixed, known cost, while a GraphQL endpoint has to defend against the queries it invited.
PredictYou're building the public API for a product with a web app, an iOS app, and third-party developers β plus a fleet of internal services behind it all. Where does each of REST, gRPC, and GraphQL fit, and why not one for everything?
Hint: Two different audiences: outside clients you don't control (some in a browser), and internal services you own that talk constantly.
Split by audience. For the internal service-to-service traffic β services you own, calling each other on the hot path in mixed languages β reach for gRPC: a firm binary contract and speed, with no browser to worry about. For the public edge (web app, iOS, third parties), use REST or GraphQL, which are readable, browser-native, and easy for outside developers to integrate. Between those two, GraphQL shines precisely when the different clients need different slices of data: the web dashboard and the iOS screen fetch different fields, and GraphQL lets each ask for exactly what it needs instead of the API guessing β killing the over-/under-fetching a fixed REST response causes. If the clients mostly want the same resource-shaped data and you value HTTP caching and simplicity, plain REST is the lighter choice. Forcing one everywhere means either slow, loose internal calls (all REST) or a painful public story (all gRPC) or unnecessary server complexity (all GraphQL). Matching each tier to the interface shape its audience needs is the strong answer.
In the wild
- Stripe's API is the reference many teams copy: resource-shaped URLs, an Idempotency-Key header on every write, cursor pagination (`starting_after` / `ending_before`, never offset), a structured error object (`type` / `code` / `param` / `message`), and dated versions that pin each account to the version it started on so clients never have to migrate.
- GitHub's REST API paginates with a cursor delivered in the standard HTTP `Link` header (`rel="next"`), and its newer GraphQL API exists precisely because clients kept over-fetching from the fixed REST responses β a real example of the two styles serving different needs side by side.
- Twilio, Slack, and Shopify all version in the URL path or a dated header and lean on additive, backward-compatible changes so most updates ship without a version bump β the discipline that keeps the number of live versions small.
- GraphQL came out of Facebook, built to stop their mobile apps from making many round trips and over-fetching on slow networks; the client-chosen query was the direct answer to that pain, and DataLoader (their batching layer) was the direct answer to the N+1 problem it created.
Pitfalls & gotchas
Why is offset pagination fine in my tests but wrong in production?
Two reasons your tests hide. Your test dataset is small, so scanning past the offset is fast β the slowdown only shows up when the offset is in the millions. And your test data is static, so nothing is inserted or deleted mid-pagination β the skip/duplicate bug only appears when rows change underneath a reader, which is exactly what a live feed does all day. Offset looks correct until the dataset is both large and changing, which is precisely when it matters.
Is it fine to return a 200 with an "error" field in the body?
No β that's a classic anti-pattern. Clients and HTTP tooling branch on the status code: a 200 says "this worked," so caches cache it, retries don't fire, and monitoring counts it as success, even though it failed. Use the status code to signal failure (4xx for the client's fault, 5xx for yours) and put the machine-readable detail in the body. The status code and the body work together; the body is not a substitute for the code.
Which HTTP status should a failed retry use β is a timeout a 4xx or a 5xx?
The class is the whole message to the client. A 4xx means "your request was wrong; retrying it unchanged won't help" (bad input, not authorized, not found). A 5xx (or a network timeout) means "the server failed; the same request might work later," so clients retry 5xx and 429 but not 4xx. Returning a server-side database error as a 400 tells clients to give up on something that would have recovered; returning a genuinely bad request as a 500 makes clients retry something that will never work. Get the class right first, the specific code second.
If versioning is so costly, should I just never change the API?
The opposite β you change it constantly, just compatibly. Additive changes (a new optional field, a new endpoint) don't break existing clients because they ignore what they don't recognize, so they need no version bump and cost you nothing. You only pay the versioning tax for breaking changes β removing or renaming a field, changing a type. So the skill is to make as many changes as possible additive, and treat a new version as the rare, expensive exception, because every live version is one more thing to maintain forever.
QuizA team's public API uses offset/limit pagination. Reports come in that clients syncing large lists occasionally miss records or process the same record twice β but only during busy periods, and never in the test suite. What's the most likely cause and the right fix?
- The database is corrupting rows under load; add retries.
- Offset shifts when rows are inserted or deleted mid-pagination, so a moving dataset skips and duplicates rows; switch to cursor pagination.
- The clients are sending the wrong limit; cap the page size.
- HTTP can't guarantee ordering; add a sequence number to each response.
Show answer
Offset shifts when rows are inserted or deleted mid-pagination, so a moving dataset skips and duplicates rows; switch to cursor pagination. β Offset pagination names a position by counting rows to skip, and that count is only stable if nothing changes between page fetches. During busy periods, inserts and deletes shift every row's position while a client paginates: an insert above the reader's spot pushes rows down so the next offset re-shows one (duplicate), and a delete pushes rows up so the next offset jumps past one (skip). It never appears in tests because test data is static. The fix is cursor pagination, which anchors the next page to the last row's own value instead of a count, so inserts and deletes elsewhere can't shift what comes after the cursor. Retries, page-size caps, and sequence numbers don't touch the underlying position-shift problem.
In an interview
"Design the API for X" is a standard prompt inside a bigger system-design question. The interviewer isn't testing whether you can name HTTP verbs β they're testing whether you design for a client you don't control: retries, pagination at scale, change over time. Lead with that framing and hit the four decisions.
- Model resources, then verbs: nouns as URLs, HTTP methods as the actions. Name the safe/idempotent property and use it β GET/PUT/DELETE are idempotent (retry-safe), POST is not, which is why a money-moving POST needs an idempotency key.
- Paginate with a cursor, not offset, and be able to say why both: offset gets slower the deeper you go (it scans past every skipped row) and skips/duplicates rows when the data changes underneath the reader. A cursor anchors to a row's value, fixing both.
- Design the error contract: correct status classes (4xx = client's fault, don't retry; 5xx = server's fault, retry) plus a stable machine-readable error code in the body β never make the client parse the human message.
- Version for change, and name the real cost out loud: the expense isn't URL-vs-header, it's that every live version is one more thing you maintain forever β so prefer additive, backward-compatible changes and treat a version bump as a last resort.
- If asked REST vs gRPC vs GraphQL, answer as a matching problem, not a winner: REST for public/resource-shaped/cacheable, gRPC for fast internal service-to-service, GraphQL when many clients each need a different slice. Say real systems mix them.
PredictAn interviewer says: "You're designing the API for a payments product. Walk me through the two or three design choices you'd defend hardest." What's the strong answer?
Hint: It moves money, clients retry, and it'll be integrated for years. Which choices protect against each of those?
Lead with idempotency, because it moves money: a charge is a POST, POST isn't idempotent, and a client that times out will retry β so without an idempotency key on every write, a network blip becomes a double charge. Say you'd require a client-chosen idempotency key and dedup on it server-side (the full mechanism is its own topic, but you name it here). Second, the error contract, because clients live on the failure path: correct status classes so clients know whether to retry (5xx/429) or not (4xx), plus a stable machine-readable error code like `card_declined` in the body, so a client branches in code instead of parsing English. Third, versioning and backward compatibility, because a payments API is integrated for years by clients you can't contact: design additively so most changes need no version bump, and if you must version, accept that you'll run the old one for a long time (Stripe's per-account version pinning is the gold standard to cite). Cursor pagination for listing charges is a good fourth if there's time. The through-line β and what the interviewer wants β is that every choice is defending against a client you don't control: it retries, it's on the error path, and it can't be forced to migrate.
References & further reading
- RFC 9110 β HTTP Semantics (Β§9.2 Safe & Idempotent Methods) β The authoritative definition of which HTTP methods are safe and idempotent, and why.
- Roy Fielding β Architectural Styles and the Design of Network-based Software Architectures (Ch. 5, REST) β The 2000 dissertation that defined REST, resources, and the (rarely-shipped) hypermedia constraint.
- Martin Fowler β Richardson Maturity Model β The three levels of REST: resources, verbs, and HATEOAS β and where real APIs actually sit.
- Stripe API β Errors & Idempotent Requests β The canonical structured error object (type/code/param/message) and the Idempotency-Key contract.
- Stripe β Pagination (cursor-based) β starting_after / ending_before: cursor pagination in a production public API, never offset.
- RFC 9457 β Problem Details for HTTP APIs β The standard JSON error envelope (type/title/status/detail) for machine-readable errors.
- GraphQL β Pagination & Connections (cursor spec) β The cursor-connection pattern and why cursors beat numbered pages for feeds.