This page is for backend, full-stack and integration developers facing a round on REST API design. Most rounds start with what REST really means, HTTP methods and status codes, then move to resource naming, PUT versus PATCH, pagination and versioning. Mid and senior rounds add authentication with API keys, OAuth 2 and JWTs, rate limiting, caching and ETags, a comparison with GraphQL and gRPC, and a live design exercise. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own projects.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Origin: an architectural style described by Roy Fielding, defined by a set of constraints.
Constraints: client-server, stateless, cacheable, uniform interface, layered system, and optional code on demand.
Uniform interface: resources named by URLs, changed through representations, self-descriptive messages, and links between resources.
Reality check: most real APIs follow some of this, which is fine if you know which parts you skipped.
"REST is an architectural style, not a protocol, and it's defined by constraints. The client and server are separate. Every request is stateless, so it carries everything the server needs. Responses say whether they can be cached. There's a uniform interface: everything is a resource with its own URL, you act on it with standard HTTP methods, and messages describe themselves through things like content types and status codes. The system can be layered, so a client doesn't care if there's a proxy or a CDN in the middle. Code on demand is the one optional constraint. In practice, most APIs people call REST use resources, methods and status codes properly but skip hypermedia links. I think that's fine, as long as you're honest that you're doing pragmatic REST and you're consistent about it."
Defining REST as 'an API that returns JSON' or confusing it with HTTP itself.
Session state: the server keeps nothing about the client's conversation between requests.
Resource state: data in the database is fine; that's the whole point of the API.
Each request: carries its own credentials and context, such as a token and the IDs it needs.
Payoff: any instance behind a load balancer can serve any request, with no sticky sessions.
"It doesn't mean the server stores nothing. The database obviously holds orders, users and so on; that's resource state. What stateless means is that the server doesn't remember anything about the client's conversation between requests. So there's no 'the user is on step two of the wizard' kept in server memory. Every request has to carry what's needed to understand it: the access token, the resource ID, any filters. The big payoff is scaling. If each request stands alone, I can put ten instances behind a load balancer and any of them can answer any request, and if one dies, nobody loses a session. It also makes requests easier to cache, retry and debug, because you can read one request and know exactly what it asked for."
Saying a stateless API can't use a database, or that stateless simply means using JWTs.
Idea: responses include links to related resources and the actions available right now.
Benefit: clients follow links instead of hard-coding URLs, and the server can change paths or hide actions.
Reality: few public APIs go all the way; most use it lightly, like pagination links and a Location header.
Formats: HAL and JSON:API are common conventions for putting links in JSON.
"HATEOAS stands for hypermedia as the engine of application state. The idea is that each response tells the client what it can do next, with links. So an order response includes a link to itself, to its customer and, only if it's still cancellable, a link to cancel it. The client follows links instead of building URLs by hand, so the server can change paths, and the client learns which actions are allowed without copying business rules. It's the top level of the Richardson Maturity Model. In practice, few APIs do it fully, because most clients are written against documentation and hard-code URLs anyway. What I do use are the cheap, high-value parts: next and previous links for pagination, a Location header after create, and sometimes an actions list so the UI knows which buttons to show."
{
"id": 981,
"status": "paid",
"_links": {
"self": { "href": "/orders/981" },
"customer": { "href": "/customers/42" },
"cancel": { "href": "/orders/981/cancellation", "method": "POST" }
}
}
Dismissing it without knowing what it is, or claiming every REST API must implement it fully to be useful.
REST: resources over plain HTTP; simple, widely understood and friendly to HTTP caching; best for public APIs.
GraphQL: one endpoint where clients ask for exactly the fields they need; great for varied UIs, harder to cache and to protect.
gRPC: Protocol Buffers over HTTP/2 with generated clients and streaming; best for internal service-to-service calls.
Decide by: who the consumers are, how varied their data needs are, and your need for caching and speed.
"I decide by who's calling. For a public API used by partners I don't know, REST wins. Everyone understands it, any language can call it with plain HTTP, and CDNs and browsers cache GET responses for free. GraphQL fits when many different screens need different slices of connected data, like a mobile app and a web dashboard showing the same products in different ways. The client asks for exactly the fields it needs in one round trip. The costs are real, though: plain HTTP caching helps less, because queries usually go as POST to one endpoint, and you need query depth and cost limits and batching to avoid N+1 lookups. gRPC is what I'd use between internal services. It's contract-first with Protocol Buffers, generates typed clients, runs over HTTP/2 with streaming and is compact on the wire. Browsers can't call it directly; they need gRPC-Web and usually a proxy, so it's rarely public-facing."
Saying GraphQL replaces REST everywhere, or choosing gRPC for a public browser-facing API without mentioning the proxy it needs.
Safe: GET, HEAD, OPTIONS and TRACE; the client is not asking to change server state.
Idempotent: all safe methods plus PUT and DELETE; repeating the call leaves the same end state.
Neither guaranteed: POST, and PATCH unless the patch is written to be repeatable.
Why it matters: clients, proxies and libraries retry idempotent calls on a timeout; safe calls can be cached and prefetched.
"Safe means the request doesn't ask to change anything on the server: GET, HEAD and OPTIONS, plus TRACE, which you rarely see. Idempotent means sending it once or five times leaves the server in the same state. All safe methods are idempotent, and so are PUT and DELETE. If I PUT the same body twice, the resource ends up the same. If I DELETE twice, it's still gone, even though the second call might return 404. So idempotency is about the state, not the response code. POST isn't idempotent, and PATCH isn't guaranteed either, because a patch like 'add 10 to the balance' changes things every time. This matters because networks fail. When a client times out, it can safely retry an idempotent call, but retrying a POST might create a second order. And if a GET changes data, a crawler or a prefetching browser can trigger it by accident."
Saying idempotent means 'returns the same response every time', or listing POST as idempotent.
PUT: replaces the full resource with the body you send; fields you leave out are cleared or reset.
PATCH: sends only the changes; the server applies them to what exists.
PATCH formats: JSON Merge Patch sends a partial object; JSON Patch sends a list of operations.
Idempotency: PUT is idempotent; PATCH only if the change itself is repeatable.
"PUT means 'here is the whole resource, replace what you have with this'. So to change the email with PUT, I send the full user: name, email, phone, everything. If I leave out the phone, a correct PUT implementation clears it. PATCH means 'apply these changes'. With JSON Merge Patch I'd send just the email field, and setting a field to null removes it. With JSON Patch I'd send a list of operations, like replace the value at the path slash email. In practice PATCH is what most clients want for edits, because they don't have to fetch and resend the whole object, and two people editing different fields don't wipe each other out. PUT is always idempotent. PATCH is idempotent for a simple 'set email to this', but not for something like 'append to this list'."
PUT /users/42
Content-Type: application/json
{"name": "Asha Rao", "email": "asha@example.com", "phone": "555-0101"}
PATCH /users/42
Content-Type: application/merge-patch+json
{"email": "asha@example.com"}
Saying PUT is for create and PATCH is for update, or that the two are interchangeable.
Client key: the client generates a unique key per logical payment and sends it in a header on every retry.
Server record: store the key with a hash of the request and the final response; replay the stored response on a repeat.
Edge cases: same key with a different body is rejected; a repeat while the first is still running gets a conflict.
Atomic and bounded: save the key and the payment in one transaction or under a unique constraint, and expire keys after a set time.
"The root problem is that the client can't tell if the first request succeeded, so retrying POST is a gamble. I'd add an idempotency key. The client generates a random ID for this one payment attempt, say a UUID, and sends it in an Idempotency-Key header, reusing the same key on every retry. On the server, I store the key with a hash of the request body and, once done, the response. If the same key comes in again, I skip the charge and return the saved response. If the same key comes with a different body, that's a client bug, so I reject it. If the first request is still in flight, I return 409 so the client waits and retries. The key record and the payment must be written atomically, usually with a unique constraint, or two parallel retries can both slip through. Keys expire after a day or so."
POST /payments
Idempotency-Key: 5f0c2a8e-1b7d-4e39-9a61-2c4d8f7e3b10
Content-Type: application/json
{"order_id": "ord_981", "payment_method_id": "pm_7731"}
Suggesting you deduplicate by amount and time window, or that switching the method to PUT alone fixes it.
Classes: 1xx informational, 2xx success, 3xx redirection, 4xx client error, 5xx server error.
Success: 200 OK, 201 Created with a Location header, 202 Accepted for async work, 204 No Content.
Client errors: 400, 401, 403, 404, 409, 422 and 429.
Server errors: 500 for bugs, 502 and 504 from gateways, 503 when overloaded or in maintenance.
"The first digit tells the client who's responsible. 2xx means it worked, 3xx means look somewhere else, 4xx means the client did something wrong and shouldn't resend the same request unchanged, apart from 429 after waiting, and 5xx means the server failed and a retry might work later. For success I use 200 for a normal read or update, 201 when something was created, with a Location header pointing to the new resource, 202 when I've accepted work that will finish later, and 204 when there's nothing to return, like after a delete. For client errors: 400 for a malformed request, 401 when you're not authenticated, 403 when you are but aren't allowed, 404 when it doesn't exist, 409 for a conflict with the current state, 422 for validation failures and 429 for rate limits. On the server side, 500 is a bug, and 502, 503 and 504 usually come from gateways or overload."
Returning 200 with an error message inside the body as the normal way to report failures.
401: no valid credentials; the client should log in or refresh its token. Sent with a WWW-Authenticate header.
403: the caller is known but not allowed to do this; logging in again won't help.
404 on purpose: when even revealing that a resource exists would leak information.
"401 is about who you are. The token is missing, expired or invalid, so the client should authenticate and try again. The name 'Unauthorized' is a bit misleading, because it really means unauthenticated, and the response should carry a WWW-Authenticate header. 403 is about what you're allowed to do. The server knows exactly who you are, and you still can't have it, so re-sending the same credentials won't help. A normal user calling an admin endpoint gets 403. The 404 case is about leaking information. If I'm user 7 and I request user 9's invoice, returning 403 confirms that invoice exists. For private resources, many APIs return 404 so an attacker can't probe which IDs are real. I pick one rule for the whole API and document it, so clients aren't guessing."
Using 401 and 403 interchangeably, or returning 403 for an expired token.
400 Bad Request: the request itself is broken: unparseable JSON, wrong types, missing required fields.
422 Unprocessable Content: well-formed, but the values break business rules, like a short password.
409 Conflict: clashes with the current state of the server, like a duplicate email.
Consistency: pick a rule, document it, and return field-level details either way.
"I split it by what went wrong. If the body can't be parsed or a field has the wrong type, that's 400. Here the JSON is fine, so it's not really 400. A password that's too short is a validation failure: the request is well-formed but the content breaks a rule, and 422, now called Unprocessable Content, fits that well. The duplicate email is different. Nothing is wrong with the request on its own; it conflicts with what already exists, so 409 Conflict is the most accurate. If both problems happen at once, I'd usually return 422 with both field errors, because the client needs to fix the password anyway. Honestly, some teams use 400 for all of this, and that's acceptable. What matters more is one documented rule and an error body that says which field failed and why."
Returning 500 for validation errors, or 200 with a success flag set to false.
One shape: every endpoint returns the same structure; Problem Details from RFC 9457 is a ready standard.
For code: a stable machine-readable error code plus field-level details for validation.
For humans: a clear message and a request ID that support can search in the logs.
Never: stack traces, SQL, internal hostnames or which part of a login was wrong.
"First, one shape for every error across the whole API, so clients write one handler. I'd lean on Problem Details, which is an RFC, served as application/problem+json. It gives you type, title, status, detail and instance, and you can add your own fields. I add a stable error code like 'card_declined' that clients can switch on, because messages get reworded but codes shouldn't change. For validation, a list of field errors with the field path and reason. And a request ID, which is the same one in our logs, so when a partner emails support we find the exact request in seconds. What never goes in: stack traces, database errors, server names or file paths. That's free reconnaissance for an attacker. And for login, I say 'email or password is wrong', not which one."
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Some fields are not valid",
"status": 422,
"code": "validation_failed",
"errors": [{"field": "password", "reason": "too_short"}],
"request_id": "req_7f3a91"
}
Letting each endpoint return its own error format, or passing raw exception messages straight to the client.
Acknowledge the goal: one simple way for the frontend to handle every response is reasonable.
Concrete costs: caches may store errors as success, monitoring on 5xx rates goes blind, and client libraries and retries rely on status codes.
Compromise: proper status codes plus one consistent error body, and a small shared client wrapper.
Decide together: agree, document, and move on, whichever way it goes.
"I'd start by agreeing with the goal. The frontend should handle every response one way. Then I'd show what we lose with always-200. Our dashboards and alerts count 5xx rates, so a broken endpoint would look perfectly healthy. A CDN or proxy might cache an error as a good response. Retry logic, HTTP client libraries and the partner teams who'll call this API all expect status codes to mean something, so they'd each have to parse bodies to detect failures. Then I'd offer a compromise that meets his aim: real status codes, plus one error body shape used everywhere, and a small shared fetch wrapper on the frontend that turns any non-2xx into the same error object. The frontend code stays just as simple. If he still prefers his approach after hearing that, I'd ask for it in writing as a team standard, so it's applied consistently."
Either caving without mentioning the monitoring and caching costs, or dismissing the lead's concern as simply wrong.
Nouns, not verbs: the method says the action; the URL names the thing.
Plural collections: /orders for the list, /orders/981 for one item.
Relationships: nest one level for ownership, /users/42/orders; avoid deep chains.
Style: lowercase, hyphens in multi-word names, filters in the query string.
"The URL should name a resource, and the HTTP method should be the verb. So 'get' in the path is redundant, because GET already says that. Collections are plural nouns: /orders is the list, /orders/981 is one order. For orders that belong to a user, I'd write GET /users/42/orders, which reads naturally and makes the relationship clear. Or, if orders are also fetched in other ways, GET /orders?user_id=42 works well and keeps the API flatter. I keep nesting to one level, because /users/42/orders/981/items/3 gets painful fast, and the item usually has its own ID anyway. Other habits: lowercase, hyphens for multi-word names like /shipping-addresses, no file extensions, and whatever casing I pick for JSON fields, I use it everywhere."
Keeping verbs in the path, like /createUser and /deleteUser, and using POST for all of them.
State change: if the action is a status change, PATCH the resource's status field.
Noun the action: create a resource that represents it, like POST /password-resets.
Action sub-resource: POST /orders/981/cancel is a common, readable compromise.
Choose by rules: if the action has its own data, rules or history, it deserves its own resource.
"I first ask whether the action is really just a state change. Cancelling might be PATCH /orders/981 with status set to cancelled. That's clean if cancelling is simple. But usually it isn't: there's a reason, a refund, maybe a rule that shipped orders can't be cancelled. Then I turn the action into a noun: POST /orders/981/cancellation creates a cancellation record, which can carry the reason and the refund status and be fetched later. The password reset is a great example of this. POST /password-resets with the email creates a reset request, and it returns 202 whether or not the email exists, so it doesn't leak who has an account. I'm also fine with POST /orders/981/cancel. Plenty of mature APIs do this, it's clear to read, and it beats forcing an awkward model just for purity."
Using GET for an action because it's easy to click, or insisting there's no RESTful way to express actions at all.
Offset and limit: simple and lets users jump to page 40, but the database still reads and throws away skipped rows.
Drift: new or deleted rows shift pages, so items get duplicated or skipped between requests.
Cursor: an opaque token holding the last sort key plus an ID tie-breaker; the next query continues after it.
Always: a default and maximum page size, and a next link or cursor in the response.
"Offset pagination is limit 20, offset 400. It's easy to build, and users can jump to any page. It breaks in two ways. Deep pages get slow, because the database reads and discards all the skipped rows. And if someone inserts a row while I'm paging, everything shifts, so I see an item twice or miss one. Cursor pagination fixes both. I sort by something stable, say created time plus the ID as a tie-breaker, and the cursor encodes the last values I returned. The next query asks for rows after that point, which an index answers quickly at any depth, and inserts don't shift anything. The trade-off is no jumping to page 40, and total counts need a separate, often expensive, query. For feeds, logs and big tables I use cursors. For a small admin table with page numbers, offset is fine."
-- Next page after the last row the client saw
SELECT id, created_at, total
FROM orders
WHERE (created_at, id) < ('2026-09-01 10:15:00', 981)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Returning the whole collection with no limit, or not knowing why large offsets get slow.
Filters: plain query parameters named after fields, with a clear pattern for ranges.
Sorting: one sort parameter, comma-separated fields, a minus sign for descending.
Fields: an optional fields parameter so clients fetch only what they show.
Guard rails: allow-list what can be filtered and sorted, validate values, and cap the page size.
"I keep it boring and consistent. Filters are just query parameters named after the fields: status=shipped, customer_id=42. For ranges I use a clear suffix pattern like created_after and created_before, and I use the same pattern on every endpoint. Sorting is one parameter, sort=-created_at,total, where the minus means descending. If payloads are big, a fields parameter lets a mobile client ask for just id, name and price. The important part is the guard rails. I only allow filtering and sorting on fields I've listed, usually ones with an index, because sorting a huge table on an unindexed column can take the database down. I validate every value and return 400 with a clear message for an unknown filter, instead of silently ignoring it. And list endpoints always have a default page size and a maximum."
GET /orders?status=shipped&created_after=2026-09-01&sort=-created_at,total&fields=id,status,total&limit=50
Passing sort or filter fields from the query string straight into SQL.
Resources: rooms and bookings; availability as a query on rooms rather than a separate stored thing.
Endpoints: list and filter rooms, create a booking with 201 and Location, read, update with If-Match, cancel.
Hard part: overlapping bookings return 409, enforced in the database, not just by a check before insert.
Details: times in ISO 8601 with an offset, pagination on lists, only the owner or an admin may change a booking.
"I'd start with two resources, rooms and bookings. GET /rooms lists rooms, with filters like min_capacity, and available_from and available_to, so the client can ask which rooms are free in a slot. GET /rooms/12 returns one room. POST /bookings takes a room ID, start, end and title, and returns 201 with a Location header. GET /bookings lets me filter by room or date range, paginated. PATCH /bookings/340 changes the time, with If-Match to avoid lost updates. For cancel, I'd set status to cancelled rather than delete, so history is kept. The hard part is two people booking the same slot at once. Checking for overlap and then inserting isn't enough under concurrency, so the database itself has to enforce it, for example with an exclusion constraint on room and time range, and the loser gets 409 with the clashing booking. All times go in ISO 8601 with an offset, stored in UTC."
GET /rooms?min_capacity=6&available_from=2026-10-01T09:00:00Z&available_to=2026-10-01T10:00:00Z
GET /rooms/12
POST /bookings -> 201 Created, Location: /bookings/340 | 409 if the slot is taken
GET /bookings?room_id=12&from=2026-10-01&to=2026-10-07&limit=50
GET /bookings/340
PATCH /bookings/340 (If-Match) -> 200 | 409 overlap | 412 stale
PATCH /bookings/340 {"status": "cancelled"} -> 200
Checking availability in application code and then inserting, with nothing in the database to stop a race.
Understand why: round trips on a slow mobile network are a real cost, not laziness.
Options: an expand or include parameter on existing resources, or a composite endpoint owned for that screen.
Where it lives: a backend-for-frontend layer keeps screen-shaped endpoints out of the core resource API.
Guard: clear ownership, caching and limits so it doesn't become a slow catch-all.
"Probably yes, but I'd first ask why and where it should live. If the home screen makes six calls on a slow mobile network, that's real latency, so the request is fair. My first option is an expand parameter, so GET /users/me?expand=recent_orders,notifications pulls related data in one call without inventing new resources. If the screen needs data from many unrelated places, I'd rather build a small backend-for-frontend endpoint owned for that app, something like GET /mobile/home, which calls the core APIs server-side and shapes the response. That keeps the public resource API clean while the app gets its single call. I'd make sure it's cached where it can be, has a timeout on each internal call so one slow part doesn't block the whole screen, and has an owner. If every screen starts needing this, that's the point to consider GraphQL."
Refusing because 'that isn't RESTful' without weighing the latency, or bolting screen logic into the core resource endpoints.
URL version: /v1/orders is visible, easy to route and test in a browser, but coarse.
Header or media type: keeps URLs clean and allows finer control, but is harder to see and cache correctly.
Breaking: removing or renaming a field, changing a type or meaning, adding a required input, changing status codes.
Non-breaking: new optional fields, new endpoints, new optional parameters, if clients ignore unknown fields.
"The common options are the URL, like /v1/orders, or a header, either a custom one or a version inside the Accept media type. URL versioning is the most popular because it's obvious: you see it in logs, you can route on it at the gateway, and you can try it in a browser. Header versioning keeps URLs stable and lets you version more finely, but it's invisible, easy to forget, and caches need a Vary header to handle it. More important than where is when. I only bump a major version for breaking changes: removing or renaming a field, changing a type, making an optional input required, or changing what a status code means. Adding a new optional field or a new endpoint isn't breaking, as long as clients are told to ignore fields they don't recognise. Good design means versions are rare."
Bumping the version on every release, or claiming renaming a field is safe because 'it's the same data'.
OpenAPI: a YAML or JSON description of paths, parameters, schemas, responses and auth; formerly the Swagger spec.
Design-first or code-first: write the spec before coding for review, or generate it from annotated code.
Uses: interactive docs, generated client SDKs, mock servers and contract tests in CI.
Beyond the spec: a getting-started guide, auth steps, real examples and an error catalogue.
"I treat the OpenAPI document as the source of truth. It's a YAML or JSON file that describes every path, its parameters, request and response schemas, status codes and how auth works. It used to be called the Swagger specification. I prefer design-first for anything shared: we write the spec, other teams review it before code exists, and they can even build against a mock server generated from it. From the same file we get interactive docs, typed client SDKs and contract tests in CI that fail if the code drifts from the spec. But a spec alone isn't enough documentation. People also need a short getting-started page, how to get a token, working examples for common tasks, and a list of error codes with what to do about each one. If a new developer can make their first successful call in a few minutes, the docs are working."
openapi: 3.1.0
info:
title: Bookings API
version: 1.0.0
paths:
/bookings/{bookingId}:
get:
summary: Get one booking
parameters:
- name: bookingId
in: path
required: true
schema:
type: string
responses:
'200':
description: The booking
'404':
description: No booking with that ID
Saying the docs are a hand-written wiki page updated 'when we get time'.
Measure: find who still reads or sends the field, through logs per API key or client.
Signal: mark it deprecated in the OpenAPI spec and changelog, and send Deprecation and Sunset headers.
Talk: email the known consumers with a date and a migration path.
Remove safely: only after the date and near-zero usage, or in a new major version.
"I wouldn't just remove it, and I wouldn't refuse either. First, I'd find out who actually uses it. If our logs record calls per API key, I can see which partners still hit endpoints returning that field, and if clients send it back in requests, I can count that too. Next, I'd mark it deprecated in the OpenAPI spec and the changelog, and add Deprecation and Sunset headers to responses so anyone watching their traffic gets a machine-readable warning. Then I'd contact the known partners directly with the date and what to use instead. I'd set a realistic window, usually a few months for external partners. As the date nears, I check usage again and chase anyone left. If a big partner truly can't move, the field stays until the next major version. The goal is that nobody finds out by their integration breaking."
Removing the field in a normal release and waiting to see who complains.
API key: a long-lived secret that identifies the calling app or project; simple, good for server-to-server.
OAuth 2: a framework for getting scoped, expiring access tokens, often for acting on a user's behalf.
JWT: a signed token format; it can be an OAuth access token, but it isn't a login protocol.
Pick by caller: backend partner, key; third-party app acting for a user, OAuth; your own services, short-lived tokens.
"They're not three competing options; they sit at different levels. An API key is a secret string that says which application is calling. It's simple and fine for server-to-server use, like a partner's backend calling our pricing API, but it usually doesn't say which user, and it lives a long time, so it needs rotation. OAuth 2 is a framework for getting access tokens, especially when a third-party app wants to act on a user's behalf without ever seeing their password. The user approves specific scopes, and the app gets a short-lived token. A JWT is just a format for a token: signed JSON with claims like who, which audience and when it expires. OAuth servers often issue access tokens as JWTs, so the two work together. So I'd pick based on who's calling and whether a user is involved."
Treating JWT and OAuth 2 as alternatives, or saying an API key alone identifies the end user.
Start: the client makes a random code verifier, hashes it into a code challenge, and redirects the user to the authorization server with it and a state value.
Consent: the user logs in and approves scopes; the browser comes back to the redirect URI with a short-lived code.
Exchange: the client checks the state, then sends the code plus the original verifier to the token endpoint.
Why PKCE: a stolen code is useless without the verifier, which never went through the browser redirect.
"The client, say a mobile app, first generates a random secret called the code verifier and hashes it with SHA-256 to get the code challenge. It sends the user to the authorization server's authorize endpoint with its client ID, redirect URI, scopes, a random state value and that challenge. The user logs in there, not in the app, and approves the scopes. The server redirects back with a one-time authorization code. The app checks the state matches, to block forged callbacks, then calls the token endpoint with the code and the original verifier. The server hashes the verifier, compares it to the challenge it saw earlier, and only then issues an access token and usually a refresh token. PKCE exists because codes can be intercepted, for example by another app registered for the same redirect scheme. Without the verifier, a stolen code can't be exchanged. Current guidance is to use PKCE for every client type."
Having the app collect the user's password and send it to the API, or thinking PKCE replaces HTTPS.
Structure: header, payload and signature, base64url-encoded; anyone can read the payload.
Checks: signature with an expected algorithm and trusted key, then expiry, not-before, issuer and audience.
Then authorize: scopes or roles in the claims decide what the caller may do on this endpoint.
Revocation: the token is valid until it expires, so keep access tokens short and use refresh tokens or a denylist.
"A JWT has three parts: a header, a payload of claims and a signature. The payload is only encoded, not encrypted, so I never put secrets in it. Before trusting it, I verify the signature with the issuer's key, and I pin the algorithm I expect. I never let the token's own header choose it, and I reject 'none'. Then I check exp so it hasn't expired, nbf if it's set, iss so it came from our auth server, and aud so it was issued for this API and not some other service. Only then do I look at scopes or roles to decide if this caller can hit this endpoint. Revocation is the catch. The API doesn't call anyone to check, so a stolen token works until it expires. That's why access tokens are short-lived, with refresh tokens that can be revoked, and a denylist by token ID for emergencies."
Decoding the token and trusting the claims without verifying the signature, or storing sensitive data in the payload.
Who: limit per API key or user, with an IP limit for anonymous traffic; different limits for costly endpoints.
Algorithm: token bucket allows short bursts and a steady rate; fixed windows allow double bursts at the boundary.
Shared counter: a fast shared store with atomic updates so every instance sees the same count.
Client view: 429 Too Many Requests with Retry-After and headers showing the limit and what's left.
"First, who am I limiting? Usually the API key or user, with a per-IP limit for unauthenticated calls, and tighter limits on expensive endpoints like search or exports. For the algorithm I like a token bucket. Each client has a bucket that refills at a steady rate and has a maximum size, so short bursts are fine but sustained floods get cut off. A simple fixed window per minute is easier, but a client can send double the limit across the boundary. Because we run several servers, the counters can't live in each server's memory. I'd keep them in a shared in-memory store like Redis, updated atomically. When a client is over, they get 429 with a Retry-After header, plus headers for the limit and how many calls remain, so well-behaved clients slow down. And I decide upfront whether to fail open or closed if the store goes down."
import time
class TokenBucket:
def __init__(self, capacity, refill_per_sec):
self.capacity = capacity
self.tokens = capacity
self.refill = refill_per_sec
self.last = time.monotonic()
def allow(self):
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
Keeping counters only in each server's memory behind a load balancer, or returning 500 or 403 for rate-limited calls.
Freshness: max-age says how many seconds a response can be reused without asking the server.
Who may cache: public allows shared caches like CDNs; private allows only the user's own client.
no-cache vs no-store: no-cache may be stored but must be revalidated before every use; no-store must not be stored at all.
Vary: tells caches which request headers change the response.
"Cache-Control tells browsers, CDNs and proxies what they may do with a response. max-age sets how many seconds it stays fresh. So a product catalogue that changes hourly might get public, max-age of 300, and a CDN can answer most requests without touching my servers. public versus private is about who can store it. Anything user-specific, like 'my orders', is private, so only that user's client caches it, never a shared cache that could hand it to someone else. The names no-cache and no-store confuse people. no-cache means you can store it but must check with the server before reusing it, usually with an ETag. no-store means don't keep it anywhere, which I use for sensitive data like account details or tokens. And if the response depends on a header, like Accept-Language, I send Vary so caches keep separate copies."
Thinking no-cache means 'never cache', or marking personalised responses as public.
ETag: an identifier for one version of a resource's representation, sent in a response header.
Save bandwidth: the client sends If-None-Match; if nothing changed, the server replies 304 Not Modified with no body.
Lost updates: the client sends If-Match on PUT or PATCH; if the resource changed, the server replies 412 Precondition Failed.
Strong vs weak: If-Match needs a strong ETag; weak ones marked W/ are for 'close enough' caching.
"An ETag is a version tag for a resource, like a hash of the content or a version number, sent in a response header. It has two big uses. First, caching. When the client already has a copy, it sends If-None-Match with the ETag it holds. If nothing changed, I return 304 Not Modified with an empty body, and the client reuses its copy. That saves bandwidth, though the server still does the lookup. Second, and this is the one people forget, it prevents lost updates. Two people open the same document at version 17. The first saves with If-Match 17, which succeeds, and the version becomes 18. The second saves with If-Match 17, and I return 412 Precondition Failed instead of silently overwriting. Their client refetches and merges. I can even require If-Match on updates and return 428 when it's missing. That's optimistic locking, over plain HTTP."
GET /documents/55 -> 200, ETag: "v17"
GET /documents/55
If-None-Match: "v17" -> 304 Not Modified (no body)
PATCH /documents/55
If-Match: "v17" -> 200, ETag: "v18"
PATCH /documents/55
If-Match: "v17" -> 412 Precondition Failed
Only knowing ETags as a browser cache feature, with no idea they can guard concurrent updates.
Context: what the API did, who used it and roughly how much.
Decisions: two or three design choices you made and why, like pagination, errors or auth.
Hindsight: one choice that hurt later, what it cost consumers, and what you'd do instead.
Lesson: the habit you carry into every API since.
"At my last company I designed the internal API that our billing, support and reporting teams used to read customer accounts. I made a few choices I'm still happy with: cursor pagination from day one, one error format with stable codes, and an OpenAPI spec reviewed by all three teams before we wrote code. What I'd change is how I modelled status. I exposed account status as a free-text string that mirrored our database. When we added two new statuses, the reporting team's dashboards broke, because they'd hard-coded the list. The API was technically unchanged, but for them it was a breaking change. Now I document enums as open lists that clients must expect to grow, give them a safe default for unknown values, and announce new values in a changelog before they ship. I learned that a contract includes meaning, not just field names."
A story with no real consumers, or a hindsight answer that blames the other teams for 'using it wrong'.
What broke: the change, why it seemed safe, and how it surfaced.
Response: how you confirmed the cause and restored the client, usually by rolling back first.
Root cause: why your checks didn't catch it.
Prevention: contract tests, spec diffs in CI, usage metrics or a deprecation process.
"In a previous role we changed a price field in our orders API from a number to a string, to avoid rounding issues. We thought it was internal-only. Within an hour, a partner's warehouse integration stopped importing orders, because their parser expected a number. Support spotted it through a spike in the partner's failed calls in our logs. I rolled back the deploy first, which restored them in about twenty minutes, and then called their developer to confirm. The root cause was that we had no view of who used which fields, and no check that flagged a type change. Afterwards, I added an OpenAPI diff step in CI that fails the build on breaking changes unless someone approves it, and we started logging which API keys called which endpoints. The price change came back later as a new field alongside the old one, with a deprecation date."
Blaming the partner for fragile code, or skipping straight to a fix without restoring the client first.
Situation: which API, what it did for you, and how it misbehaved.
Defences: timeouts, retries only on safe or idempotent calls, backoff with jitter, and respecting their rate limits.
Isolation: queues, caching or a circuit breaker so their outage doesn't become yours.
Result: what improved and how you knew.
"At my first job I integrated a shipping provider's API for tracking updates. It sometimes took ten seconds to answer, sometimes returned 503, and the docs didn't match real responses. First, I set a short timeout, because we had none and slow calls were tying up our request threads. Then I added retries with exponential backoff and jitter, but only for timeouts and 5xx, never for 4xx, and I honoured their Retry-After on 429. Because the docs were wrong, I logged sample responses and wrote our parser around what really came back. The biggest win was moving the calls out of the user's request path: a background job polled for updates and cached them, so a provider outage only made tracking a little stale instead of breaking our order page. Page errors from that integration dropped close to zero."
Retrying every failure immediately in a loop, including 4xx errors and non-idempotent calls.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.