← Back to Blog

REST API Design Best Practices: Resource Modelling, Idempotency, and Versioning

An API Is a Product, Not a Mirror of Your Database

A familiar failure mode: expose every database table as a CRUD endpoint and you end up with a pile of /getUsers, /createOrder, and /updateUserStatus. It works, but every new client needs bespoke glue, and every schema change has to be announced to every consumer.

The value of REST is not that it looks tidy. It is that an endpoint can be guessed correctly without reading the documentation. Getting there starts with resource modelling, not with URL strings.

When you are debugging response bodies and matching up fields, pasting the returned JSON into a JSON formatter is the fastest first step; for deeply nested payloads, a JSON viewer that lets you collapse and expand beats scrolling through a terminal.

1. Resource Modelling: First Principles of URL Design

Use nouns, not verbs

The URL names the resource; the HTTP method names the action. Putting a verb in the URL throws away the semantic layer HTTP already gives you for free.

Anti-pattern Preferred Why
GET /getUsers GET /users GET already says read
POST /createOrder POST /orders POST already says create
POST /deleteUser/123 DELETE /users/123 Intermediaries cannot tell a destructive call is safe otherwise
GET /getUserOrders?uid=1 GET /users/1/orders Hierarchy belongs in the path, not a query parameter

Express hierarchy, but stop at two levels

/users/1/orders/2/items/3 is technically valid, but it locks clients into one fixed traversal path and forces a parent-existence check at every level.

Rules of thumb:

  • Use hierarchy when the relationship is exclusive and strongly owned: /users/1/orders
  • Flatten past two levels and filter with query parameters: /orders?userId=1&status=paid
  • If a resource has a globally unique id, let it be reachable as a top-level resource: /orders/2

Naming consistency

Rule Example Reason
Plural nouns for collections /orders, /users Matches collection semantics, avoids mixed singular/plural
Lowercase only /order-items, not /orderItems Parts of a URL are case-sensitive; mixing guarantees bugs
Hyphens for multi-word /order-items, not /order_items Hyphens need no escaping and read best
No trailing slash /orders, not /orders/ Some frameworks treat these as two distinct routes

What about non-CRUD actions

Some operations never map cleanly onto CRUD — publish, cancel, batch recalculate. Three options, in priority order:

  1. Model the action as a resource: POST /orders/1/cancellations. Cancelling becomes creating a cancellation record, which is clean and auditable.
  2. Model it as a state field: PATCH /orders/1 with { "status": "cancelled" }.
  3. Only as a last resort, a verb suffix: POST /orders/1:publish. Acceptable, but document clearly that it is not a resource.

2. HTTP Methods: Semantics and Idempotency

Method Meaning Safe Idempotent Typical response
GET Read a resource Yes Yes 200 + body
HEAD Headers only Yes Yes 200, no body
POST Create or sub-resource action No No 201 + Location
PUT Full replacement (may create) No Yes 200 / 201 / 204
PATCH Partial update No No by default 200 / 204
DELETE Delete No Yes 204 / 200
OPTIONS Ask which methods are supported Yes Yes 200 + Allow

For the full semantics and side-effect matrix, see the HTTP methods reference.

Idempotency is not an academic concern

Idempotent means executing the same request N times leaves the server in the same state as executing it once.

It matters because retries are guaranteed to happen: mobile network handovers, gateway timeout replays, at-least-once queue delivery, a user double-tapping a button. Any non-idempotent write endpoint produces duplicates without protection.

The two most common traps:

  • Creating orders with POST: two taps, two orders. Fix it by having the client send an Idempotency-Key header (a UUID generator is all you need), storing it server-side as a unique key for 24 hours, and replaying the original result on a hit.
  • Relative deltas via PATCH: { "balanceDelta": -100 } retried twice charges two hundred. Submit the absolute value { "balance": 900 } instead and idempotency follows automatically.

What POST should return

On success return 201 Created with the new resource URL in Location:

HTTP/1.1 201 Created
Location: /orders/9f3c...

If creation is asynchronous, return 202 Accepted with a status endpoint. Do not return 200 and pretend the work is done.

3. Status Codes: Layer Them, Do Not Return 200 for Everything

Returning 200 for everything and putting the error code in the body is the most common anti-pattern. It blinds monitoring (every request looks successful), confuses gateway caches, and forces clients to parse the body just to learn whether the call worked.

Class Meaning Common codes
2xx Success 200 OK / 201 Created / 202 Accepted / 204 No Content
3xx Redirection and caching 301 / 302 / 304 Not Modified
4xx Client error, retrying is pointless 400 / 401 / 403 / 404 / 409 / 422 / 429
5xx Server error, retrying is reasonable 500 / 502 / 503 / 504

For the complete list and common misuses, see the HTTP status codes reference.

