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 /ordersto create, thenGET /orders/{id}to read it back. - Don't: use
GET /orders/{id}/deleteto 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 Conflictwhen a unique constraint is violated on create. - Don't: return
200 OKwith{"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
currencyfield to a response withinv1. - Don't: change
amountfrom a number to an object insidev1- 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: trueandSunset: Wed, 31 Dec 2026 23:59:59 GMTplus aLinkto the migration doc. - Don't: silently delete
/v1/legacy-reportand let callers discover it via404s.
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:onPOST /paymentsand 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=100000with400and 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.subownsorder/{id}before returning it, returning403if not. - Don't: rely on the client hiding a button, or on an
is_adminflag 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 sendStrict-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
429withRetry-After: 30when 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.yamlin 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}/ordersandGET /orders?customerId={id}consistently. - Don't: mix
/getCustomer,/Customers/, and/customer_listacross 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 aPUTwhoseIf-Matchdoes not match the current version. - Don't: let two concurrent updates silently overwrite each other's changes.