Skip to main content

Best practices

Use HTTP methods according to their defined semantics

Map operations to the method whose semantics match: GET for safe retrieval, PUT and DELETE for idempotent writes, POST for non-idempotent creation, PATCH for partial updates. Honouring these contracts lets caches, proxies, and clients reason about and safely retry requests.

  • Do: POST /orders to create, then GET /orders/{id} to read it back.
  • Don't: use GET /orders/{id}/delete to mutate state - a safe method must never change anything.

Return the semantically correct status code

Use the status code as the primary success/failure signal: 201 for created, 204 for no content, 400/422 for invalid input, 401/403 for auth, 404 for missing, 409 for conflict, 429 for rate limits, 5xx for server faults. Generic clients and intermediaries depend on the code, not on parsing prose.

  • Do: return 409 Conflict when a unique constraint is violated on create.
  • Don't: return 200 OK with {"error": "..."} in the body - it hides failure from every tool.

Standardise error bodies with Problem Details

Adopt RFC 9457 application/problem+json so every error has the same type, title, status, detail, and instance shape plus stable extension fields. A uniform error contract lets one client code path handle failures from every endpoint.

  • Do: return {"type": "https://errors.example.com/out-of-stock", "title": "Out of stock", "status": 409, "detail": "SKU-42 has 0 units"}.
  • Don't: invent a different ad-hoc error JSON shape per endpoint.

Version the API and only break in a new major

Choose one versioning scheme (URI like /v1, or media type) and keep changes within a major version backward compatible: add optional fields and endpoints, never remove or rename. Clients written against a major version must keep working until you deliberately retire it.

  • Do: add a new optional currency field to a response within v1.
  • Don't: change amount from a number to an object inside v1 - that breaks every existing parser.

Communicate deprecation before removal

When an endpoint or field is going away, signal it early with the Deprecation and Sunset headers (RFC 9745, RFC 8594), publish a migration guide, and keep the old behaviour through a stated window. Removing something clients still call, without warning, is the fastest way to break integrations.

  • Do: return Deprecation: true and Sunset: Wed, 31 Dec 2026 23:59:59 GMT plus a Link to the migration doc.
  • Don't: silently delete /v1/legacy-report and let callers discover it via 404s.

Make unsafe operations safe to retry with idempotency keys

Networks lose responses, so clients retry; ensure a retried create does not act twice by accepting a client-supplied Idempotency-Key. Store the key with its first response for a bounded window and return that stored response on a repeat.

  • Do: accept Idempotency-Key: on POST /payments and dedupe on it.
  • Don't: assume the client will never resend a POST - it will, after any timeout.

Default to cursor pagination for collections

Return collections in bounded pages and prefer cursor (keyset) pagination so results stay correct and fast even as data changes. Always cap the maximum page size and expose the next cursor or links consistently.

  • Do: respond with {"data": [...], "next_cursor": "eyJpZCI6..."} and accept ?after=&limit=20.
  • Don't: return an unbounded array of every row, or use large offsets that table-scan.

Validate and bound all input at the boundary

Treat every request as untrusted: validate types, ranges, lengths, and enum values before processing, and reject malformed input fast with a clear 400/422. Cap array sizes, string lengths, and page sizes so a single request cannot exhaust resources.

  • Do: reject limit=100000 with 400 and a message stating the allowed maximum.
  • Don't: pass raw client input straight into a query or downstream call.

Authenticate and authorize on every request, server-side

Because REST is stateless, each request must carry credentials, and the server must verify them every time - validate a bearer token's signature, exp, iss, and aud - then separately authorize the specific action on the specific resource. Never trust a client-asserted role or ID.

  • Do: check token.sub owns order/{id} before returning it, returning 403 if not.
  • Don't: rely on the client hiding a button, or on an is_admin flag sent by the client.

Serve only over TLS

Expose the API exclusively over HTTPS so credentials, tokens, and payloads cannot be intercepted or tampered with, and reject plaintext HTTP. Require modern TLS (1.2+) and consider HSTS to prevent downgrade.

  • Do: redirect or refuse http:// and send Strict-Transport-Security.
  • Don't: accept tokens over an unencrypted connection "just for testing" in production.

Enforce rate limits and surface them in headers

Protect the service with per-client rate limits, reject excess with 429, and make the limits visible via RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After. Predictable limits let well-behaved clients self-throttle instead of being surprised.

  • Do: return 429 with Retry-After: 30 when the bucket is empty.
  • Don't: silently drop or hang requests over the limit, leaving clients to guess.

Design API-first from a written contract

Write the OpenAPI (or .proto/GraphQL schema) before implementing, review it with consumers, and generate docs, mocks, SDKs, and contract tests from it. The contract becomes the single source of truth and lets client and server teams work in parallel.

  • Do: keep openapi.yaml in version control and fail CI if the implementation drifts from it.
  • Don't: hand-write docs after the fact that slowly diverge from what the code actually returns.

Keep resource names consistent and noun-based

Use plural nouns for collections, a single casing convention, and HTTP verbs (not path verbs) for actions, and avoid nesting more than one or two levels deep. Consistency makes the API guessable and reduces the documentation a client must read.

  • Do: GET /customers/{id}/orders and GET /orders?customerId={id} consistently.
  • Don't: mix /getCustomer, /Customers/, and /customer_list across the same API.

Use ETags for caching and concurrency control

Return an ETag for cacheable resources so clients can revalidate with If-None-Match (getting a cheap 304) and update safely with If-Match, which rejects stale writes with 412. This cuts bandwidth and prevents lost updates without server-side locks.

  • Do: emit ETag: "v7" and reject a PUT whose If-Match does not match the current version.
  • Don't: let two concurrent updates silently overwrite each other's changes.