Boundaries that are easy to get wrong:

  • 400 vs 422: when the syntax is valid but the semantics fail (end date before start date), use 422 Unprocessable Entity; malformed syntax is 400.
  • 401 vs 403: 401 means we do not know who you are; 403 means we know who you are and the answer is no. An authenticated user without permission gets 403.
  • 404 vs 403: returning 404 to hide the existence of a resource is a legitimate security practice.
  • 409 Conflict: a state conflict — duplicate submission, version mismatch, insufficient stock. It tells the client that retrying is useless until the state changes.
  • 429 Too Many Requests: rate limited. You must include Retry-After, otherwise clients retry blindly and turn overload into an avalanche.

4. A Uniform Error Format

Use RFC 7807 application/problem+json as the shared envelope:

{
  "type": "https://api.example.com/errors/order-already-paid",
  "title": "Order already paid",
  "status": 409,
  "detail": "Order 9f3c has already been paid and cannot be modified.",
  "instance": "/orders/9f3c",
  "code": "ORDER_ALREADY_PAID",
  "errors": [
    { "field": "quantity", "reason": "must be greater than 0" }
  ]
}

Key points:

  1. type is the stable contract. Clients branch on it, never on detail prose.
  2. code is a deliberate extension for localisation and monitoring — the same code can be grouped by directly in log queries.
  3. errors carries field-level validation detail. Form endpoints are unusable without it.
  4. Never echo stack traces, SQL, internal hostnames, or raw user input.

When a team needs to freeze the contract, generating a schema from real responses with a JSON Schema generator drifts far less than hand-written docs; TypeScript clients can generate types directly with JSON to TypeScript.

5. Pagination, Filtering, and Sorting

Dimension Offset pagination Cursor pagination
Syntax ?offset=100&limit=20 ?cursor=eyJpZCI6MTAwMH0&limit=20
Deep-page performance Degrades linearly with offset Constant
Drift from inserts Duplicates or skips Immune
Random page jumps Supported Not supported
Total count Easy Requires an extra count
Best for Admin back offices, reports Feeds, timelines, exports

Filtering and sorting should follow one global convention instead of being reinvented per endpoint:

  • Filtering: ?status=paid&created_after=2026-01-01
  • Sorting: ?sort=-created_at,amount (a leading minus means descending)
  • Field selection: ?fields=id,status,total (saves bandwidth, but be explicit about its effect on cache keys)
  • Totals: do not return total on every call; it is often the most expensive COUNT(*) in the system. Offer ?count=true when it is actually needed.

When comparing two revisions of a response, JSON Diff is far more reliable than eyeballing fields one by one.

6. Versioning

Strategy Example Pros Cons Best for
URL prefix /v1/orders Obvious, shareable, visible in logs Version leaks into the identifier Public APIs
Request header Accept: application/vnd.app.v1+json Clean URLs, proper content negotiation Hides the version, complicates cache config Internal services
Query parameter ?version=1 Trivial to implement Pollutes the query space, easily ignored Not recommended

More important: avoid bumping the version at all. These changes are backward compatible and need no new version:

  • New optional request fields
  • New response fields (clients must ignore unknown fields)
  • New endpoints
  • New Link headers or new error type values

These are breaking and require a new version or a staged rollout:

  • Removing or renaming a field
  • Tightening validation rules
  • Changing a field type or unit
  • Changing error semantics

7. Caching and Conditional Requests

Caching offers the highest performance return for the smallest change, and is the step most often skipped.

Header Direction Purpose
Cache-Control Response Declares the caching policy (max-age, no-store, private)
ETag Response Fingerprint of the resource version
If-None-Match Request Sends the last ETag; the server replies 304 if unchanged
Last-Modified / If-Modified-Since Both Time-based weak validation
Vary Response Which request headers the cache keys on (for example Accept-Encoding)
Retry-After Response Tells the client when to retry after 429 or 503

Working through a checklist is the cheapest way to catch gaps: HTTP headers reference.

Practical notes: GET is cacheable by default, which is exactly why it must never carry side effects (that is the real problem with delete-by-GET designs). Mark writes explicitly with no-store. Authenticated personalised responses must carry Cache-Control: private, otherwise a shared cache can leak one user's data to another.

8. The Minimum Consensus on Auth

  • Force HTTPS and 301-redirect plain HTTP. Certificate and TLS details are covered in the HTTPS certificate guide.
  • Do not invent an auth protocol. Use OAuth 2.1 / OIDC or a mature session mechanism. Token design, signing, and common pitfalls are in JWT security best practices.
  • Never store passwords in plaintext; use bcrypt, Argon2, or scrypt. The full tradeoff is in the password hashing guide, with the matching bcrypt hash tool.
  • Enforce authorisation server-side. Hiding a button in the UI is not access control.
  • Rate-limit by identity, not by IP, or users behind the same NAT will block each other.

When debugging a call, feeding a request copied out of the browser into cURL to code produces client snippets for each language far more reliably than transcribing headers by hand.

9. Anti-Pattern Checklist

