LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

REST API Design Principles

apiresthttpapi-designopenapiidempotency

A well-designed API is intuitive to use and hard to misuse, and the difference between the two is almost never about clever features — it is about a handful of boring contracts kept consistently across every endpoint. The reason API design has principles at all is that an API is a promise to people you will never meet, who will build on your guarantees and curse your inconsistencies for years. Once a client depends on a behavior, that behavior is load-bearing whether you intended it or not, which makes the cheap-to-change decisions you make on day one expensive to change on day one thousand. This post walks the principles that survive contact with real traffic: how to model resources, what HTTP methods actually promise, how to return errors that a machine can act on, how to paginate without lying about consistency, how to version without breaking the world, and how to make unsafe operations safe to retry.


Resources, not actions

REST’s core idea is that your URLs name things (resources), and HTTP methods are the verbs you apply to them. The URL identifies the noun; the method says what to do.

# Good — nouns, methods carry the verb
GET    /users          # list users
GET    /users/123      # fetch one
POST   /users          # create
PUT    /users/123      # replace
PATCH  /users/123      # partial update
DELETE /users/123      # remove

# Bad — verbs in the path, method ignored
GET    /getUsers
POST   /createUser
POST   /deleteUser/123

The verb-in-the-path style works right up until you have getUser, fetchUser, getUserById, and getUserProfile and nobody can guess which one exists. Nouns plus a fixed set of methods is guessable: if you know the resource, you know the URL.

Two refinements matter in practice. Nest only to express ownership, and shallowly: /users/123/orders is fine for “orders belonging to this user,” but do not go three levels deep — /users/123/orders/456/items/789 is better expressed as /order-items/789 because the item has its own identity. And accept that not everything is a resource. Some operations are genuinely verbs — “search,” “transcode,” “send the password-reset email.” Forcing those into pure REST produces worse APIs than admitting it: a POST /password-resets (modeling the reset as a resource you create) or a pragmatic POST /users/123/actions/deactivate is more honest than pretending a state transition is a CRUD update. Dogmatic REST is not the goal; a predictable, guessable interface is.


HTTP methods and the idempotency contract

Each method carries two promises that clients, proxies, and your own retry logic rely on: whether it is safe (no side effects) and whether it is idempotent (calling it N times has the same effect as calling it once). These are not trivia — they decide whether a network library is allowed to retry a request automatically.

Method Purpose Safe Idempotent Request body
GET Read a resource Yes Yes No
HEAD Read headers only Yes Yes No
POST Create / non-idempotent action No No Yes
PUT Replace a resource wholesale No Yes Yes
PATCH Partially modify a resource No Not inherently Yes
DELETE Remove a resource No Yes No

The subtleties are where bugs live. PUT is idempotent because it replaces — sending the same full representation twice lands you in the same state. POST is not idempotent because each call creates another resource, which is exactly why a flaky network plus an auto-retrying client can charge a card twice (the fix is idempotency keys, below). PATCH is not inherently idempotent: a JSON Merge Patch that sets a field is idempotent, but a patch expressed as an operation like “increment balance by 10” is not — run it twice and the balance is wrong. And DELETE is idempotent in effect: deleting an already-deleted resource should still leave it deleted, which is why a second DELETE returning 404 versus 204 is a real design decision (many APIs return 204 both times to keep retries clean).


Status codes that tell the truth

Status codes are the first thing a client inspects, so they must be honest. The cardinal sin is the “200 OK with an error inside the body” — it forces every client to parse the body to find out if the request actually worked, defeating every piece of HTTP-aware tooling between you and them.

2xx success
  200 OK              standard success with a body
  201 Created         resource created (return its Location header)
  202 Accepted        accepted for async processing, not done yet
  204 No Content      success, deliberately no body (e.g. DELETE)

