In this lesson, we’ll turn a product requirement into a REST API: the resources a service exposes, the operations on them, and the rules clients can lean on. A REST API is a contract, and a good one is clear to call, safe to retry, and possible to change without breaking the clients already using it. By the end you’ll be able to model a domain as resources, pick status codes that mean something, bound your lists, and version without fear. A companion lesson on idempotency and webhooks goes deeper on safe retries and server-to-server callbacks.
Model resources, not actions
Start from the nouns in the domain and expose them as resources with stable identifiers. Let the HTTP method carry the operation, so the verb lives in the method and never in the path.
| Method | Path | Meaning | Safe | Idempotent |
|---|---|---|---|---|
| GET | /orders/{id} | Read one order | yes | yes |
| GET | /orders | List orders | yes | yes |
| POST | /orders | Create an order | no | no |
| PUT | /orders/{id} | Replace an order | no | yes |
| PATCH | /orders/{id} | Partial update | no | no |
| DELETE | /orders/{id} | Remove an order | no | yes |
Two labels in that table decide everything a proxy or a retry is allowed to do, so read them carefully. “Safe” means the call has no side effects, so a proxy can cache it and a client can call it as often as it likes. “Idempotent” means repeating the same call leaves the server in the same state as making it once, so a client can retry after a timeout without duplicating work. GET is both, which is why lists and reads cache freely. POST is neither, which is why a dropped response after a create is genuinely dangerous, and the next lesson exists to fix exactly that.
Prefer /orders/{id}/items (a sub-collection) over /getOrderItems?order=.... Keep paths plural and hierarchical, and avoid encoding verbs like /createOrder. Once the resources and their safe/idempotent labels are fixed, the next question is what happens to a request between arriving and being answered.
Every stage can reject a request early
A request does not go straight to your handler. It passes through a chain, and each stage can turn it away with a specific status before the expensive work begins.
flowchart LR C["Client"] --> G["API gateway<br/>TLS, routing"] G --> A["AuthN / AuthZ"] A --> R["Rate limit check"] R --> V["Validate + parse"] V --> H["Handler / service"] H --> D["Data store"] H --> RESP["Response<br/>status + body + headers"]
The point of the chain is order: cheap rejections happen first. Auth fails before rate limiting spends a lookup, rate limiting rejects before validation parses a body, validation catches malformed input before the handler touches the data store. Each early exit keeps expensive work off the hot path and hands the client a precise reason for the failure. That precision is only useful if the status codes downstream actually mean distinct things, which is the next choice to get right.
Status codes that carry meaning
Pick codes that let a client branch on the outcome without reading the message. The families below cover almost everything a normal API returns.
200 OKfor a successful read or update,201 Createdfor a new resource (return itsLocation),202 Acceptedfor work that will finish asynchronously,204 No Contentfor a successful delete.400 Bad Requestfor malformed input,401 Unauthorizedfor missing or invalid credentials,403 Forbiddenfor authenticated-but-not-allowed,404 Not Foundfor a missing or hidden resource.409 Conflictfor a state clash (for example a duplicate create),422 Unprocessable Entityfor semantically invalid input,429 Too Many Requestswhen rate limited.5xxfor server faults. Clients may retry5xxand429; they must not blindly retry4xxother than429.
That last line is the one that matters for retries: a 4xx means the request itself is wrong, so retrying it unchanged just fails again, while a 5xx or 429 means “not now, try again later.” A code is only half the answer, though. When something fails, the client also needs a body it can act on.
A consistent error body clients can branch on
Return the same error shape everywhere, machine-readable, so clients decide what to do without parsing prose.
{
"error": {
"code": "insufficient_funds",
"message": "Wallet balance is below the requested amount.",
"request_id": "req_9f2c...",
"details": [{ "field": "amount", "issue": "exceeds_balance" }]
}
}
Each field has one job. The code is a stable enum the client switches on, the message is prose for a human, and request_id lets support trace one call through the logs when someone asks why it failed. Keep this shape identical across every endpoint, because the moment two endpoints disagree on error format, every client has to special-case them. Errors handled, the next failure mode is success at scale: a list endpoint that returns everything.
Never return an unbounded list
A collection that grows without limit will eventually return a response large enough to hurt both server and client, so bound every list. Two schemes are common, and the choice comes down to how big and how fast-changing the collection is.
- Offset/limit (
?limit=50&offset=100): simple and jumpable, but slow on deep pages and unstable when rows are inserted or deleted mid-scan. - Cursor (
?limit=50&cursor=eyJpZCI6...): the server returns an opaque cursor pointing at the last item; the client sends it back for the next page. Stable under writes and efficient because it maps to an indexed range scan. Prefer cursors for large or fast-changing collections.
Why offset degrades: offset=100 makes the database walk and discard the first 100 rows before it reaches your page, so deep pages get linearly slower, and if a row is inserted while a client pages through, everything shifts and an item can appear twice or be skipped. A cursor sidesteps both by naming where to resume, which the index can jump to directly.
Expose filtering and sorting as query parameters (?status=paid&sort=-created_at) and document exactly which fields are filterable, since each one implies an index you have to keep. Bounded lists keep today’s clients healthy; the next concern is the clients that outlive this release.
Version from day one, because clients outlive any release
A client written against today’s API will still be calling it after you have shipped ten changes, so plan for change before you need it.
- Put the major version in the path (
/v1/orders) or a header. A path version is the most visible and cache-friendly. - Treat these as backward-compatible (no new version needed): adding an endpoint, adding an optional request field, adding a response field.
- Treat these as breaking (new major version): removing or renaming a field, changing a type, making an optional field required, changing default behavior.
- Deprecate on a published timeline: announce, emit a
Deprecationheader, then remove only after the window closes.
The dividing line is simple to say out loud in an interview: additive is compatible, subtractive is breaking. Adding a field a client can ignore never hurts it; removing or renaming one it depends on always does. Versioning protects the shape of the contract. The last decision protects who is allowed to call it.
Authenticate before you authorize
These are two separate questions asked in order. Authentication settles who you are; authorization settles what you may do. Answer them in that order, because there is nothing to authorize until you know the caller.
- API keys identify a caller and suit server-to-server traffic; scope them and allow rotation.
- OAuth 2.0 / OIDC issues short-lived access tokens on behalf of a user, with refresh tokens for renewal. Use it for third-party and user-facing access.
- JWTs are self-contained signed tokens the server validates without a database lookup; keep their lifetime short because they are hard to revoke before expiry.
The JWT trade-off is the one to name: skipping the database lookup is exactly why a JWT is fast and also why it is hard to revoke, so a short lifetime is the price of that speed. Whatever the mechanism, enforce authorization in the service layer, never only in the client, because a check that lives only in the client is a check an attacker can delete.
Conclusion
A clean REST API comes down to a few decisions made deliberately:
- Model the domain as resources with stable identifiers, and let HTTP methods carry the operations.
- Know which endpoints are safe and which are idempotent, because that governs caching and retries.
- Use status codes and a consistent error body so clients can branch on failures without parsing prose.
- Bound every list with pagination, and prefer cursors for large or fast-changing collections.
- Version from day one and treat additive changes as compatible, removals and renames as breaking.
- Authenticate before authorizing, and enforce authorization in the service layer.
One line to remember: design the contract so a client can retry safely, branch on failures precisely, and keep calling after you have changed the API underneath it.
Further reading
- RFC 9110: HTTP Semantics: the authoritative definition of methods, status codes, and the safe/idempotent properties.
- MDN: HTTP request methods and HTTP response status codes: practical, readable references.
- Google Cloud API Design Guide: resource-oriented design at scale.
- Stripe API Reference: a widely studied example of consistent errors, pagination, and versioning in production.