← Back to the library
BackendHow it works · Deep dive · 18 min

REST API design that clients can rely on

How do you design an HTTP API whose methods, status codes, errors, pagination and versions let clients retry safely, avoid lost updates and survive change?

Good REST is less about URLs and more about letting HTTP semantics do work for you: safe and idempotent methods tell clients and proxies what may be retried, status codes and problem details tell them what to do next, and validators, idempotency keys and cursors make concurrent, unreliable traffic correct.

THE MENTAL MODEL

Treat every response as an instruction to a program, not a message to a human. The client, its HTTP library, caches and gateways all branch on the method, the status code and a few headers: may I retry, should I wait (Retry-After), is my copy stale (ETag), did someone else change this (412), is this endpoint going away (Deprecation, Sunset). Design resources around nouns the client cares about, keep the semantics of each method honest (GET never mutates, PUT and DELETE are idempotent, POST is made retryable with an Idempotency-Key), and publish the whole thing as an OpenAPI contract that your tests enforce. Fielding's REST also asks for hypermedia (HATEOAS); almost nobody ships it, and that is fine as long as the contract, not link-following, is what clients depend on.

HOW IT FITS TOGETHER

Optimistic concurrency with ETag and If-MatchTwo clients edit the same order. The second write carries a stale validator and is rejected with 412 instead of silently overwriting the first.
  1. Client A → APIGET /orders/42
  2. API → Client A200, ETag: "v7"The ETag is derived from a row version column, not from hashing the JSON.
  3. Client B → APIGET /orders/42
  4. API → Client B200, ETag: "v7"
  5. Client A → APIPATCH /orders/42, If-Match: "v7"
  6. API → DatabaseUPDATE ... WHERE id=42 AND version=7Compare and set in one statement; checking first and writing later reintroduces the race.
  7. Database → API1 row updated, version=8
  8. API → Client A200, ETag: "v8"
  9. Client B → APIPATCH /orders/42, If-Match: "v7"
  10. API → DatabaseUPDATE ... WHERE version=7
  11. Database → API0 rows updated
  12. API → Client B412 Precondition Failed + problem+jsonNot retryable as-is: B must GET again, merge or ask the user, then resend with the new ETag.
  13. Client B → APIPATCH without If-Match
  14. API → Client B428 Precondition RequiredRequiring the precondition (RFC 6585) stops clients that forget it from causing lost updates.
Status codes clients can act onRetry only when the failure is transient and the request is idempotent (by method or by Idempotency-Key). Always cap attempts and add jittered backoff.
MeaningClient actionRetry?
200 / 201 / 204Done; 201 carries Location of the new resourceUse the body or follow LocationNo
202 AcceptedQueued; a status resource tracks progressPoll the Location URL, honour Retry-AfterPoll, do not resubmit
304 Not ModifiedCached copy still valid (If-None-Match)Reuse the cached bodyNo
400 / 422Malformed or semantically invalid inputFix the request; show field errorsNo
401 / 403Not authenticated / not allowedRefresh credentials once (401); stop (403)401 once after refresh
404 / 410Missing / gone for goodStop; drop local references on 410No
409 ConflictState conflict, or same Idempotency-Key in flightRe-read state; for an in-flight key, waitKey in flight: yes, later
412 / 428Stale If-Match / precondition missingGET, merge, resend with the new ETagOnly after re-reading
429 Too Many RequestsRate limitedWait Retry-After or RateLimit resetYes, after the wait
500Server bug; outcome unknownReport; treat the write as maybe appliedOnly if idempotent
502 / 503 / 504Upstream or capacity problem; often transientBack off, honour Retry-After on 503Yes, if idempotent

KEY TERMS

