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.

Lesson 0218 min read#rest#api-design#http

On this page

  1. Words used in this lesson
  2. Resources, not verbs
  3. Methods and status codes together
  4. A single error format
  5. Collections: filtering, sorting, pagination
  6. Data conventions that save pain
  7. Versioning
  8. Idempotency keys (safe retries)
  9. Partial updates: PUT vs PATCH
  10. Concurrency control with ETags
  11. Other operational conventions
  12. What about HATEOAS, GraphQL and RPC?
  13. Common mistakes
  14. Interview questions
  15. Key takeaways
  16. 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: GET reads, POST creates, PUT replaces, PATCH changes part, DELETE removes.
  • A status code is a three-digit number in the response that says how the request went. 2xx means success, 4xx means the client made a mistake, and 5xx means the server failed.
  • A header is a name-and-value pair sent with a request or response, such as Location or ETag. It carries information about the message, not the data itself.
  • A query parameter is a name=value pair after the ? in a URL, such as ?status=paid. A path parameter is a part of the path itself, such as the 7 in /orders/7.
  • JSON is a text format for data, made of objects {}, arrays [], strings, numbers, booleans and null.
  • 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:

CodeNameMeaning
200OKSuccess, with a body
201CreatedA new resource was made
202AcceptedThe work was accepted but is not finished yet
204No ContentSuccess, with no body
207Multi-StatusOne response holds a separate result for each item in a batch
304Not ModifiedWhat you already have is still current
400Bad RequestThe request is malformed or invalid
401UnauthorizedYou are not logged in (the request has no valid proof of who you are)
403ForbiddenYou are logged in, but not allowed
404Not FoundNo such resource
409ConflictThe request clashes with the current state (a duplicate, or an old version)
412Precondition FailedA condition you sent (such as If-Match) is false
415Unsupported Media TypeThe body format is not accepted
422Unprocessable ContentThe format is fine, but the values are invalid
429Too Many RequestsYou 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 /createOrderPOST /orders
GET /getOrderById?id=7GET /orders/7
POST /deleteOrderDELETE /orders/7
POST /updateOrderStatusPATCH /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/items is fine. /users/3/orders/7/items/2/refunds is a warning sign. When the path gets deeper than about two levels, use /refunds?orderId=7 or 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 practical POST /orders/7/cancel is 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.

OperationRequestSuccessCommon errors
ListGET /orders?status=paid&limit=20200 with items400 bad query, 401, 403
Read oneGET /orders/7200404, 403
CreatePOST /orders201 + Location: /orders/7400/422 validation, 409 duplicate
ReplacePUT /orders/7200 or 204404, 409, 412
Partial updatePATCH /orders/7200 (with body) or 204404, 409, 422
DeleteDELETE /orders/7204404 (or 204 again: idempotent)
Async workPOST /reports202 + 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:

JavaScript
{  "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 code or type that 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 requestId to support staff.

Never put stack traces or SQL in detail.

Collections: filtering, sorting, pagination

TEXT
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 sort parameter (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.
JavaScript
{  "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 as Z. 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: 4999 for ₹49.99) plus a currency code. Never use floats. A float is a decimal number stored in binary, and it can give small rounding errors (in JavaScript, 0.1 + 0.2 is 0.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, not isNotDisabled).
  • 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:

  1. 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.
  2. 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.
  3. Strategies:
StrategyExampleProsCons
URL path/v1/ordersObvious, easy to route, cache and testThe whole API is versioned at once; the URL "changes"
Header / media typeAccept: application/vnd.example.v2+jsonClean URLs, versions per resourceHidden, harder to try in a browser, easy to forget; caches need Vary: Accept
Query parameter/orders?version=2SimpleMixes the version with filters; easy to leave out

For most teams, URL versioning (/v1) is the practical default.

  1. Retire old versions with care: announce it, add Deprecation and Sunset response headers (Deprecation says the version is deprecated, meaning "stop using it"; Sunset gives the date it will stop working), measure who still calls the old version, and then remove it.
  2. 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:

HTTP
POST /v1/paymentsIdempotency-Key: 3d1c0b6e-6a9e-4c4e-9f0a-1a2b3c4d5e6f

What the server does:

  1. Look up the key (limited to this user).
  2. 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.
  3. Key seen, same request: return the stored response. Do not do the work again.
  4. Key seen, different request body: return 409/422. The key was reused for something else.
  5. 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.
  6. Delete keys after some time (for example 24 hours).
JavaScript
// 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

  • PUT replaces the whole resource. Missing fields are treated as cleared. This is dangerous when a client sends a partial body by mistake.
  • PATCH changes only the fields you send. The simple, common convention is JSON Merge Patch (RFC 7396): send only the fields to change, and null means "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.)

HTTP
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 Failed

An 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 standard Retry-After on 429) 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 ETag on GET responses allows cheap 304 checks: the client sends the label in If-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, like 207). 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

  1. Verbs and RPC names in paths (/getUsers), mixed with REST in other places.
  2. 200 with an error body, or different error shapes in different endpoints.
  3. No pagination on list endpoints.
  4. Breaking changes with no new version, or no process to retire old versions.
  5. Floats for money, local-time strings for dates, numeric IDs as JSON numbers.
  6. A POST that is not idempotent and gets retried blindly, which causes duplicates.
  7. Exposing the database structure (internal IDs, responses shaped like tables) instead of designing for what the client needs.
  8. 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 200 for 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 /v1 by default) and warn clients before you retire old versions.
  • Idempotency keys make POST safe to retry. ETags and If-Match prevent 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

Part of the study guide

Related series