Anti-pattern Consequence Fix
Every response is 200 Monitoring blind, caches misled Return layered status codes
Verbs in URLs Duplicated semantics, no HTTP caching Nouns plus HTTP methods
POST for every write Idempotency lost, retries duplicate data Distinguish POST / PUT / PATCH / DELETE
No idempotency key on non-idempotent writes A double tap creates two orders Introduce Idempotency-Key
Different error shapes per endpoint Clients cannot handle errors uniformly RFC 7807 envelope
Deeply nested URLs Coupled traversal, complex validation Flatten plus query filters
Offset for deep paging Slower the deeper you go, data drifts Switch to cursors
Breaking changes without a version bump Silently breaks every consumer Semantic versioning plus staged rollout
GET with side effects Triggered by crawlers and prefetch Use POST or PUT
Error bodies echo internals Information disclosure Return only safe, client-readable detail

Ship Checklist

Before releasing, verify each item:

  1. All URLs use plural nouns, lowercase, hyphenated, no verbs
  2. HTTP methods line up with semantics, safety, and idempotency
  3. Every write endpoint can answer "what happens on a retry"
  4. Non-idempotent writes support Idempotency-Key
  5. Status codes are layered 2xx/4xx/5xx; nothing returns 200 for everything
  6. Both 429 and 503 carry Retry-After
  7. Errors are uniformly application/problem+json with a stable type and a business code
  8. Validation errors include field-level detail
  9. Error bodies contain no stack traces, SQL, or internal hostnames
  10. Cursors are used once paging goes past two levels deep
  11. Filtering, sorting, and field-selection syntax is globally consistent
  12. Backward-compatible changes did not bump the version; breaking ones did and are staged
  13. GET is safely cacheable, writes declare no-store, personalised responses declare private
  14. HTTPS everywhere, HTTP is 301-redirected
  15. Authorisation enforced server-side; rate limiting keyed by identity
Advertisement

Frequently Asked Questions

Should I use PUT or PATCH?

**PUT means full replacement, PATCH means partial update.** PUT requires the client to submit the complete representation of the resource; missing fields are treated as deletions, and it is **idempotent by nature** — sending the same body a hundred times produces exactly the same result. PATCH submits only the fields being changed, which saves bandwidth, but it is **not idempotent by default**: an operation like `{ op: add, value: 1 }` produces a different result on the second try. In practice: **prefer PUT for setting a resource to a known state, and PATCH for changing a few specific fields**. If PATCH also needs to be idempotent, only accept absolute values from the client rather than relative deltas. The deciding test is retry safety — if the endpoint can ever be retried automatically by a gateway or client, it must be idempotent.

Should the API version go in the URL or in a header?

**There is no single right answer, but there are clear decision criteria.** A **URL prefix** (`/v1/orders`) is the most discoverable: you can open it in a browser, it shows up directly in logs and monitoring, and it is the cheapest to debug — the cost is that the version leaks into the resource identifier. **Header versioning** (`Accept: application/vnd.myapp.v1+json`) keeps URLs clean and follows HTTP content negotiation, at the cost of hiding the version, breaking shareable links, and complicating gateway cache configuration. **Pragmatic advice**: use a URL prefix for public APIs aimed at external developers, where discoverability wins; use a header for internal services, where evolvability wins. The more important principle is **avoid bumping the version at all**. Backward-compatible changes — new optional fields, new enum values, new endpoints — never need a new version number; only breaking changes do. Treat v1 to v2 as an expensive product decision, not a number you increment casually.

Should pagination use offset or cursor, and why does deep paging get slower?

**Use offset for shallow paging, and a cursor for deep paging or data that changes.** The problem with `?offset=100000&limit=20` is that the database must scan and discard the first hundred thousand rows before returning anything, so the deeper the page, the slower it gets. Worse, if rows are inserted while the user pages through, offset **causes duplicates or skipped records**. A cursor relies on the sort key of the last row on the previous page — usually an auto-increment id or a timestamp — to fetch the next batch: `?cursor=eyJpZCI6MTAwMH0&limit=20`. With a supporting index the lookup is direct, so **cost is constant regardless of depth** and it is immune to drift caused by inserts. **The tradeoff** is that a cursor cannot jump to an arbitrary page. The most common hybrid: **offset for admin back offices** that need page jumping and total counts, **cursors for consumer feeds** that only need load-more.

What format should error responses use? Isn't the HTTP status code enough?

**It is not. The status code expresses only the category of the failure; the client also needs a stable, machine-readable reason.** Returning a bare 400 with no detail forces the client to show the raw backend message to the user, and backend messages are rarely written for end users. **RFC 7807 (problem+json) is the recommended envelope**: it defines `type`, `title`, `status`, `detail`, and `instance`, and `application/problem+json` is natively supported by mainstream frameworks. Key practical points: **1. use the `type` field as a stable URI that identifies the error class, so clients branch on it rather than on prose; 2. add one custom extension field for a business error code** (for example `code: ORDER_ALREADY_PAID`) to support localisation and monitoring aggregation; **3. always include field-level details for validation errors**, otherwise form UX becomes unusable; **4. never echo stack traces, SQL, or internal hostnames in an error body.**

← Back to Blog