Resources and method semantics
URLs name things (/orders/42/items), methods name actions. RFC 9110 defines GET, HEAD, OPTIONS and TRACE as safe, and those plus PUT and DELETE as idempotent; POST and PATCH are neither by default. Clients, proxies and HTTP libraries retry idempotent requests automatically after a dropped connection, so a GET that mutates or a non-idempotent PUT breaks under ordinary network failure. For complex read queries that do not fit in a URL, the new QUERY method (RFC 10008, June 2026) is a safe, idempotent, cacheable request with a body; until your stack supports it, a POST to a search resource is the pragmatic fallback.
Problem details (RFC 9457)
One error shape for the whole API: application/problem+json with type (a stable URI clients switch on), title, status, detail and instance, plus extension members such as errors[] for field validation or traceId. RFC 9457 obsoletes RFC 7807. Clients must ignore unknown extensions, so you can add fields without breaking anyone. Never put the only machine-readable signal in detail; that text is for humans and will change.
Idempotency keys for POST
The client generates a unique Idempotency-Key per logical operation and resends the same key on retry. The server stores key, request fingerprint and final response atomically: a replay returns the stored response, a concurrent duplicate gets 409, and the same key with a different body gets 422 (draft-ietf-httpapi-idempotency-key-header-08, still an Internet-Draft but widely implemented, e.g. by Stripe). Scope keys per client or account and expire them after a documented window.
Conditional requests
ETag plus If-None-Match turns polling and cache revalidation into cheap 304s. ETag plus If-Match turns writes into compare-and-set: the server applies the change only if the current representation still matches, otherwise 412. If-Match uses strong comparison, so weak validators (W/"...") cannot guard writes. Return 428 when a write arrives without a precondition you require. Set Cache-Control deliberately on every GET (private for per-user data), and pair 429 with Retry-After plus, if you want clients to pace themselves, the RateLimit and RateLimit-Policy fields from draft-ietf-httpapi-ratelimit-headers (still a draft, -11).
Pagination, filtering, field selection
Offset/limit is simple and lets users jump to page 37, but it degrades linearly and skips or duplicates rows when data changes between pages. Cursor (keyset) pagination encodes the last seen sort key in an opaque token: stable under inserts and index-friendly, at the cost of no random access. Keep filters and sorts explicit and allow-listed (?status=paid&sort=-created_at), require a unique tiebreaker in every sort, and offer sparse fieldsets (?fields=id,total) or expansions (?expand=customer) for over- and under-fetching instead of inventing new endpoints per screen.
Versioning and deprecation
The cheapest version is the one you never cut: additive changes (new optional fields, new endpoints, new enum values clients were told to tolerate) need no version. For breaking changes, version the whole API in the path (/v2) or with a dated header (Stripe-style), not per-endpoint. Announce removal with Deprecation (RFC 9745, a structured date such as @1767225599), Sunset (RFC 8594, an HTTP date not earlier than Deprecation) and a Link rel="deprecation" to the migration guide, then watch traffic by client before switching anything off.
Long-running and bulk operations
If work outlives a request timeout, return 202 Accepted with Location pointing to an operation resource (/operations/abc) that reports pending, succeeded with a link to the result, or failed with a problem document. For bulk writes, accept an array, process each item independently and return per-item results; RFC 9457 discourages a generic batch problem type, so report item failures inside the success body or a single problem with an errors[] extension.

IN YOUR STACK

TypeScript · Hono Server

import { Hono } from "hono";
const app = new Hono();
const problem = (status: number, type: string, title: string, detail?: string) =>
  new Response(JSON.stringify({ type, title, status, detail }), {
    status, headers: { "content-type": "application/problem+json" } });

app.get("/orders/:id", async (c) => {
  const order = await db.orders.find(c.req.param("id"));
  if (!order) return problem(404, "https://api.example.com/problems/not-found", "Order not found");
  c.header("ETag", `"v${order.version}"`);
  return c.json(order);
});

app.patch("/orders/:id", async (c) => {
  const ifMatch = c.req.header("If-Match");
  if (!ifMatch) return problem(428, "https://api.example.com/problems/precondition-required", "Send If-Match");
  const version = Number(ifMatch.replace(/^"v|"$/g, ""));
  // Compare-and-set in one statement: UPDATE ... WHERE id = $1 AND version = $2
  const updated = await db.orders.updateIfVersion(c.req.param("id"), version, await c.req.json());
  if (!updated) return problem(412, "https://api.example.com/problems/stale-version",
    "Order changed since you read it", "GET the order again and reapply your change.");
  c.header("ETag", `"v${updated.version}"`);
  return c.json(updated);
});
  • Derive the ETag from a version column so the precondition check and the write are one atomic statement. Hashing the serialized body works for caching but not for compare-and-set.
  • db.orders is your data layer; updateIfVersion returns null when zero rows matched.
  • Expose ETag in Access-Control-Expose-Headers if browsers on another origin need to read it.
Hono documentation (opens in new tab)

Python · FastAPI Server

import hashlib, json
from typing import Annotated
from fastapi import FastAPI, Header, Request
from fastapi.responses import JSONResponse

app = FastAPI()

def problem(status: int, slug: str, title: str) -> JSONResponse:
    return JSONResponse({"type": f"https://api.example.com/problems/{slug}", "title": title, "status": status},
                        status_code=status, media_type="application/problem+json")

