Back to Blog
EngineeringOctober 25, 202631 min read

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.

Backend API Design Guide: REST, Errors, Auth, Versioning

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:

LibraryAPI conceptWhere it is covered below
Books, members, loansResourcesREST resource design
Call number on the shelfURL / URIREST resource design
"Look at, borrow, return"HTTP methodsHTTP methods
The librarian's short answerStatus codesStatus codes
"Here's why, and what to do"Error bodyError handling
"First shelf, continue here"PaginationPagination
New catalogue with a noticeVersioning and deprecationVersioning
Library card, staff badgeAuthentication and authorizationSecurity
Two people editing one reservationConcurrencyConcurrency
"We'll call you when it's back"Webhooks, SSE, WebSocketsReal-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:

  1. Client-server. The UI and the data storage are separate concerns.
  2. Stateless. Every request carries everything the server needs to understand it. No server-side session that the next request depends on.
  3. Cache. Responses say whether they can be cached.
  4. Uniform interface. One consistent way to interact with every resource.
  5. Layered system. A client can't tell whether it is talking to the origin server, a proxy or a CDN.
  6. 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.

  1. List the resources. Write down the nouns your clients care about: orders, customers, invoices. Not the actions.
  2. 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).
  3. Map operations to methods. Read with GET, create with POST, replace with PUT, partially update with PATCH, remove with DELETE.
  4. Pick status codes and one error shape. Use the standard codes and RFC 9457 bodies everywhere.
  5. Design lists with pagination from day one. Cursor tokens, a default page size, a maximum.
  6. Decide auth and authorization. Who are you, and are you allowed to touch this exact object?
  7. Choose a versioning policy. Write down what counts as a breaking change before you ship v1.
  8. Add concurrency control. ETags for updates, idempotency keys for creates.
  9. 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.

DoDon'tWhy
GET /ordersGET /getOrdersThe method already says "get"
POST /ordersPOST /orders/createSame
GET /orders/42GET /order?id=42The ID identifies the resource, so it belongs in the path
GET /orders?status=paidGET /paid-ordersFilters are query parameters, not new resources
POST /orders/42/refundsPOST /refundOrder/42Model 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.

MethodSafeIdempotentUse it forTypical success code
GETYesYesRead one item or a list200
HEADYesYesSame as GET, headers only200
OPTIONSYesYesAsk what the server supports (CORS preflight)204 or 200
POSTNoNoCreate in a collection, or trigger an action201 with Location, or 200
PUTNoYesReplace the whole resource at a known URL200 or 204
PATCHNoNoChange some fields200 or 204
DELETENoYesRemove204

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:

CodeMeaning (RFC)Use it when
200 OKSuccess with a bodyReads, updates that return the resource
201 Created"one or more new resources" were createdPOST created something; add a Location header
204 No ContentSuccess, no bodyDELETE, or an update that returns nothing
400 Bad RequestServer "cannot or will not process the request"Malformed JSON, wrong types, missing required headers
401 UnauthorizedNot authenticatedNo 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 FoundNo current representationDoesn't exist, or you don't want to admit it exists
409 ConflictConflict with the resource's current stateDuplicate email, order already shipped
412 Precondition FailedA condition in the request (like If-Match) was falseStale update, see concurrency
422 Unprocessable ContentServer understands the content but can't process itValid JSON that fails business rules
428 Precondition RequiredRFC 6585: server "requires the request to be conditional"You demand If-Match and the client didn't send it
429 Too Many RequestsRFC 6585: "too many requests in a given amount of time"Rate limit hit; send Retry-After
500 Internal Server ErrorUnexpected conditionA bug. Log it, never leak the stack trace
503 Service UnavailableServer temporarily can't handle itMaintenance, 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:

FieldWhat it means
typeA URI that identifies the kind of problem. Defaults to about:blank
titleA short summary of the problem type, the same every time it happens
statusThe HTTP status code, repeated for convenience (advisory only)
detailA human explanation of this specific occurrence, aimed at helping the client fix it
instanceA 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 type a 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_size and page_token. Response has next_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_size is 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_size field 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 50YesNo
Stable when rows are inserted mid-scrollNo, items shift and repeat or vanishYes
Server can change internals laterHard, the client knows it's an offsetEasy, the token is opaque
Good forSmall admin tables with page numbersFeeds, 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.

