Backend API Design Guide: REST, Errors, Auth, Versioning
One reference for backend API design: REST resources, HTTP methods, status codes, errors, pagination, versioning, auth, real-time and concurrency, all built from the RFCs and official docs.

17 min read · Last updated: October 25, 2026
Backend API design is the set of decisions that fix how clients talk to your server: what the resources are, which HTTP methods change them, which status codes and errors come back, how lists page, how versions change, who is allowed in, and what happens when two writes collide. Make these decisions once and every client stays simple.
This backend API design guide is the page I wish I had before my first public API. It covers REST resource design, HTTP methods, status codes, error bodies, pagination, versioning, security, real-time options and concurrency. Each section answers its question in the first paragraph and then gives you the detail, a decision table, and a link to go deeper.
Every rule here comes from a spec or from the docs of an API that serves a lot of traffic. Where something is my opinion, I say so.
Key takeaways
- HTTP has exact rules for methods. Per RFC 9110 (June 2022), GET, HEAD, OPTIONS and TRACE are safe, and PUT, DELETE plus the safe methods are idempotent. POST and PATCH are neither.
- Send errors as RFC 9457 Problem Details (
application/problem+json) with five standard fields:type,title,status,detail,instance. - Broken Object Level Authorization is #1 on the OWASP API Security Top 10 (2023). Check ownership on every object, on every request.
- Use opaque cursor tokens for pagination. Google's AIP-158 warns that adding pagination later is a breaking change.
- Stop lost updates with ETag + If-Match (a stale write gets 412). Make POST retries safe with an Idempotency-Key header (IETF draft-08, October 2026).
- Stripe ships API versions monthly with no breaking changes and saves breaking changes for two major releases a year. That is a model worth copying.
What is backend API design? A library you have used
Backend API design means deciding the contract between your server and everyone who calls it: the nouns (resources), the verbs (methods), the replies (status codes and bodies) and the rules (auth, limits, versions). A good design lets a stranger guess the next endpoint correctly without reading your docs.
The easiest way I know to explain it is a public library.
The library has things: books, members, loans. Every book has a call number that tells you exactly where it sits. You walk to the front desk and ask for one of a few things: look at a book, borrow it, return it, renew it. The librarian answers in a few standard ways: "here you go", "that doesn't exist", "you're not a member", "you're a member but that shelf is staff only", "come back in ten minutes, we're busy".
If you ask for every crime novel, nobody hands you 40,000 books. You get the first shelf and a slip that says where to continue. When the library changes its catalogue system, it keeps the old call numbers working for a while and puts up a notice with a date.
That is an API. Here is the mapping:
| Library | API concept | Where it is covered below |
|---|---|---|
| Books, members, loans | Resources | REST resource design |
| Call number on the shelf | URL / URI | REST resource design |
| "Look at, borrow, return" | HTTP methods | HTTP methods |
| The librarian's short answer | Status codes | Status codes |
| "Here's why, and what to do" | Error body | Error handling |
| "First shelf, continue here" | Pagination | Pagination |
| New catalogue with a notice | Versioning and deprecation | Versioning |
| Library card, staff badge | Authentication and authorization | Security |
| Two people editing one reservation | Concurrency | Concurrency |
| "We'll call you when it's back" | Webhooks, SSE, WebSockets | Real-time |
Keep this picture in your head. Every section below is one row of that table.
What is RESTful programming?
REST (Representational State Transfer) is an architectural style that Roy Fielding defined in his 2000 doctoral dissertation. It is a set of constraints, not a protocol. An API is RESTful when it models things as resources with stable URLs and changes them through a uniform set of operations, which on the web means HTTP methods.
Fielding's chapter lists six constraints:
- Client-server. The UI and the data storage are separate concerns.
- Stateless. Every request carries everything the server needs to understand it. No server-side session that the next request depends on.
- Cache. Responses say whether they can be cached.
- Uniform interface. One consistent way to interact with every resource.
- Layered system. A client can't tell whether it is talking to the origin server, a proxy or a CDN.
- Code on demand (optional). The server can send code for the client to run.
The uniform interface splits into four parts: resources are identified in requests, resources are changed through representations (usually JSON), messages describe themselves (method, headers and media type tell you how to read them), and hypermedia drives application state (responses contain links to what you can do next).
That last one, often called HATEOAS, is the part almost nobody implements. Fielding himself wrote on October 20, 2008: "I am getting frustrated by the number of people calling any HTTP-based interface a REST API."
So here is the honest version. Most "REST APIs" today are resource-oriented HTTP APIs. They use URLs for nouns, methods for verbs and status codes for outcomes, and they skip hypermedia. That is fine. It is still the most widely understood way to build a public API, and it is what the rest of this guide assumes. If you want the gentler intro first, read our REST API explained for beginners.
How to design a REST API, step by step
To design a REST API, start from the resources, not the endpoints. List your nouns, give each a collection URL and an item URL, map the operations onto HTTP methods, choose status codes and one error format, then add pagination, auth, versioning and concurrency control before the first client ships.
The order matters, because the later steps get expensive to change once people depend on you.
- List the resources. Write down the nouns your clients care about:
orders,customers,invoices. Not the actions. - Give each one two URLs. A collection (
/orders) and an item (/orders/{id}). Nest only when the child can't exist without the parent (/orders/{id}/items). - Map operations to methods. Read with GET, create with POST, replace with PUT, partially update with PATCH, remove with DELETE.
- Pick status codes and one error shape. Use the standard codes and RFC 9457 bodies everywhere.
- Design lists with pagination from day one. Cursor tokens, a default page size, a maximum.
- Decide auth and authorization. Who are you, and are you allowed to touch this exact object?
- Choose a versioning policy. Write down what counts as a breaking change before you ship v1.
- Add concurrency control. ETags for updates, idempotency keys for creates.
- Write the contract down. An OpenAPI file or equivalent, so clients and docs come from one source.
Resource naming rules that save arguments later
Use plural nouns for collections, lowercase with hyphens, and IDs in the path. Keep verbs out of URLs; the method is the verb.
| Do | Don't | Why |
|---|---|---|
GET /orders | GET /getOrders | The method already says "get" |
POST /orders | POST /orders/create | Same |
GET /orders/42 | GET /order?id=42 | The ID identifies the resource, so it belongs in the path |
GET /orders?status=paid | GET /paid-orders | Filters are query parameters, not new resources |
POST /orders/42/refunds | POST /refundOrder/42 | Model the action as a resource you create |
That last row is the trick for actions that don't fit CRUD. A refund, a cancellation or an export becomes a noun you POST. Our full guide to REST API URL naming conventions goes through the edge cases.
Which HTTP method should you use?
Use GET to read, POST to create or trigger something, PUT to replace a whole resource at a known URL, PATCH to change part of one, and DELETE to remove it. The choice matters because RFC 9110 defines which methods are safe (read-only) and which are idempotent (safe to repeat), and clients, proxies and retry logic rely on that.
RFC 9110 defines a method as safe when "its defined semantics are essentially read-only". It defines idempotent as a method whose intended effect is the same whether you send it once or many times. In its words: "PUT, DELETE, and safe request methods are idempotent."
RFC 5789 adds PATCH and says plainly that "PATCH is neither safe nor idempotent". The difference from PUT: with PUT the client asks that "the stored version be replaced", while with PATCH the body "contains a set of instructions" for changing it.
| Method | Safe | Idempotent | Use it for | Typical success code |
|---|---|---|---|---|
| GET | Yes | Yes | Read one item or a list | 200 |
| HEAD | Yes | Yes | Same as GET, headers only | 200 |
| OPTIONS | Yes | Yes | Ask what the server supports (CORS preflight) | 204 or 200 |
| POST | No | No | Create in a collection, or trigger an action | 201 with Location, or 200 |
| PUT | No | Yes | Replace the whole resource at a known URL | 200 or 204 |
| PATCH | No | No | Change some fields | 200 or 204 |
| DELETE | No | Yes | Remove | 204 |
Two practical rules come out of this table.
First, never change state on GET. Crawlers, link previews and prefetchers will call your GETs. If GET /orders/42/cancel cancels an order, something you don't control will eventually cancel it.
Second, POST and PATCH need help to be retried safely. A mobile client on a bad network will resend. Without an idempotency key, that means two orders. The concurrency section below fixes it.
Which HTTP status codes should an API return?
Return the most specific standard status code that is true: 2xx for success, 4xx when the client must change something before retrying, and 5xx when the server failed. Don't return 200 with an error inside the body. Every HTTP client, proxy, monitoring tool and AI agent reads the status code first.
You don't need all of them. These cover almost every API I have built:
| Code | Meaning (RFC) | Use it when |
|---|---|---|
| 200 OK | Success with a body | Reads, updates that return the resource |
| 201 Created | "one or more new resources" were created | POST created something; add a Location header |
| 204 No Content | Success, no body | DELETE, or an update that returns nothing |
| 400 Bad Request | Server "cannot or will not process the request" | Malformed JSON, wrong types, missing required headers |
| 401 Unauthorized | Not authenticated | No token, expired token. RFC 9110: the server "MUST send a WWW-Authenticate header field" |
| 403 Forbidden | "understood the request but refuses to fulfill it" | Logged in, but not allowed |
| 404 Not Found | No current representation | Doesn't exist, or you don't want to admit it exists |
| 409 Conflict | Conflict with the resource's current state | Duplicate email, order already shipped |
| 412 Precondition Failed | A condition in the request (like If-Match) was false | Stale update, see concurrency |
| 422 Unprocessable Content | Server understands the content but can't process it | Valid JSON that fails business rules |
| 428 Precondition Required | RFC 6585: server "requires the request to be conditional" | You demand If-Match and the client didn't send it |
| 429 Too Many Requests | RFC 6585: "too many requests in a given amount of time" | Rate limit hit; send Retry-After |
| 500 Internal Server Error | Unexpected condition | A bug. Log it, never leak the stack trace |
| 503 Service Unavailable | Server temporarily can't handle it | Maintenance, overload |
The pair people mix up most is 401 and 403. The name "Unauthorized" is a historical accident: 401 means "I don't know who you are", 403 means "I know who you are, and no".
The second most common mix-up is 400 and 422. My rule: 400 if the request can't be parsed or has the wrong shape, 422 if it parses fine but breaks a rule (a delivery date in the past).
One more opinion. When a user asks for an object they don't own, I return 404 instead of 403. A 403 confirms the object exists, which tells an attacker their guess was right.
How do you handle errors in an API?
Handle errors by returning the correct status code plus a body in one consistent, machine-readable format. The standard for this is RFC 9457, Problem Details for HTTP APIs (July 2023), which replaced RFC 7807. It defines the application/problem+json media type and five fields every client can rely on.
The five fields:
| Field | What it means |
|---|---|
type | A URI that identifies the kind of problem. Defaults to about:blank |
title | A short summary of the problem type, the same every time it happens |
status | The HTTP status code, repeated for convenience (advisory only) |
detail | A human explanation of this specific occurrence, aimed at helping the client fix it |
instance | A URI for this specific occurrence |
You can add your own fields. This is the example from the RFC itself:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"detail": "Your current balance is 30, but that costs 50.",
"instance": "/account/12345/msgs/abc",
"balance": 30,
"accounts": ["/account/12345", "/account/67890"]
}
Notice what makes this good. A program can switch on type. A human can read detail. The extra fields (balance, accounts) give the client what it needs to recover without a second request.
My error-handling rules on top of the RFC:
- One format for every error, including the ones your framework throws (404 on unknown routes, 405 on wrong methods).
- Validation errors list every bad field at once, not just the first one, so the user fixes the form in one go.
- Make
typea real page that explains the error. It is free documentation. - Never put stack traces, SQL or internal hostnames in a response. Log them with a request ID and return the ID.
There is a fair counterargument for internal services that only your own code calls, and I wrote it up in why your private API doesn't need HTTP status codes. For anything public, use the standard.
How should you paginate an API?
Paginate with an opaque cursor: the client sends a page size and the token from the previous response, and the server returns the next page plus a new token. An empty token means the end. Cursors stay correct when rows are inserted or deleted between requests, which offset pagination can't promise.
Google's AIP-158 is the clearest written rulebook I have found. The rules that matter:
- Request fields are
page_sizeandpage_token. Response hasnext_page_token. An empty token is the only end-of-list signal. - Tokens must be opaque and URL-safe. Base64 over a readable value does not count as opaque. Clients must never parse them.
- If
page_sizeis 0 or missing, the API picks a documented default and must not return an error. Too large gets reduced to the maximum. Negative is an error. - The API may return fewer items than asked for, even zero, before the end. Clients must follow the token, not count items.
- A
total_sizefield is optional, and it may be an estimate. - Pagination must be there from the start, because adding it later is a breaking change.
That last point is the one that bites. A list endpoint that returns everything today returns a page tomorrow, and every old client silently sees only the first page.
Offset (?page=3&limit=20) | Cursor (?page_token=...) | |
|---|---|---|
| Jump to page 50 | Yes | No |
| Stable when rows are inserted mid-scroll | No, items shift and repeat or vanish | Yes |
| Server can change internals later | Hard, the client knows it's an offset | Easy, the token is opaque |
| Good for | Small admin tables with page numbers | Feeds, infinite scroll, public APIs, sync jobs |
You can also expose the next page as a link. RFC 8288 (Web Linking) defines the Link header, so Link: </orders?page_token=abc>; rel="next" works for clients that prefer headers to body fields.
How do you version an API?
Version an API by promising that additive changes never break clients, and only bumping the version for breaking ones. The common mechanisms are a version in the URL path (/v1/orders), a version header, or a date-based version pinned per account. The policy matters more than the mechanism.
| Approach | Example | Good | Not so good |
|---|---|---|---|
| URL path | /v1/orders | Obvious, easy to route and cache, easy to test in a browser | Big jumps; v2 often means a rewrite |
| Request header | Api-Version: 2 | Clean URLs | Invisible in logs and links unless you record it |
| Date-based, pinned per account | Stripe-Version: 2026-09-30.endive | Small, frequent, opt-in upgrades | Needs real tooling to run many versions |
| Media type | Accept: application/vnd.example.v2+json | Very "REST" in theory | Awkward for most clients |
Stripe is the best public example of the date-based approach. According to its versioning docs, since the 2024-09-30.acacia release Stripe ships new API versions monthly with no breaking changes, and twice a year issues a major release that starts with breaking changes. The current version as of this writing is 2026-09-30.endive. Requests use the account's default version unless you override it with the Stripe-Version header, and webhook endpoints can be pinned to a version too.
For a smaller team, my recommendation is simpler: put /v1 in the path, and treat these as breaking, which means a new major version:
- Removing or renaming a field or endpoint
- Changing a field's type or meaning
- Adding a new required parameter
- Changing error codes that clients branch on
- Adding pagination to a list that didn't have it (straight from AIP-158)
Adding a new optional field, a new endpoint or a new enum value clients are told to expect is not breaking. Tell clients in the docs to ignore fields they don't know.
When you do retire something, announce it in the response itself. RFC 9745 (March 2025) defines a Deprecation header, and it works with the older Sunset header (RFC 8594) that says when the endpoint stops responding:
Deprecation: @1688169599
Sunset: Sun, 30 Jun 2024 23:59:59 UTC
Link: <https://api.example.com/docs/migrate-v2>; rel="deprecation"
The Sunset date must not be earlier than the Deprecation date. Old versions you forgot about are a security problem too, which is the next section.
How do you secure a REST API?
Secure a REST API by encrypting every request with TLS, authenticating every caller, checking authorization on every object and every field, limiting how much any caller can consume, and keeping an inventory of every endpoint and version you expose. The OWASP API Security Top 10 (2023) is the checklist: it lists the ten failures that actually get APIs breached.
Here is the 2023 list, with what each means in one line:
| OWASP 2023 | What goes wrong | What you do |
|---|---|---|
| API1 Broken Object Level Authorization | User A reads /orders/43, which belongs to user B | Check ownership on every object, every request |
| API2 Broken Authentication | Weak tokens, no expiry, credential stuffing | Use a proven auth library, short-lived tokens, rate-limit login |
| API3 Broken Object Property Level Authorization | API returns or accepts fields it shouldn't (is_admin) | Allow-list fields in and out |
| API4 Unrestricted Resource Consumption | No limits on size, rate or cost | Rate limits, max page size, request size caps, 429 |
| API5 Broken Function Level Authorization | Normal user calls /admin/... | Role checks on every admin route, deny by default |
| API6 Unrestricted Access to Sensitive Business Flows | Bots buy all the stock or spam signups | Flow-level limits, bot checks on the sensitive flows |
| API7 Server Side Request Forgery | API fetches a user-supplied URL, hits internal services | Allow-list destinations, block internal ranges |
| API8 Security Misconfiguration | Verbose errors, open CORS, missing headers | Hardened defaults, no stack traces |
| API9 Improper Inventory Management | Forgotten /v0 still live and unpatched | Track every version and host, retire on schedule |
| API10 Unsafe Consumption of APIs | Trusting data from third-party APIs blindly | Validate what you receive like user input |
Notice that three of the top five are about authorization, not login. Checking that someone has a valid token is the easy half. Checking that this token may touch this order is the half people forget.
Authentication: what to use
For server-to-server calls, API keys sent in a header over TLS are fine if you can rotate them. For users and third-party apps, use OAuth 2.0. RFC 9700, the OAuth 2.0 Security Best Current Practice (January 2025), sets the current rules:
- Public clients (mobile apps, SPAs) MUST use PKCE, and authorization servers must support it.
- Clients SHOULD NOT use the implicit grant.
- The resource owner password credentials grant MUST NOT be used.
- Refresh tokens for public clients MUST be sender-constrained or rotated.
- Access tokens SHOULD be audience-restricted to a specific resource server.
If you are building on Next.js and choosing an auth library, our comparison of Better Auth vs NextAuth covers the practical side. For the wider picture beyond APIs, see the cybersecurity basics every developer should know.
Rate limiting
When a caller goes over the limit, return 429 Too Many Requests. RFC 6585 says the response "MAY include a Retry-After header indicating how long to wait". Send it. Good clients back off when they see it, and that is how you stop a retry storm.
How do you handle concurrent updates and retries?
Handle concurrent updates with optimistic locking: return an ETag with every resource, require clients to send it back in If-Match when they update, and reply 412 Precondition Failed if it no longer matches. Handle retries of POST by accepting an Idempotency-Key header and replaying the first result for the same key.
These are two different problems.
The lost update. Alice and Bob both open order 42. Alice changes the address and saves. Bob changes the phone number and saves. Bob's PUT carries the old address, so Alice's change is gone and nobody gets an error.
RFC 9110 built If-Match for exactly this, "to prevent accidental overwrites when multiple user agents might be acting in parallel on the same resource". The flow:
- Client reads
GET /orders/42. Server returnsETag: "v7". - Client sends
PATCH /orders/42withIf-Match: "v7". - If the order is still at v7, the server applies it and returns
ETag: "v8". - If someone else already moved it to v8, the server returns 412 and the client re-reads.
- If the client didn't send
If-Matchat all and you require it, return 428 Precondition Required, which RFC 6585 created to prevent lost updates.
The duplicate create. A client sends POST /payments, the connection drops before the response arrives, and the client retries. POST isn't idempotent, so you just charged twice.
The fix is a client-generated key per logical operation. The IETF is standardising this as the Idempotency-Key header. It is still an Internet-Draft, not an RFC (draft-08, updated October 22, 2026), but it describes how many payment APIs already work. It is meant to make "non-idempotent HTTP methods such as POST or PATCH fault-tolerant", and it suggests these responses:
| Situation | Status |
|---|---|
| Key missing on an endpoint that requires it | 400 |
| Same key, different request body | 422 |
| Same key while the first request is still processing | 409 |
| Same key, same body, first request finished | Replay the stored response |
Store the key with the response for a fixed window (say 24 hours, your choice), scoped to the caller so one user's key can't collide with another's.
What are the real-time options for an API?
For real-time updates you have four options: polling (the client asks repeatedly), webhooks (your server calls theirs when something happens), Server-Sent Events (one long HTTP response the server keeps writing to), and WebSockets (a two-way connection). Pick the simplest one that meets your latency and direction needs.
| Option | Direction | Built on | Best for | Watch out for |
|---|---|---|---|---|
| Polling | Client asks | Plain HTTP | Rare changes, simple clients | Wasted requests, delay up to the poll interval |
| Webhooks | Server to server | HTTP POST to the client's URL | Notifying other backends (payment succeeded) | Retries, ordering, signing payloads so receivers can verify them |
| Server-Sent Events | Server to browser, one way | text/event-stream over HTTP | Live feeds, notifications, streaming AI tokens | Browser per-server connection limits with many tabs |
| WebSockets | Both ways | HTTP Upgrade, then its own framing (RFC 6455) | Chat, multiplayer, collaborative editing | Stateful connections to scale and authenticate |
A few details from the specs that change the decision.
SSE reconnects for you. The WHATWG standard defines the EventSource interface so servers can "push data to web pages over HTTP". When the connection drops, the browser reconnects and sends a Last-Event-ID header so you can resume from the right event. The server can tell it to stop by responding with 204. The spec also warns that clients with a per-server connection limit can run into trouble when several pages each open an EventSource to the same domain.
WebSockets start as HTTP. RFC 6455 (December 2011) describes a protocol that "enables two-way communication", and the client's handshake "is an HTTP Upgrade request". It uses ws:// and wss:// (TLS) schemes. Always use wss.
My default: webhooks for other servers, SSE for browsers that only need to listen, WebSockets only when the client also talks back at high frequency.
REST vs GraphQL vs gRPC: which should you use?
Use REST for public APIs and most CRUD backends, because every tool and developer already understands it. Use GraphQL when many different clients need different shapes of the same connected data. Use gRPC for internal service-to-service calls where a strict schema and streaming matter more than browser-friendliness.
What each one actually is, from its own spec:
- GraphQL describes itself as "a query language designed to build client applications". It has three operation types: query ("a read-only fetch"), mutation ("a write followed by a fetch") and subscription ("a long-lived request that fetches data in response to source events").
- gRPC uses Protocol Buffers by default, Google's open-source mechanism for serializing structured data, as both its interface definition language and its wire format. It runs over HTTP/2 framing with
Content-Type: application/grpc, and supports four method kinds: unary, server streaming, client streaming and bidirectional streaming.
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Shape of the API | Many URLs, one per resource | Usually one endpoint, client writes the query | Service methods defined in .proto files |
| Payload | Usually JSON | JSON responses | Protocol Buffers (binary) by default |
| Transport | Any HTTP version | Usually HTTP | HTTP/2 |
| HTTP caching | Works out of the box with GET | Harder, most queries go through one endpoint | Not the point |
| Streaming | Via SSE or WebSockets | Subscriptions | Built in, four modes |
| Contract | OpenAPI (optional) | Schema (required) | .proto (required) |
| Best fit | Public APIs, CRUD, webhooks | Many frontends over connected data | Internal microservices |
| Not great for | Clients that need ten resources in one round trip | Simple CRUD, heavy caching | Public browser clients |
When to use REST, and when not to
Use REST when:
- Outside developers will call your API and you want the shortest learning curve.
- Your data maps cleanly to resources with create, read, update, delete.
- You want CDN and browser caching on reads.
- You send webhooks, since those are HTTP POSTs anyway.
Don't reach for REST when:
- One screen needs data from eight resources and the round trips are hurting a mobile app. That is GraphQL's home ground.
- Two of your own services exchange thousands of small calls and you control both ends. gRPC's strict contract and streaming earn their setup cost there.
- The interaction is really a live two-way stream. That is a WebSocket, whatever the rest of the API looks like.
You can mix them. A common shape is REST at the public edge, gRPC between internal services, and SSE or WebSockets for the live parts. If you are preparing for interviews where you have to defend this kind of choice out loud, our post on why developers fail system design interviews covers how to talk through tradeoffs.
A small example: one endpoint with every rule applied
Here is one update endpoint that uses most of this guide at once: authentication, object-level authorization, If-Match concurrency, and RFC 9457 errors. It is written against the standard Web Request/Response API, so it reads the same in Next.js route handlers, Hono, Deno or Bun. authenticate and db stand in for your own auth and data layer.
// PATCH /v1/orders/:id
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await authenticate(req);
if (!user) {
return problem(401, "unauthenticated", "Send a valid bearer token.", {
"WWW-Authenticate": 'Bearer realm="api"',
});
}
const order = await db.orders.find(id);
if (!order || order.ownerId !== user.id) {
return problem(404, "not-found", `Order ${id} does not exist.`);
}
const ifMatch = req.headers.get("if-match");
if (!ifMatch) {
return problem(428, "precondition-required", "Send If-Match with the ETag you last read.");
}
if (ifMatch !== `"${order.version}"`) {
return problem(412, "stale-write", "This order changed since you read it. GET it again.");
}
const { shippingAddress, phone } = await req.json();
const updated = await db.orders.update(id, { shippingAddress, phone }, {
expectedVersion: order.version,
});
if (!updated) {
return problem(412, "stale-write", "This order changed since you read it. GET it again.");
}
return Response.json(updated, { headers: { ETag: `"${updated.version}"` } });
}
function problem(status: number, slug: string, detail: string, headers: Record<string, string> = {}) {
return new Response(
JSON.stringify({ type: `https://api.example.com/problems/${slug}`, title: slug.replace(/-/g, " "), status, detail }),
{ status, headers: { "Content-Type": "application/problem+json", ...headers } },
);
}
What each part is doing, in order:
- 401 with
WWW-Authenticate, because RFC 9110 says a 401 must carry it. - 404 for someone else's order, not 403. That is the API1 (BOLA) check, and it doesn't confirm the order exists.
- 428 when
If-Matchis missing, so no client can skip concurrency control by accident. - 412 when the ETag is stale.
- Only two named fields are read from the body. A client sending
"status": "refunded"or"ownerId"gets ignored. That is the API3 (property-level) check. expectedVersionon the write itself. The ETag check above and the update are two separate steps, so another request could land between them. Making the database update conditional on the version closes that gap. This is the line people leave out.- A new
ETagon success, so the client can make its next edit without re-reading.
And here is what the conversation looks like on the wire when Bob loses the race:
PATCH /v1/orders/42 HTTP/1.1
Authorization: Bearer eyJ...
If-Match: "7"
Content-Type: application/json
{"phone": "+1 555 0100"}
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/stale-write",
"title": "stale write",
"status": 412,
"detail": "This order changed since you read it. GET it again."
}
Bob's app re-reads, shows him Alice's new address, and he saves again. Nobody's change is lost.
The backend API design guide in one checklist: REST API best practices
These are the REST API best practices from every section above, in the order you should apply them. If you only bookmark one part of this page, bookmark this.
| Area | Rule |
|---|---|
| Resources | Plural nouns, IDs in the path, no verbs in URLs; actions become sub-resources |
| Methods | Never change state on GET; PUT replaces, PATCH changes part |
| Status codes | Most specific true code; 401 vs 403 and 400 vs 422 used correctly |
| Errors | RFC 9457 application/problem+json everywhere, all field errors at once, no stack traces |
| Pagination | Opaque cursor, default and maximum page size, from day one |
| Versioning | Written breaking-change policy; Deprecation and Sunset headers before removal |
| Auth | TLS everywhere; OAuth 2.0 per RFC 9700 for users; rotatable keys for servers |
| Authorization | Ownership check on every object, allow-listed fields in and out |
| Limits | Rate limits with 429 and Retry-After, caps on body and page size |
| Concurrency | ETag + If-Match on updates; Idempotency-Key on creates and payments |
| Real-time | Webhooks to servers, SSE to listening browsers, WebSockets for two-way |
| Contract | One OpenAPI (or GraphQL schema, or .proto) file as the source of truth |
FAQ
What is the difference between REST and a RESTful API?
REST is the architectural style Roy Fielding defined in 2000, with six constraints including statelessness and a uniform interface. A RESTful API is one that follows those constraints. In everyday use, people call any resource-oriented HTTP API RESTful even when it skips hypermedia links, which Fielding himself has publicly objected to.
Should I use PUT or PATCH to update a resource?
Use PUT when the client sends the complete resource and wants it replaced; it is idempotent, so retries are safe. Use PATCH when the client sends only the fields that change. RFC 5789 says PATCH is neither safe nor idempotent, so pair it with If-Match to avoid overwriting someone else's change.
Is it OK to return 200 with an error message in the body?
For a public API, no. Clients, proxies, monitoring and retry logic all read the status code before the body, so a 200 error looks like a success to all of them. Return the right 4xx or 5xx code with an RFC 9457 Problem Details body that explains what went wrong.
What is the best way to paginate an API?
For most APIs, cursor pagination with an opaque token. The client sends page_size and page_token, the server returns results and a next_page_token, and an empty token means the end. Google's AIP-158 also requires a documented default page size and warns that adding pagination later is a breaking change.
Pick one endpoint you own this week and run it against the checklist above, then grab the free tools on Art of Code for the formatting and testing chores along the way. If you are new to all of this, start with REST API explained for beginners.
Written by Adithya Guttha, Founder of Art of Code.
Sign in to like and save posts.
Keep reading



Discussion
Comments (0)
Want to join the conversation?
Sign in to commentNo comments yet. Be the first to comment!