@app.post("/payments", status_code=201)
async def create_payment(request: Request, idempotency_key: Annotated[str | None, Header()] = None):
    if not idempotency_key:
        return problem(400, "idempotency-key-missing", "Idempotency-Key is missing")
    body = await request.body()
    fingerprint = hashlib.sha256(body).hexdigest()
    # INSERT ... ON CONFLICT DO NOTHING: only one request wins the key
    record = await store.claim(account_id(request), idempotency_key, fingerprint)
    if record.fingerprint != fingerprint:
        return problem(422, "idempotency-key-reused", "Key reused with a different payload")
    if record.state == "in_progress" and not record.claimed_by_me:
        return problem(409, "idempotency-key-in-flight", "A request with this key is still running")
    if record.state == "done":
        return JSONResponse(record.response_body, status_code=record.response_status)  # replay
    payment = await charge(json.loads(body))
    await store.complete(record, 201, payment)
    return payment
  • store, account_id and charge are your persistence, auth and domain layers. claim must be atomic (unique constraint on account + key), or two concurrent retries both charge.
  • Status codes follow draft-ietf-httpapi-idempotency-key-header: 400 missing, 422 reused with a different payload, 409 still in flight.
  • Persist the final response, including failures you decided are terminal, so a replay returns exactly what the first caller would have seen.
FastAPI documentation (opens in new tab)

Go · net/http (Go 1.22+ routing) Server

type cursor struct {
	CreatedAt time.Time `json:"c"`
	ID        int64     `json:"i"`
}

func listOrders(w http.ResponseWriter, r *http.Request) {
	limit := max(1, min(parseIntOr(r.URL.Query().Get("limit"), 50), 200))
	after := cursor{CreatedAt: time.Now().Add(time.Hour), ID: math.MaxInt64}
	if raw := r.URL.Query().Get("cursor"); raw != "" {
		b, err := base64.RawURLEncoding.DecodeString(raw)
		if err != nil || json.Unmarshal(b, &after) != nil {
			writeProblem(w, 400, "invalid-cursor", "Cursor is malformed or expired")
			return
		}
	}
	// Keyset: (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3
	rows := queryOrders(r.Context(), after, limit+1)
	page := map[string]any{"data": rows[:min(len(rows), limit)]}
	if len(rows) > limit {
		last := rows[limit-1]
		b, _ := json.Marshal(cursor{last.CreatedAt, last.ID})
		page["next_cursor"] = base64.RawURLEncoding.EncodeToString(b)
	}
	writeJSON(w, 200, page)
}
// mux.HandleFunc("GET /v1/orders", listOrders)
  • Fetch limit+1 rows to know whether a next page exists without a COUNT(*). parseIntOr, queryOrders, writeJSON and writeProblem are small local helpers.
  • The id tiebreaker makes the order total; without it rows sharing a timestamp are skipped or repeated across pages. Back it with an index on (created_at DESC, id DESC).
  • Treat cursors as opaque. Signing or encrypting them stops clients from hand-crafting values you then have to support forever.
net/http (Go 1.22+ routing) documentation (opens in new tab)

Java · Spring Boot 3 / Spring MVC Server

@RestControllerAdvice
class ApiErrors {
  @ExceptionHandler(InsufficientStockException.class)
  ProblemDetail outOfStock(InsufficientStockException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT, "Only " + ex.available() + " left of " + ex.sku());
    pd.setType(URI.create("https://api.example.com/problems/insufficient-stock"));
    pd.setTitle("Not enough stock");
    pd.setProperty("sku", ex.sku());
    pd.setProperty("available", ex.available());
    return pd;
  }
}

@RestController
@RequestMapping("/v1/reports")
class ReportController {
  @PostMapping
  ResponseEntity<Void> start(@RequestBody ReportRequest req) {
    String opId = reports.enqueue(req); // work continues in a job runner
    return ResponseEntity.accepted()
        .location(URI.create("/v1/operations/" + opId))
        .header("Retry-After", "5")
        .build();
  }
}
  • spring.mvc.problemdetails.enabled=true renders Spring's own exceptions (validation, 405, 415) as RFC 9457 too, so framework and domain errors share one shape.
  • instance is filled from the request path when you leave it unset.
  • The operation resource should return 200 with status pending/succeeded/failed and, on success, a link to the produced report.
Spring Boot 3 / Spring MVC documentation (opens in new tab)

TypeScript · fetch (browser, Node, Deno) Client

const RETRYABLE = new Set([429, 502, 503, 504]);

export async function send(url: string, init: RequestInit & { idempotent?: boolean } = {}) {
  const headers = new Headers(init.headers);
  const method = (init.method ?? "GET").toUpperCase();
  if (method === "POST" && init.idempotent) headers.set("Idempotency-Key", crypto.randomUUID());
  const canRetry = ["GET", "HEAD", "PUT", "DELETE"].includes(method) || headers.has("Idempotency-Key");

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(10_000) });
    if (res.ok || !canRetry || !RETRYABLE.has(res.status) || attempt === 3) return res;
    const retryAfter = Number(res.headers.get("Retry-After"));
    const backoff = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : Math.random() * 2 ** attempt * 500; // full jitter
    await new Promise((r) => setTimeout(r, backoff));
  }
}