ApproachExampleGoodNot so good
URL path/v1/ordersObvious, easy to route and cache, easy to test in a browserBig jumps; v2 often means a rewrite
Request headerApi-Version: 2Clean URLsInvisible in logs and links unless you record it
Date-based, pinned per accountStripe-Version: 2026-09-30.endiveSmall, frequent, opt-in upgradesNeeds real tooling to run many versions
Media typeAccept: application/vnd.example.v2+jsonVery "REST" in theoryAwkward 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 2023What goes wrongWhat you do
API1 Broken Object Level AuthorizationUser A reads /orders/43, which belongs to user BCheck ownership on every object, every request
API2 Broken AuthenticationWeak tokens, no expiry, credential stuffingUse a proven auth library, short-lived tokens, rate-limit login
API3 Broken Object Property Level AuthorizationAPI returns or accepts fields it shouldn't (is_admin)Allow-list fields in and out
API4 Unrestricted Resource ConsumptionNo limits on size, rate or costRate limits, max page size, request size caps, 429
API5 Broken Function Level AuthorizationNormal user calls /admin/...Role checks on every admin route, deny by default
API6 Unrestricted Access to Sensitive Business FlowsBots buy all the stock or spam signupsFlow-level limits, bot checks on the sensitive flows
API7 Server Side Request ForgeryAPI fetches a user-supplied URL, hits internal servicesAllow-list destinations, block internal ranges
API8 Security MisconfigurationVerbose errors, open CORS, missing headersHardened defaults, no stack traces
API9 Improper Inventory ManagementForgotten /v0 still live and unpatchedTrack every version and host, retire on schedule
API10 Unsafe Consumption of APIsTrusting data from third-party APIs blindlyValidate 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:

  1. Client reads GET /orders/42. Server returns ETag: "v7".
  2. Client sends PATCH /orders/42 with If-Match: "v7".
  3. If the order is still at v7, the server applies it and returns ETag: "v8".
  4. If someone else already moved it to v8, the server returns 412 and the client re-reads.
  5. If the client didn't send If-Match at 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:

SituationStatus
Key missing on an endpoint that requires it400
Same key, different request body422
Same key while the first request is still processing409
Same key, same body, first request finishedReplay 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.

OptionDirectionBuilt onBest forWatch out for
PollingClient asksPlain HTTPRare changes, simple clientsWasted requests, delay up to the poll interval
WebhooksServer to serverHTTP POST to the client's URLNotifying other backends (payment succeeded)Retries, ordering, signing payloads so receivers can verify them
Server-Sent EventsServer to browser, one waytext/event-stream over HTTPLive feeds, notifications, streaming AI tokensBrowser per-server connection limits with many tabs
WebSocketsBoth waysHTTP Upgrade, then its own framing (RFC 6455)Chat, multiplayer, collaborative editingStateful 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.
RESTGraphQLgRPC
Shape of the APIMany URLs, one per resourceUsually one endpoint, client writes the queryService methods defined in .proto files
PayloadUsually JSONJSON responsesProtocol Buffers (binary) by default
TransportAny HTTP versionUsually HTTPHTTP/2
HTTP cachingWorks out of the box with GETHarder, most queries go through one endpointNot the point
StreamingVia SSE or WebSocketsSubscriptionsBuilt in, four modes
ContractOpenAPI (optional)Schema (required).proto (required)
Best fitPublic APIs, CRUD, webhooksMany frontends over connected dataInternal microservices
Not great forClients that need ten resources in one round tripSimple CRUD, heavy cachingPublic 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:

  1. 401 with WWW-Authenticate, because RFC 9110 says a 401 must carry it.
  2. 404 for someone else's order, not 403. That is the API1 (BOLA) check, and it doesn't confirm the order exists.
  3. 428 when If-Match is missing, so no client can skip concurrency control by accident.
  4. 412 when the ETag is stale.
  5. 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.
  6. expectedVersion on 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.
  7. A new ETag on 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.

AreaRule
ResourcesPlural nouns, IDs in the path, no verbs in URLs; actions become sub-resources
MethodsNever change state on GET; PUT replaces, PATCH changes part
Status codesMost specific true code; 401 vs 403 and 400 vs 422 used correctly
ErrorsRFC 9457 application/problem+json everywhere, all field errors at once, no stack traces
PaginationOpaque cursor, default and maximum page size, from day one
VersioningWritten breaking-change policy; Deprecation and Sunset headers before removal
AuthTLS everywhere; OAuth 2.0 per RFC 9700 for users; rotatable keys for servers
AuthorizationOwnership check on every object, allow-listed fields in and out
LimitsRate limits with 429 and Retry-After, caps on body and page size
ConcurrencyETag + If-Match on updates; Idempotency-Key on creates and payments
Real-timeWebhooks to servers, SSE to listening browsers, WebSockets for two-way
ContractOne 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.

#api design#rest api#backend#http#web development

Sign in to like and save posts.

Curious Adithya

Written by

Curious Adithya

@curious_adithya · 5 followers

Discussion

Comments (0)

Want to join the conversation?

Sign in to comment

No comments yet. Be the first to comment!