Backend in Depth
REST API Design Best Practices: Resources, Versioning, Status Codes
Nouns, not verbs. One error format. Ways to version an API. Idempotency keys for POST, and partial updates.
On this page
- Words used in this lesson
- Resources, not verbs
- Methods and status codes together
- A single error format
- Collections: filtering, sorting, pagination
- Data conventions that save pain
- Versioning
- Idempotency keys (safe retries)
- Partial updates: PUT vs PATCH
- Concurrency control with ETags
- Other operational conventions
- What about HATEOAS, GraphQL and RPC?
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: a good REST API is predictable. Use nouns for resources, HTTP methods for actions, and status codes for results. Use one error format. Make changes that never break existing clients. Predictable APIs let frontend developers guess correctly.
You use APIs every day as a frontend engineer. This helps you, because you already know what annoys a client. This lesson turns those annoyances into design rules. It also covers senior-level topics: versioning, idempotency keys, partial updates, filtering and concurrency control.
Words used in this lesson
- An API (application programming interface) is a set of URLs and rules that one program uses to talk to another. Here, it is the server that your frontend calls.
- REST is a style for designing web APIs. Each thing has a URL (a resource), and you act on it with standard HTTP methods. It is a set of habits, not a strict standard.
- A client is any program that calls your API, such as your frontend, a mobile app or another service. An endpoint is one URL plus one method, such as
GET /orders/7. - An HTTP method is the verb of a request:
GETreads,POSTcreates,PUTreplaces,PATCHchanges part,DELETEremoves. - A status code is a three-digit number in the response that says how the request went.
2xxmeans success,4xxmeans the client made a mistake, and5xxmeans the server failed. - A header is a name-and-value pair sent with a request or response, such as
LocationorETag. It carries information about the message, not the data itself. - A query parameter is a
name=valuepair after the?in a URL, such as?status=paid. A path parameter is a part of the path itself, such as the7in/orders/7. - JSON is a text format for data, made of objects
{}, arrays[], strings, numbers, booleans andnull. - A schema is a written description of what shape the data must have (which fields, which types).
- Idempotent means that doing the same request many times has the same result as doing it once. (More below.)
The status codes used in this lesson:
| Code | Name | Meaning |
|---|---|---|
200 | OK | Success, with a body |
201 | Created | A new resource was made |
202 | Accepted | The work was accepted but is not finished yet |
204 | No Content | Success, with no body |
207 | Multi-Status | One response holds a separate result for each item in a batch |
304 | Not Modified | What you already have is still current |
400 | Bad Request | The request is malformed or invalid |
401 | Unauthorized | You are not logged in (the request has no valid proof of who you are) |
403 | Forbidden | You are logged in, but not allowed |
404 | Not Found | No such resource |
409 | Conflict | The request clashes with the current state (a duplicate, or an old version) |
412 | Precondition Failed | A condition you sent (such as If-Match) is false |
415 | Unsupported Media Type | The body format is not accepted |
422 | Unprocessable Content | The format is fine, but the values are invalid |
429 | Too Many Requests | You are being rate limited |
Resources, not verbs
REST models things (resources), like orders or users. The HTTP methods (GET, POST, DELETE) are the verbs.
| ❌ RPC-style | ✅ Resource-style |
|---|---|
POST /createOrder | POST /orders |
GET /getOrderById?id=7 | GET /orders/7 |
POST /deleteOrder | DELETE /orders/7 |
POST /updateOrderStatus | PATCH /orders/7 with { "status": "shipped" } |
(RPC means "remote procedure call": the URL names a function to run.)
Conventions:
- Use plural nouns for collections:
/orders,/users. One item is/orders/{id}. - Nest only when one thing truly belongs to another, and keep it shallow.
/orders/7/itemsis fine./users/3/orders/7/items/2/refundsis a warning sign. When the path gets deeper than about two levels, use/refunds?orderId=7or a top-level resource. - For actions that are not CRUD: model them as sub-resources or state changes (
POST /orders/7/cancellation,POST /payments/{id}/refunds). Do not put verbs in the path. A practicalPOST /orders/7/cancelis common and acceptable if you use it the same way everywhere. - Use lowercase, hyphenated paths (
/order-items). Use camelCase JSON fields (to match what the frontend uses). - Use IDs that are safe to show. A UUID (a 128-bit random-looking identifier, such as
3d1c0b6e-...) does not reveal how many records you have, and a random UUID is hard to guess. (A ULID is similar but starts with a timestamp, so it is a little more guessable.) Either way, you must still check authorization, which means checking that this user may see this record.
Methods and status codes together
When the mapping is always the same, nobody has to guess (HTTP semantics). The Location header tells the client the URL of the new resource.
| Operation | Request | Success | Common errors |
|---|---|---|---|
| List | GET /orders?status=paid&limit=20 | 200 with items | 400 bad query, 401, 403 |
| Read one | GET /orders/7 | 200 | 404, 403 |
| Create | POST /orders | 201 + Location: /orders/7 | 400/422 validation, 409 duplicate |
| Replace | PUT /orders/7 | 200 or 204 | 404, 409, 412 |
| Partial update | PATCH /orders/7 | 200 (with body) or 204 | 404, 409, 422 |
| Delete | DELETE /orders/7 | 204 | 404 (or 204 again: idempotent) |
| Async work | POST /reports | 202 + Location: /report-jobs/9 |
Never return 200 OK with an error in the body. Clients, API gateways, retry logic, monitoring and caches all read the status code. A 200 that says {"success": false} breaks all of them.
A single error format
Every error from every endpoint should have the same shape. The industry standard is Problem Details for HTTP APIs (RFC 9457, media type application/problem+json). It is a standard JSON shape for error responses:
{ "type": "https://api.example.com/errors/validation", "title": "Validation failed", "status": 422, "detail": "One or more fields are invalid.", "instance": "/v1/orders", "requestId": "01HZX3V7N8Q2", "errors": [ { "path": "items.0.qty", "code": "too_small", "message": "Must be at least 1" } ]}This helps the frontend in three ways:
- It can switch on a stable
codeortypethat a program can read. It does not need to read the message text. - It can show field-level errors next to the inputs.
- It can show the
requestIdto support staff.
Never put stack traces or SQL in detail.
Collections: filtering, sorting, pagination
GET /orders?status=paid&createdAfter=2026-01-01&sort=-createdAt&limit=20&cursor=eyJpZCI6MTAwfQ- Filter with query parameters named after fields. Check them and allow only known names. Do not pass them straight into queries.
- Sort with a
sortparameter (a-prefix means descending). Allow only indexed fields that you chose. - Always paginate, which means returning a long list in small pages. A list with no limit will cause an outage one day. Offset pagination says "skip N items, give me the next M". Cursor pagination says "give me the M items after this marker". Choose one on purpose (Practical Design Problems, Lesson 03). Set a maximum limit (for example 100). Return pagination info. Treat the cursor as an opaque string: clients must not parse it.
- Sparse fields (
fields=id,status) and expansion (expand=customer) let clients get less or more data. Add them only if you need them.
{ "data": [ { "id": "ord_1", "status": "paid" } ], "page": { "limit": 20, "nextCursor": "eyJpZCI6MTIwfQ", "hasMore": true }}An envelope is an outer object that wraps the real data, like { data, page }. Should you wrap the list in an envelope or return a bare array? That is a matter of taste. Wrapping is easier to grow, because you can add extra data later without breaking clients. So many teams choose it.
Data conventions that save pain
- Timestamps: use ISO 8601 in UTC (
2026-05-01T09:30:00Z). ISO 8601 is the international text format for dates and times. UTC is the world reference time zone, written asZ. Do not send local time or epoch milliseconds (a count of milliseconds since 1 January 1970) unless you document it. - Money: use whole numbers in the smallest unit (
amountMinor: 4999for ₹49.99) plus acurrencycode. Never use floats. A float is a decimal number stored in binary, and it can give small rounding errors (in JavaScript,0.1 + 0.2is0.30000000000000004). - Null vs missing: decide what each one means and write it down. For PATCH, for example: "
null= clear the field; missing = leave it unchanged". - Booleans: name them in a positive way (
isActive, notisNotDisabled). - Enums (a fixed list of allowed values): use stable strings (
"shipped"), not numbers. - IDs: use strings in JSON. JavaScript numbers lose precision above 9007199254740991 (2^53 - 1), so very large integer IDs can change when parsed.
Versioning
Versioning means giving the API a version number, so you can change it without breaking clients that still use the old shape. You do not control your clients, and they will not update when you do. Follow these rules:
- Prefer changes that only add things and do not break old clients. Adding an optional field, a new endpoint, or a new enum value (if clients ignore unknown values) needs no new version.
- A breaking change is a change that makes a correct old client stop working. It needs a new version. A change is breaking if it removes or renames a field, changes a type, changes a meaning, makes an optional field required, or makes validation stricter.
- Strategies:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/orders | Obvious, easy to route, cache and test | The whole API is versioned at once; the URL "changes" |
| Header / media type | Accept: application/vnd.example.v2+json | Clean URLs, versions per resource | Hidden, harder to try in a browser, easy to forget; caches need Vary: Accept |
| Query parameter | /orders?version=2 | Simple | Mixes the version with filters; easy to leave out |
For most teams, URL versioning (/v1) is the practical default.
- Retire old versions with care: announce it, add
DeprecationandSunsetresponse headers (Deprecationsays the version is deprecated, meaning "stop using it";Sunsetgives the date it will stop working), measure who still calls the old version, and then remove it. - Be tolerant on both sides. Clients should ignore unknown response fields, so added fields are safe. Servers should decide on purpose what to do with unknown request fields: ignore them, or reject them (stricter and safer, see Lesson 03).
Idempotency keys (safe retries)
An idempotency key is a unique value that the client puts on a request, so the server can recognise a repeat of the same request. "Idempotent" (defined at the top) means that doing the same request many times has the same result as doing it once. Think of a lift button: pressing it five times still calls the lift only once.
Networks fail. Say POST /payments times out. The client cannot know whether it worked. If it retries blindly, it might charge the customer twice. The fix:
POST /v1/paymentsIdempotency-Key: 3d1c0b6e-6a9e-4c4e-9f0a-1a2b3c4d5e6fWhat the server does:
- Look up the key (limited to this user).
- Key not seen before: process the request. Store
(key → request fingerprint, response). (A fingerprint is a hash, a short code computed from the request body, so you can tell whether two requests are the same.) Return the response. - Key seen, same request: return the stored response. Do not do the work again.
- Key seen, different request body: return
409/422. The key was reused for something else. - Two duplicates at the same time: use a unique constraint (a database rule that refuses a second row with the same value) or a lock on the key, so only one runs. The other one waits or gets
409. - Delete keys after some time (for example 24 hours).
// Simplified: the unique constraint is the real guard. If two requests race, the second// create() fails with a unique-violation error; catch it, re-read the key and replay.await prisma.$transaction(async (tx) => { const existing = await tx.idempotencyKey.findUnique({ where: { userId_key: { userId, key } } }); if (existing) return existing.response; // replay const result = await createPayment(tx, input); // do the work await tx.idempotencyKey.create({ data: { userId, key, response: result } }); return result;});GET, PUT and DELETE are idempotent by nature. POST is the one that needs a key. (PATCH may or may not be idempotent, depending on the change.)
Partial updates: PUT vs PATCH
PUTreplaces the whole resource. Missing fields are treated as cleared. This is dangerous when a client sends a partial body by mistake.PATCHchanges only the fields you send. The simple, common convention is JSON Merge Patch (RFC 7396): send only the fields to change, andnullmeans "clear this field". Arrays are replaced as a whole, not merged.- Check patch bodies with the same schema, made
.partial(). A "partial" schema is the same schema with every field made optional. Also reject unknown fields, so a typo does not succeed silently.
Concurrency control with ETags
Two users edit the same record. With no protection, the second save silently overwrites the first. This is called a "lost update". Concurrency means several things happening at the same time. The fix is optimistic concurrency: you assume conflicts are rare, and you detect them when they happen. (The opposite is pessimistic locking: lock the record first, so nobody else can change it.)
GET /articles/5 → 200, ETag: "v7"PUT /articles/5 If-Match: "v7" → 200 (and a new ETag "v8")# if someone else saved "v8" first: → 412 Precondition FailedAn ETag (entity tag) is a header whose value labels the current version of a resource. The client sends it back in the If-Match header, which means "only do this if the label still matches". If the label is old, the server refuses with 412. The client then reloads the data, merges the changes, and tries again. If a client sends no If-Match at all, you may refuse with 428 Precondition Required. You can also use a version column and return 409.
Other operational conventions
- Rate limiting means capping how many requests one client may send in a period. Rate limit headers on responses (
RateLimit-Limit,RateLimit-Remaining, which come from an IETF draft that is still changing, and the standardRetry-Afteron429) tell clients when to slow down (Practical Design Problems, Lesson 02). - Request IDs (a unique label for each request) in a response header and in error bodies, to help debugging (Lesson 05).
- CORS (cross-origin resource sharing) is the browser rule that decides which other websites may call your API. Set it up on purpose (Browser Contract, Lesson 04).
- Compression (gzip or Brotli, which make responses smaller) and caching headers (which let clients reuse a response) where they help. An
ETagonGETresponses allows cheap304checks: the client sends the label inIf-None-Match, and the server says "nothing changed" with no body. - Documentation as code: keep an OpenAPI description in the repo. OpenAPI is a standard file format (JSON or YAML) that describes every endpoint, input and output of an API. Ideally it generates types for the frontend. Then a mismatch between frontend and backend becomes a compile error (Lesson 03).
- Bulk operations: prefer clear endpoints (
POST /orders/batch) with a clear rule for partial failure, meaning some items work and some fail (per-item results, like207). This is better than overloading single-item endpoints.
What about HATEOAS, GraphQL and RPC?
"Pure" REST includes hypermedia links (links in responses that tell the client what it can do next; this is called HATEOAS). Most real APIs are "REST-ish": resources plus HTTP rules. That is fine. GraphQL is a query language for APIs where the client asks for exactly the fields it wants from one endpoint. It may fit when clients need very different shapes of the same data (Lesson 09). gRPC is a framework for calling functions on another service over the network with a strict typed contract; it is common for internal service-to-service calls. Choose on purpose, and be ready to say why.
Common mistakes
- Verbs and RPC names in paths (
/getUsers), mixed with REST in other places. 200with an error body, or different error shapes in different endpoints.- No pagination on list endpoints.
- Breaking changes with no new version, or no process to retire old versions.
- Floats for money, local-time strings for dates, numeric IDs as JSON numbers.
- A
POSTthat is not idempotent and gets retried blindly, which causes duplicates. - Exposing the database structure (internal IDs, responses shaped like tables) instead of designing for what the client needs.
- Filtering or sorting on fields that are not indexed or not checked. This invites slow queries and injection.
Interview questions
Design endpoints for orders and order items.
GET/POST /v1/orders, GET/PATCH /v1/orders/{id}, POST /v1/orders/{id}/cancellation for the cancel action, GET /v1/orders/{id}/items (items belong to an order), and filters like GET /v1/orders?status=paid&limit=20&cursor=…. Creates return 201 with Location. Errors use one problem-details format. Money is in minor units. Timestamps are ISO 8601 UTC. Lists are paginated with a maximum limit.
How do you version without breaking clients?
I make changes additive whenever I can. New optional fields and endpoints need no version. For breaking changes, I add a new version, usually in the URL (/v2), and run both versions side by side. I announce the end of the old one with Deprecation and Sunset headers. I watch how much the old version is still used, and I remove it only when the traffic is gone. Clients should ignore unknown fields, so additions are safe.
How do you make a payment POST safe to retry?
I require an Idempotency-Key header (a header used by many payment APIs and described in an IETF draft). The server saves the key, a fingerprint of the request and the response in a record with a unique constraint. If the same key comes again, the server returns the saved response and does not charge again. The same key with different content is rejected. Two duplicates at the same time are handled one after the other by the constraint. Keys expire after some time.
Is returning 200 with an error body ever OK?
No. Status codes drive client logic, retries, monitoring, alerts, caches and gateways. I use the correct 4xx/5xx code and put the details in one consistent problem-details body. The exception is a protocol that does this on purpose, like GraphQL with partial results. Then it is a documented rule, not an accident.
PUT vs PATCH?
PUT replaces the whole resource and is idempotent. Fields you leave out are cleared. PATCH changes only the fields you send, often with JSON Merge Patch rules. I use PATCH for partial edits. I check the body with a partial schema, reject unknown fields, and document how null is treated.
How do you prevent two users overwriting each other's edits?
I use optimistic concurrency. I return an ETag or version with the resource. I require If-Match (or the version) on updates. If it is old, I reply 412 or 409. The client reloads, merges and retries. For cases with many conflicts, I could use pessimistic locking (lock the record first) or operational transformation (an algorithm that merges edits made at the same time, used in live co-editing). But optimistic locking is enough for most CRUD apps.
What should a good error response contain?
It needs the correct status code and a stable code or type that a program can read. It needs a human title and detail. For validation errors, it needs field-level errors with paths. It also needs a request or trace ID. It must never show stack traces, SQL or internal host names. Every endpoint should use the same structure.
How would you design filtering and sorting safely?
I allow only listed fields and operators. I check types and ranges. I map them to indexed columns. I set a maximum page size and always paginate. I never put parameters straight into SQL. I use parameterised queries (the values are sent separately from the SQL text) or the ORM. I document the defaults and the tiebreaker, which is an extra sort column such as id that keeps the order stable when two rows have the same sort value.
Scenario: your public API needs a breaking change to a field that thousands of clients use. How do you ship it?
I avoid a hard break (a change that stops old clients at once). I add the new field next to the old one (an additive change). I announce the deprecation with Deprecation and Sunset headers and in the docs. I measure which clients still use the old field, and I give them a long time to move. If the change truly cannot be additive, I add /v2, run both versions side by side, move clients slowly, and remove v1 only when its traffic is gone. An OpenAPI breaking-change check in CI (the automatic checks that run on every change) stops accidental breaks.
Scenario: a client times out on POST /payments, retries, and the customer is charged twice. What do you change?
I require an Idempotency-Key for each payment attempt. The server stores the key with a request fingerprint under a unique constraint. It returns the saved response for repeats and rejects the same key with a different body. It also passes the key to the payment provider. This way, a crash between the provider call and our save cannot cause a double charge. The client must reuse the same key when it retries. It must treat a timeout as "unknown", not as "failed".
Key takeaways
- Nouns for resources, HTTP methods for actions, status codes for results. Never use
200for errors. - Use one problem-details error format, stable error codes and request IDs.
- Always paginate, cap page sizes, and allow only known filters and sorts.
- Prefer changes that only add things. Version breaking changes (URL
/v1by default) and warn clients before you retire old versions. - Idempotency keys make
POSTsafe to retry. ETags andIf-Matchprevent lost updates. - Use ISO 8601 UTC timestamps, whole numbers in minor units for money, and string IDs.
What's next
Lesson 03: Validation at the Edge. A well-designed contract is useless if the server trusts whatever arrives. Next: schemas, DTOs, and why you validate everything that crosses a boundary.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- HTTP Semantics Interviewers Ask: Methods, Idempotency, Status Codes, Redirects · The Browser Contract
- What Makes a Good API: Resources, URLs and the Contract · API Design
- HTTP Methods, Status Codes and Idempotency in Practice · API Design
Part of the study guide
Related series