const res = await send("/v1/payments", { method: "POST", idempotent: true, body: JSON.stringify(p) });
if (res.headers.get("content-type")?.startsWith("application/problem+json")) {
  const problem = await res.json();
  if (problem.type === "https://api.example.com/problems/insufficient-stock") showStock(problem.available);
}
  • The key is generated once, outside the loop, so every retry of the same logical payment carries the same Idempotency-Key.
  • Branch on problem.type, never on title or detail text.
  • This sketch only handles the delta-seconds form of Retry-After and does not retry thrown network errors or timeouts; those are exactly the ambiguous cases the Idempotency-Key exists for, so catch and retry them the same way.
fetch (browser, Node, Deno) documentation (opens in new tab)

YAML · OpenAPI 3.2 Client + server

openapi: 3.2.0
info: { title: Orders API, version: "2026-11-01" }
paths:
  /v1/orders/{id}:
    get:
      operationId: getOrderV1
      deprecated: true
      responses:
        "200":
          description: The order
          headers:
            ETag: { schema: { type: string } }
            Deprecation: { description: "RFC 9745, e.g. @1767225599", schema: { type: string } }
            Sunset: { description: "RFC 8594 HTTP date", schema: { type: string } }
            Link: { description: '<https://api.example.com/docs/v2-migration>; rel="deprecation"', schema: { type: string } }
          content:
            application/json: { schema: { $ref: "#/components/schemas/OrderV1" } }
        "404": { $ref: "#/components/responses/Problem" }
components:
  responses:
    Problem:
      description: RFC 9457 problem details
      content:
        application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } }
  • OpenAPI 3.2 (current patch 3.2.1) adds a query operation for the HTTP QUERY method, additionalOperations for other methods, and streaming media types such as SSE.
  • Make the document the contract: generate clients from it, validate requests and responses against it in tests, and diff it in CI (oasdiff, Optic) to block breaking changes.
  • deprecated: true only changes documentation; the Deprecation and Sunset headers are what running clients and gateways can see.
OpenAPI 3.2 documentation (opens in new tab)

WHEN TO REACH FOR WHAT

REST + OpenAPI

Use when Public, partner or multi-team APIs over resources; you want HTTP caching, CDN support, curl-ability and the widest client ecosystem.

Avoid when One client composes deeply nested data from many sources per screen, or you need high-throughput typed streaming between internal services.

GraphQL

Use when Many first-party screens with different data needs over a shared graph, and you can invest in cost limits, trusted documents and DataLoader.

Avoid when Public APIs that need CDN caching and simple per-request rate limits, or simple CRUD with one consumer.

gRPC / Connect

Use when Internal service-to-service calls, streaming, strict schemas across many languages, low latency.

Avoid when Browser-first public APIs, cache-heavy reads, or consumers who expect curl and JSON.

RPC-style JSON over HTTP

Use when Operations are verbs that do not map to resources (POST /v1/invoices:send), within an otherwise RESTful API.

Avoid when As the whole design: you lose method semantics, caching and safe retries everywhere.

WHERE IT BITES

  • Lost updates from read-modify-write without preconditions. Two clients PATCH the same resource and the last one wins silently; require If-Match on writes and return 412 or 428.
  • Retrying non-idempotent POSTs from clients, gateways or service meshes. A timeout does not mean failure; without an Idempotency-Key the retry charges twice. Configure gateway retries only for idempotent methods.
  • Inconsistent error formats: HTML from the load balancer, {error} from one service, {message} from another, and 200 with an error body. Normalize everything at the edge to application/problem+json with stable type URIs.
  • Chattiness and N+1 endpoints: a list endpoint that forces one GET per item for details. Add ?expand= or embed the commonly needed fields, and batch reads (GET /orders?ids=1,2,3) instead of making clients loop.
  • Breaking changes shipped as "just a rename" or a newly required field. Mobile apps stay installed for years; make only additive changes within a version and enforce it with an OpenAPI diff in CI.
  • Offset pagination over a hot table: page 2000 scans and discards 100 000 rows, and concurrent inserts shift items between pages. Use keyset cursors for feeds and exports.

CLOSE THE AI. EXPLAIN THIS.

Two support agents edit the same refund and one change disappears; a mobile client double-charges after a timeout; a partner breaks when you rename a field. For each, name the HTTP mechanism (header, status code or contract rule) that prevents it and the exact response the client receives.

SOURCES

Explainer reviewed