API Design Guide · Backend Engineering

REST API Design Best Practices: A Practitioner's Field Guide

A good REST API is predictable: resources named consistently, status codes trusted, errors machine-readable, pagination that survives scale, and mutating operations that are safe to retry. Every decision below comes from running production APIs serving thousands of integrators — including what broke, what we changed, and why.

~14 min read · For backend engineers & API designers · Next: Database Scaling Strategies →

URL & Resource Design

Name URLs after resources (nouns), never actions (verbs): plural collections, kebab-case segments, identifiers in the path, and behavior expressed through HTTP methods and query parameters. If your URL contains get, create, or do, you've written an RPC endpoint wearing REST clothing.

// Bad — RPC over HTTP
POST   /createOrder
GET    /getUserOrders?userId=42
POST   /orders/7/cancelOrder

// Good — resource-oriented
POST   /orders               // create an order
GET    /users/42/orders      // list orders belonging to user 42
PATCH  /orders/7             // { "status": "cancelled" } — state transition
DELETE /orders/7             // remove, if the business allows deletion

The naming rules that matter

When to avoid nesting entirely

Flatten a nested route to a top-level collection with a filter parameter when any of these hold: the child has a lifecycle independent of the parent (comments survive post deletion for moderation), the child is reachable from multiple parents (a product appearing in many categories), or the URL would exceed two path segments beyond the root. Deep nesting also fragments cache keys, complicates permission checks, and forces clients to thread four IDs through every call.

// Instead of:
GET /tenants/8/workspaces/31/projects/55/tasks/902/comments

// Prefer:
GET /comments?task_id=902
// One canonical location, one permission check, one set of pagination params.

HTTP Methods & Status Codes: The Ones That Actually Matter

Five methods (GET, POST, PUT, PATCH, DELETE) and about a dozen status codes cover 95% of real API traffic. What matters more than coverage is honoring the idempotency contract of each method — clients build retry logic directly on top of it.

Method Safe Idempotent Typical Success Use For
GETYesYes200Read; must have no side effects
HEADYesYes200Metadata only — existence checks, cache validation
POSTNoNo201, 202Create; any non-idempotent operation
PUTNoYes200, 204Full replacement at a known URL
PATCHNoNot guaranteed*200Partial update
DELETENoYes204Removal; repeated calls converge to same state

*PATCH { "views": "increment" } is not idempotent; PATCH { "status": "archived" } is. Design patches to be idempotent wherever possible — retries become free.

Status code shortlist

Use these, in practice: 200 OK (read/update succeeded), 201 Created (with a Location header pointing at the new resource), 202 Accepted (queued for async processing), 204 No Content (delete or empty-body update), 304 Not Modified (conditional GET), 400 Bad Request (malformed), 401 Unauthorized (missing/expired credentials — the name lies; it means unauthenticated), 403 Forbidden (authenticated, not permitted), 404 Not Found, 409 Conflict (state clash: duplicate email, stale version), 422 Unprocessable Entity (well-formed but semantically invalid), 429 Too Many Requests, 500 Internal Server Error (your bug), 503 Service Unavailable (maintenance/overload).

Mistakes that break clients

Versioning Strategies

Version public APIs in the URL path (/v1/) — it's visible in logs, trivially debuggable with curl, and plays perfectly with CDNs. Header-based versioning buys cleaner URLs at the cost of invisibility; media-type versioning is the most principled and the least practical. Additive changes never bump a version — only breaking ones do.

Strategy Example Pros Cons
URL pathGET /v1/ordersObvious in logs and docs; curl-friendly; clean CDN/proxy routing; trivial load-balancer splitsCoarse-grained; URLs churn between versions; purists object
Custom headerX-API-Version: 2026-08-25Stable URLs; supports fine-grained or date-based versionsInvisible in logs/browser; breaks naive caching; support tickets spike ("works in Postman, not in prod")
Media typeAccept: application/vnd.acme.v2+jsonMost HTTP-correct; per-representation versioning; content negotiation built inHardest to implement and debug; poor tooling support; every client must set Accept correctly

Recommendation

Ship /v1/ for anything consumed outside your organization. Bump the major version only for breaking changes: removing or renaming fields, changing field semantics or types, making optional fields required, altering status-code behavior, or changing URL structure. Adding new optional fields, new endpoints, or new enum values is not breaking — clients that follow forward-compatible parsing (ignore unknown fields) absorb those for free, and you should say exactly that in your docs.

// Express: versioned routers share middleware, diverge handlers
app.use("/v1", v1Router);
app.use("/v2", v2Router);

// Deprecation signals on old versions (RFC 8594):
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/changelog>; rel="deprecation"

Run old and new versions in parallel for a deprecation window — six months minimum for public APIs — monitor per-version traffic, and email integrators who are still calling the sunset version. Traffic-based sunsetting beats calendar-based sunsetting: give laggards notice proportional to their usage, not a cliff.

Pagination, Filtering & Sorting