4xx the client must change something
  400 Bad Request     malformed syntax / unparseable
  401 Unauthorized    not authenticated (you don't know who they are)
  403 Forbidden       authenticated but not allowed
  404 Not Found       no such resource (or hide existence on purpose)
  409 Conflict        state conflict (version mismatch, duplicate)
  422 Unprocessable   syntactically fine, semantically invalid
  429 Too Many        rate limited — include Retry-After

5xx the server failed
  500 Internal Error  unhandled — never leak a stack trace
  503 Unavailable     down/overloaded — include Retry-After

The two most-confused pairs: 401 vs 403 — 401 means “I don’t know who you are” (authenticate and try again), 403 means “I know exactly who you are and you still can’t” (retrying won’t help). And 400 vs 422 — 400 is for requests you cannot parse, 422 for requests you parsed fine but that fail business validation (a well-formed JSON body with an invalid email). Getting these right lets clients branch correctly without guessing.


Design the error contract on purpose

Errors are part of your public API, used more often under pressure than your success paths, and they deserve a stable schema. The modern standard is RFC 9457 Problem Details for HTTP APIs (which obsoletes RFC 7807): a JSON object with a documented shape and a application/problem+json content type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Your request parameters didn't validate.",
  "status": 422,
  "detail": "The 'email' field is required and must be a valid address.",
  "instance": "/users",
  "errors": [
    { "field": "email", "message": "is required" }
  ]
}

Whatever shape you choose, three properties make an error contract good. It is stable — the structure never changes, so clients can rely on it. It carries a machine-readable code (type or an explicit code field) distinct from the human-readable message, so clients branch on the code while showing the message — never make clients string-match your prose, because the day you fix a typo you break their error handling. And it is actionable: a validation error names the offending fields. Return a single consistent error envelope across every endpoint; an API where each service invents its own error shape is exhausting to integrate against.


Pagination without lying

Every list endpoint must paginate — an endpoint that returns “all users” works in development and falls over in production. The real decision is offset-based versus cursor-based, and it is a decision about consistency, not just ergonomics.

OFFSET pagination:  ?page=3&per_page=20   →  SQL OFFSET 40 LIMIT 20
  page 1   page 2   page 3
  [....]   [....]   [....]
     ▲ a new row inserted here shifts everything right →
       so page 3 now repeats a row you already saw on page 2.

CURSOR pagination:  ?limit=20&after=eyJpZCI6MTI0fQ
  ...│ cursor points at a stable key (e.g. id 124) │...
  inserts elsewhere don't shift the window; you resume
  exactly after the last item you actually saw.

Offset pagination (?page=2&per_page=20) is trivial to implement and lets users jump to an arbitrary page, which is why it dominates UIs with page numbers. Its flaws are real: on a list that is being written to, rows shift between requests so you can see duplicates or skip items, and deep offsets (OFFSET 1000000) are slow because the database still walks all the skipped rows. Cursor pagination encodes “where you left off” as an opaque token pointing at a stable sort key; it is stable under inserts and stays fast at any depth, at the cost of no random page access and a slightly more complex implementation.

The honest rule: use cursors for large, append-heavy, or infinite-scroll datasets (feeds, logs, event streams) where consistency and deep-page performance matter; offset is acceptable for small, slow-changing, human-browsable lists. Either way, return the pagination metadata in a consistent place and expose a Link header or a next cursor so clients can follow pages without constructing URLs themselves.

1
2
3
4
{
  "data": [ { "id": 124, "name": "..." } ],
  "page": { "next": "eyJpZCI6MTI0fQ", "limit": 20 }
}

Filtering, sorting, and sparse responses

Give clients a consistent query grammar so they can ask for exactly what they need:

GET /products?category=electronics&min_price=100   # filter
GET /products?sort=-price,name                      # sort: -desc, +asc
GET /products?fields=id,name,price                  # sparse fieldset
GET /products?include=reviews                        # related resources

Conventions matter more than the specific choices: pick one sort syntax (a leading - for descending is common and compact) and use it everywhere. Sparse fieldsets (?fields=) let mobile clients shrink payloads without you building bespoke endpoints. The trap is letting filtering become an injection surface or an unbounded query generator — whitelist the filterable fields, cap the page size server-side regardless of what the client asks for, and never interpolate filter values straight into a query.


