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.
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
- Plural nouns:
/orders, not/order. A collection holds many; a single item lives at/orders/{id}. - kebab-case for multi-word segments:
/payment-methods, not/payment_methodsor/paymentMethods. - Path identifies, query refines: identity in the path (
/orders/7), filtering/sorting/pagination in the query string. - Nest at most one level:
/users/{id}/ordersis fine./users/{id}/orders/{orderId}/items/{itemId}/reviewsis not. - No trailing verbs: model actions as state changes (
PATCHthe status) or, rarely, as documented sub-resources (POST /orders/7/cancellation).
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 |
|---|---|---|---|---|
| GET | Yes | Yes | 200 | Read; must have no side effects |
| HEAD | Yes | Yes | 200 | Metadata only — existence checks, cache validation |
| POST | No | No | 201, 202 | Create; any non-idempotent operation |
| PUT | No | Yes | 200, 204 | Full replacement at a known URL |
| PATCH | No | Not guaranteed* | 200 | Partial update |
| DELETE | No | Yes | 204 | Removal; 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
- 200 with an error body (
{ "success": false }): monitoring, caches, gateways, and retry libraries all read the status code. Your "failure" gets counted as success and cached. - 500 for validation errors: clients can't distinguish their bug from yours, so nobody fixes anything. Client-input problems are 400/422, always.
- 404 on repeated DELETE: makes a safe retry look like an error. If deletion is idempotent, return 204 again — or track tombstones.
- 201 for work that hasn't happened: if the request was enqueued, say so with 202 plus a status resource the client can poll.
- 403 vs 404 confusion: 404 on a resource the caller isn't allowed to know exists is legitimate security hygiene; 403 tells them it exists. Pick a policy per resource sensitivity and document it.
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 path | GET /v1/orders | Obvious in logs and docs; curl-friendly; clean CDN/proxy routing; trivial load-balancer splits | Coarse-grained; URLs churn between versions; purists object |
| Custom header | X-API-Version: 2026-08-25 | Stable URLs; supports fine-grained or date-based versions | Invisible in logs/browser; breaks naive caching; support tickets spike ("works in Postman, not in prod") |
| Media type | Accept: application/vnd.acme.v2+json | Most HTTP-correct; per-representation versioning; content negotiation built in | Hardest 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 pages | Constant — indexed seek past last-seen row | Linear — OFFSET 100000 reads and discards 100K rows |
| Rows inserted mid-scroll | Stable — no duplicates or skips | Items shift between pages; users see items twice or miss them |
| Jump to arbitrary page | Not supported | Native |
| Total count | Requires separate COUNT(*) (expensive) | Natural fit |
| Client complexity | Opaque token; follow next_cursor | Trivial — increment page number |
| Best for | Feeds, infinite scroll, sync jobs, mobile apps | Admin 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 |
|---|---|---|
| 400 | VALIDATION_ERROR | Malformed JSON, wrong types, missing required fields |
| 401 | UNAUTHENTICATED | Missing, malformed, or expired credentials |
| 403 | FORBIDDEN | Authenticated but lacking scope/permission for this action |
| 404 | NOT_FOUND | Resource doesn't exist or existence is hidden from this caller |
| 409 | CONFLICT | Duplicate unique key, concurrent modification, illegal state transition |
| 422 | UNPROCESSABLE_ENTITY | Well-formed request that violates business rules |
| 429 | RATE_LIMITED | Quota exceeded — always paired with Retry-After |
| 500 | INTERNAL_ERROR | Your bug. Generic message + request_id; specifics go to logs only |
| 503 | SERVICE_UNAVAILABLE | Planned 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
- Token bucket: bucket capacity N, refill r tokens/second. Tolerates short bursts up to N while enforcing a sustained rate of r/sec. The right default for most APIs (Stripe uses this shape).
- Sliding window counter: weighted blend of the previous and current fixed windows. Strict fairness, no burst allowance, cheap to compute with Redis — good for shared infrastructure quotas.
- Fixed window: a counter reset each minute. Simplest possible, but permits a 2x burst at window boundaries (59 requests at 00:00:59 + 59 at 00:01:00). Acceptable for coarse daily/monthly quotas; not for per-second abuse control.
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
- Scope keys per endpoint: uniqueness is (route, key), not global — two different operations can legitimately reuse a client-generated UUID.
- Set a TTL: 24 hours is standard; purge expired rows so the table doesn't grow forever.
- Fingerprint the request: reusing a key with a different body must fail loudly (422). Silent acceptance hides real client bugs.
- Handle concurrency: two simultaneous requests with the same key — one wins the claim, the other gets 409 REQUEST_IN_FLIGHT. Use a unique constraint on the key row, not application-level checks alone.
- Make the database idempotent anyway: unique constraints on natural keys (order number, charge reference) protect you after the key expires or when a caller forgets to send one. Keys are a convenience layer; constraints are the guarantee.
- Return identical responses on replay, including the original status code — a retry that returns 201 again is what lets naive clients sleep at night.
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
- TLS everywhere, no exceptions; add HSTS once you're sure. Tokens in the
Authorizationheader, never query strings — URLs end up in access logs, browser history, and referrer headers. - OAuth 2.0 flows by actor: authorization code + PKCE for user-facing apps, client credentials for machine-to-machine. JWTs short-lived (15 minutes or less) with rotating refresh tokens and server-side revocation for logout/compromise.
- Scopes checked per route in middleware (
orders:write, not justauthenticated); log every denial with actor and scope gap for audit. - Object-level checks, not just route-level: verifying a valid token isn't enough — verify the token's owner owns this order ID. Broken object-level authorization (BOLA) is the #1 API vulnerability in OWASP's API Top 10.
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
- Validate bodies and query params against a schema at the edge (Zod, Joi, JSON Schema); reject unknown fields explicitly rather than silently ignoring them — silent ignoring turns typos into mystery behavior (
?limt=10). Parameterized queries everywhere; ORM raw-query escapes reviewed like production code, because they are. - CORS: explicit origin allowlist. Reflecting the
Originheader back withAccess-Control-Allow-Origindefeats the entire mechanism while looking configured. - Enforce request body size limits at the gateway (1–10MB typical ceiling) — unbounded parsing is a memory-exhaustion DoS vector.
- Audit-log privileged mutations with actor, timestamp, and before/after diff. Your incident postmortem depends on it.
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 useless | Resources + methods; actions as state transitions |
| Always-200 envelopes | Breaks retries, monitoring, caching, gateway policies | Correct status codes; envelope only for metadata |
| Unbounded list endpoints | One /orders call with no limit takes down the DB at scale | Default limit=50, hard cap=1000, cursor pagination |
| "Just added a field" shipped as breaking | Removing/renaming/requiring breaks silent clients in production | Additive-only within a version; version bump on any break |
| Stack traces / SQL in error bodies | Free reconnaissance: schema, versions, file paths exposed | RFC 7807 + request_id; details go to server logs |
| Chatty N+1 APIs | Mobile 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 tickets | Named enums documented in the schema |
| No ETag / If-Match on mutable resources | Lost updates under concurrency; two support agents overwrite each other | ETag + optimistic locking; 409/412 on stale writes |
| Tokens in query strings | Leaked via access logs, proxies, referrers, shared links | Authorization header only |
| Naive timestamps ("2026-08-25") | Timezone off-by-hours bugs surface months later in billing | ISO 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.