Default to cursor pagination. Offset pagination scans every skipped row and shifts results under the client when data is inserted mid-scroll. Reserve offset pagination for bounded datasets (under ~10K rows) or admin screens that genuinely need "jump to page 7."

Dimension Cursor Offset
Cost of deep pagesConstant — indexed seek past last-seen rowLinear — OFFSET 100000 reads and discards 100K rows
Rows inserted mid-scrollStable — no duplicates or skipsItems shift between pages; users see items twice or miss them
Jump to arbitrary pageNot supportedNative
Total countRequires separate COUNT(*) (expensive)Natural fit
Client complexityOpaque token; follow next_cursorTrivial — increment page number
Best forFeeds, infinite scroll, sync jobs, mobile appsAdmin dashboards, reports, bounded lists

Parameter design conventions

GET /orders?status=paid&created_after=2026-01-01T00:00:00Z&sort=-created_at,id&limit=100&cursor=eyJpZCI6IDEwNDJ9

Conventions worth standardizing across every endpoint:
- limit     : server-enforced default (50) and hard cap (1000). Never unbounded.
- sort      : "-" prefix = descending. ALWAYS append a unique tiebreaker column
              ("created_at,id") — unstable sorts corrupt cursors.
- cursor    : base64(JSON({ last_seen: { created_at, id } })). Sign it if the
              contents are user-visible; treat it as opaque either way.
- filters   : exact-match params named after fields; ranges via _after/_before;
              multiple values as repeated params (?status=open&status=paid).
{
  "data": [
    { "id": "ord_1042", "status": "paid", "total": 4900 },
    ...
  ],
  "pagination": {
    "next_cursor": "eyJpZCI6IDEwNTF9",
    "has_more": true,
    "limit": 100
  }
}

Two implementation notes that save pain later: encode the cursor's sort columns in the token itself so a client can't pair a stale cursor with a different sort, and return null for next_cursor on the final page rather than omitting the key — explicit beats absent when clients write deserialization code.

Error Responses: RFC 7807 problem+json

Return one machine-readable error envelope for every failure — application/problem+json (RFC 7807). Humans read the detail message; machines branch on the type/code field. Ad-hoc error shapes force every integrator to write bespoke parsing for your API alone.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
X-Request-ID: req_9f2ab71c

{
  "type": "https://api.example.com/errors/insufficient-inventory",
  "title": "Insufficient inventory",
  "status": 422,
  "detail": "Requested 12 units of SKU 'A-1042', but only 3 are available.",
  "instance": "/orders",
  "code": "INSUFFICIENT_INVENTORY",
  "request_id": "req_9f2ab71c",
  "errors": [
    { "field": "items[0].sku",       "value": "A-1042" },
    { "field": "items[0].quantity",  "issue": "exceeds_available_stock", "available": 3 }
  ]
}

The type URI doubles as documentation: serve a human-readable page there explaining the error, common causes, and how to fix it. It costs nothing and cuts support volume measurably.

Error code taxonomy

HTTP code When
400VALIDATION_ERRORMalformed JSON, wrong types, missing required fields
401UNAUTHENTICATEDMissing, malformed, or expired credentials
403FORBIDDENAuthenticated but lacking scope/permission for this action
404NOT_FOUNDResource doesn't exist or existence is hidden from this caller
409CONFLICTDuplicate unique key, concurrent modification, illegal state transition
422UNPROCESSABLE_ENTITYWell-formed request that violates business rules
429RATE_LIMITEDQuota exceeded — always paired with Retry-After
500INTERNAL_ERRORYour bug. Generic message + request_id; specifics go to logs only
503SERVICE_UNAVAILABLEPlanned maintenance or upstream dependency down