Versioning without breaking the world

Any API with external users eventually needs to make a breaking change, and the versioning strategy decides how much pain that causes. The realistic options:

Strategy Example Reality
URL path /v1/users Ugly but unambiguous, trivial to route and cache, most common
Custom header X-API-Version: 1 Cleaner URLs, but invisible in a browser and easy to forget
Accept header Accept: application/vnd.api+json; version=1 “Correct” per HTTP purists; awkward in practice
Query param /users?version=1 Easy to set, but muddies caching and feels like an afterthought

In practice URL path versioning wins for most public APIs because it is impossible to use by accident, easy to route and cache, and obvious in logs and docs. Whatever you choose, the more important discipline is what counts as breaking: removing a field, renaming one, changing a type, tightening validation, or changing an error code are all breaking. Adding an optional field or a new endpoint is not — so design clients to tolerate unknown fields (ignore what they don’t recognize) and you can evolve additively for a long time before you ever need /v2. When you do cut a new version, announce a deprecation window with a sunset date and emit a Deprecation header on the old one rather than yanking it.


Make unsafe operations safe to retry

Networks fail after the server did the work but before the response got back, so any non-idempotent operation that matters — payments, order creation — needs an idempotency key. The client generates a unique key per logical operation and sends it as a header; the server records the key with the result of the first call and replays that stored result for any retry carrying the same key.

POST /payments
Idempotency-Key: 8f14e45f-ea0a-4c1b-9b2a-7c0d3e9a1b22
Content-Type: application/json

{ "amount": 4200, "currency": "usd", "source": "tok_visa" }

The contract: the first request with a given key executes and the result is persisted against that key; subsequent requests with the same key return the same response without re-executing. This is how a client can safely retry a timed-out payment without the dreaded double charge, and it is a small amount of server-side bookkeeping (a key-to-result table with a TTL) for an enormous reduction in real-world failure modes. Stripe popularized the pattern; it belongs on any write endpoint where a duplicate would hurt.


Rate limiting, security, and the spec as contract

Three things round out a production API. Rate limiting protects you and signals limits honestly: return 429 Too Many Requests with a Retry-After, and expose the budget so well-behaved clients can self-throttle. The mechanics — token bucket, sliding window, distributed counters — are their own topic, covered in API rate limiting patterns.

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 4
X-RateLimit-Reset: 1609459200
Retry-After: 30

Security is non-negotiable: TLS everywhere (see HTTPS and TLS explained), authenticate every non-public endpoint, authorize at the object level (the classic breach is a valid user fetching another user’s /orders/456), validate and bound every input, and never leak internals in errors. The OWASP API Security Top 10 is the checklist to design against, and broken object-level authorization sits at the top of it for a reason.

Finally, treat the OpenAPI specification as the contract, not as documentation you write afterward. A machine-readable spec generates client SDKs, drives request validation, powers interactive docs, and catches breaking changes in CI by diffing against the previous version — the workflow covered in OpenAPI and Swagger. An API whose spec is the source of truth stays consistent almost by accident; one whose docs are hand-maintained drifts out of sync the first busy week.


Verdict

Good REST API design is the disciplined application of a few contracts, kept consistently: name resources as nouns and let methods carry the verbs; honor the safe/idempotent promises of those methods so retries are well-defined; return status codes that tell the truth instead of burying errors in a 200; ship a single stable, machine-readable error format across every endpoint; paginate with cursors when consistency and depth matter and offset only when they don’t; version in the URL and evolve additively so you rarely need to; and protect write endpoints with idempotency keys so a flaky network never double-charges anyone. None of this is exotic, and that is the point — an API is a long-lived promise, and the teams that keep it boring and predictable are the ones whose APIs are still pleasant to use a decade later. Design for the human on the other end who has to integrate against you under a deadline, make the correct usage the obvious one, and let the OpenAPI spec keep you honest.


Sources

Comments