Three rules that prevent the worst incidents: never echo stack traces, SQL fragments, or library names in error bodies (they're reconnaissance gifts); echo a X-Request-ID back on every response so a single ID correlates the client report, your gateway logs, and your service traces; and distinguish "client sent garbage" (400) from "client sent something we understand but won't act on" (422) consistently — mixed usage trains integrators to ignore your codes.

Rate Limiting: Headers, Strategies, and 429 Handling

Rate-limit every public endpoint and advertise the current budget in response headers on every request — not just when the client exceeds it. Well-behaved clients self-throttle when they can see RateLimit-Remaining; everyone else gets a clean 429 with Retry-After instead of a cascade of timeouts.

// Every successful response (IETF draft-distributed-rate-limiting style,
// plus legacy X- headers most SDKs still parse):
RateLimit-Limit: 600              // requests allowed in the window
RateLimit-Remaining: 437          // left in the current window
RateLimit-Reset: 28               // seconds until the window resets
RateLimit-Policy: 600;w=60        // optional: policy disclosure

// On 429 only — the single most important header:
Retry-After: 28                   // seconds (or HTTP-date)

// Legacy but still expected by many clients:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 437
X-RateLimit-Reset: 1756128000     // epoch seconds

Choosing an algorithm

Key limits by API key first, fall back to IP for unauthenticated traffic. Apply per-endpoint weights so one expensive report-generation call doesn't consume the same quota as one health check. Store counters in Redis with atomic INCR/EXPIRE — checking-and-decrementing in application memory breaks the moment you run two instances.

What a proper 429 looks like

HTTP/1.1 429 Too Many Requests
Retry-After: 28
RateLimit-Remaining: 0
Content-Type: application/problem+json

{ "type": "https://api.example.com/errors/rate-limited",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "600 requests per 60 seconds exceeded. Retry after 28 seconds." }

On the client side, the correct consumption pattern is: respect Retry-After exactly when present; otherwise exponential backoff with jitter (base 500ms, factor 2, cap 30s) — jitter is non-negotiable, because synchronized retries from many clients re-create the thundering herd you were throttled to avoid.

Idempotency Keys: Making Retries Safe

Require an Idempotency-Key header on every POST that creates money movement or other critical side effects. Store the key with a request fingerprint and the final response, then replay the stored response on retry instead of executing twice. Networks retry; payment processors double-charge when you're not prepared.

// Express middleware sketch
async function idempotency(req, res, next) {
  if (req.method !== "POST") return next();
  const key = req.get("Idempotency-Key");
  if (!key) return res.status(400).json(problem("MISSING_IDEMPOTENCY_KEY"));

  const fingerprint = sha256(`${req.method}|${req.originalUrl}|${rawBody}`);

  const existing = await db.idempotency_keys.find(key);
  if (existing) {
    if (existing.fingerprint !== fingerprint)
      return res.status(422).json(problem(
        "IDEMPOTENCY_KEY_REUSED",
        "This key was already used with a different request body."));
    if (!existing.completed)
      return res.status(409).json(problem(
        "REQUEST_IN_FLIGHT",
        "Original request still processing; retry shortly."));
    return res.status(existing.status).json(existing.body);  // replay
  }

  await db.idempotency_keys.insert({ key, fingerprint, completed: false }); // claim
  res.locals.idempotency_key = key;
  next();
}

// After the handler resolves, persist { key -> {status, body}, completed: true }.
curl -X POST https://api.example.com/payments \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: 8f3c1e2a-7b44-4c9d-a1f2-5e6d8a90b3c1" \
  -H "Content-Type: application/json" \
  -d '{"amount": 4900, "currency": "usd", "customer": "cus_1042"}'

Implementation rules

Security Essentials

Authenticate every request with short-lived bearer tokens, authorize per-scope with deny-by-default, validate input against a schema at the boundary, and whitelist writable fields to prevent mass assignment. Most API breaches trace back to one of those four controls being missing — not to exotic cryptography failures.

Authentication & authorization

Mass assignment protection

// Vulnerable — the client controls which fields are written:
const user = await User.update(req.params.id, req.body);
// Attacker POSTs: { "bio": "hi", "role": "admin", "planTier": "enterprise" }

// Safe — explicit allowlist per endpoint:
const WRITABLE = ["displayName", "bio", "timezone"];
const patch = pick(req.body, ...WRITABLE);
const user = await User.update(req.params.id, patch);
// Privileged fields change only through dedicated admin endpoints,
// behind admin scopes, with audit logging.

Input validation & hygiene

Anti-Patterns Catalog

Ten mistakes account for most of the production API incidents and integrator complaints worth writing down. Each has a mechanical fix — none require a rewrite if you catch them early.

Anti-Pattern Why It Hurts Fix
Verbs in URLs (POST /getUser)Two conventions collide; HTTP caching and tooling become uselessResources + methods; actions as state transitions
Always-200 envelopesBreaks retries, monitoring, caching, gateway policiesCorrect status codes; envelope only for metadata
Unbounded list endpointsOne /orders call with no limit takes down the DB at scaleDefault limit=50, hard cap=1000, cursor pagination
"Just added a field" shipped as breakingRemoving/renaming/requiring breaks silent clients in productionAdditive-only within a version; version bump on any break
Stack traces / SQL in error bodiesFree reconnaissance: schema, versions, file paths exposedRFC 7807 + request_id; details go to server logs
Chatty N+1 APIsMobile clients make 50 calls to render one screen; latency explodes?include=customer embedding or batch endpoints
Undocumented magic values (type=3)Integrators guess, guess wrong, file ticketsNamed enums documented in the schema
No ETag / If-Match on mutable resourcesLost updates under concurrency; two support agents overwrite each otherETag + optimistic locking; 409/412 on stale writes
Tokens in query stringsLeaked via access logs, proxies, referrers, shared linksAuthorization header only
Naive timestamps ("2026-08-25")Timezone off-by-hours bugs surface months later in billingISO 8601 UTC everywhere, e.g. 2026-08-25T14:30:00Z

The meta-pattern

Nearly every entry above is the same failure: optimizing for the happy path today instead of the retry path tomorrow. Status codes, idempotency keys, cursors, and version discipline all exist because networks partition, clients time out, and data changes mid-scroll. Design for the second attempt first — the first one usually takes care of itself.

Continue Learning

Go deeper with the companion guides on system architecture, database scaling, and the backend roadmap.