API Design & Structure Playbook
An opinionated, battle-tested appendix for designing HTTP APIs that stay evolvable, predictable, and kind to clients. Pulls from HATEOAS patterns, “Your API is Bad”, and other industry scars.
Goals and scope
- Make resource models and URLs obvious and stable.
- Keep verbs, status codes, and errors semantically correct.
- Build in evolution: linking, pagination, versioning, and compatibility rules.
Core principles
- Nouns over verbs: Model resources, not RPC actions. Keep verbs for rare domain commands.
- Stable identifiers: Immutable IDs, never positional indexes. Use opaque cursor tokens for pagination.
- One level of nesting, max: Show ownership/containment, but avoid deeply coupled URLs.
- Self-descriptive messages: Use media types and link relations (HATEOAS) so clients can follow contracts.
- Explicit contracts: Content types, schemas, error shapes, and version policies are part of the API, not tribal knowledge.
Resource modeling & URLs
- Plural, lowercased nouns:
/users,/cars. Avoid verbs like/createUser. - Canonical resource first:
/cars/{car_id}is the anchor; nested access is a convenience, not the only path. - Controlled nesting (1 level): Show ownership or context, but keep a top-level escape hatch.
- Good:
/users/{user_id}/cars(list owned cars),/users/{user_id}/cars/{car_id}(scoped fetch). - Also provide:
/cars?owner_id={user_id}(top-level lookup without deep coupling). - Avoid:
/orgs/{org_id}/users/{user_id}/cars/{car_id}/tires/{tire_id}(fragile, over-coupled).
- Good:
- Decision rules for nesting vs query:
- Nest when the parent is the only meaningful context (e.g.,
/users/{id}/sessions). - Keep top-level when the child has a life of its own or cross-cutting queries exist (
/cars/{id}even if owned by a user). - Expose filters to avoid path explosion for queries (
/cars?owner_id=...&status=...beats/users/{id}/cars/status/...).
- Nest when the parent is the only meaningful context (e.g.,
- Actions as sub-resources: When you must model verbs, make them nouns:
- Good:
POST /payments/{id}/capture,POST /users/{id}/email-verification. - Avoid:
POST /capturePaymentorGET /doDeleteUser?id=123.
- Good:
- Field selection & expansions: Use query params like
?fields=id,nameor?include=ownerinstead of bespoke endpoints. - Domain language: Prefer stable business terms over implementation words (
/invoices, not/billingRecords). - Multitenancy shape: Pick one and stick with it: path prefix (
/tenants/{tid}/users), header (X-Tenant-Id), or dedicated host ({tid}.api.example.com). Do not mix without a migration plan.
HTTP methods mapped to intent
- GET: Safe, idempotent reads only. No mutations, no side effects.
- POST: Create new subordinate resources or invoke domain actions that are not pure replacements. Use idempotency keys for retries.
- PUT: Full replacement of a resource. Idempotent.
- PATCH: Partial updates (RFC 7386 merge-patch or JSON Patch). Idempotent in effect.
- DELETE: Idempotent removal; responding 404 for repeated deletes is acceptable but consider 204 for already-deleted to aid retries.
- HEAD/OPTIONS: Support
HEADwhereGETexists;OPTIONSmust advertise allowed methods and CORS. - Safe vs unsafe: Do not overload GET for search that executes expensive side effects or mutations; use POST for complex searches that accept large bodies.
Representations, media types, and links
- JSON by default with explicit
Content-Type: application/json; charset=utf-8. - Link affordances (HATEOAS):
- Include
selfand key transitions:next,prev,related,collection,canonical. - Use standard relation names where possible (IANA link relations) or namespaced custom rels.
- Include
- Uniform envelopes: Represent lists as objects with
data,links,meta, not raw arrays, so pagination and metadata stay stable. - Consistent timestamps and IDs: ISO-8601 UTC (
2024-06-01T12:00:00Z), opaque IDs (UUID/KSUID/Snowflake).
Example list response with HATEOAS-style links:
{ "data": [ { "id": "car_123", "make": "Subaru", "model": "Forester", "links": { "self": "/cars/car_123", "owner": "/users/user_9" } } ], "links": { "self": "/users/user_9/cars", "next": "/users/user_9/cars?cursor=abc", "canonical": "/cars?owner_id=user_9" }, "meta": { "count": 1 }}Responses, status codes, and transitions
- 201 + Location for creations; body returns the new resource.
- 202 Accepted for long-running work; include
Locationof an operation/status resource and retry-after guidance. - 204 No Content when the body would be empty (DELETE success, idempotent completion).
- 304 Not Modified with
ETag/If-None-Matchto save bandwidth. - 409 Conflict for state conflicts (duplicate names, version mismatch, business rules).
- 412 Precondition Failed when
If-Match/If-Unmodified-Sinceguard fails. - 503 + Retry-After for maintenance; prefer shedding load with explicit backpressure signals.
- State transitions: Document allowed transitions and their codes (e.g.,
POST /payments/{id}/capturereturns 202 until settled, 409 if already settled, 422 if invalid state).
Errors that help instead of hurt
- Never 200 for failures. Use accurate status codes:
400for validation,401/403for auth,404for missing,409for conflicts,422for semantic validation,429for throttling,500/503for server/maintenance. - Structured errors: Prefer RFC 7807 Problem Details:
{ "type": "https://api.example.com/errors/validation", "title": "Invalid request", "status": 422, "detail": "car_id is required", "instance": "req-49ad", "errors": [ { "field": "car_id", "message": "missing" } ]}- Correlation: Return a request ID header (
X-Request-Id) echoed in error bodies. - No stack traces or debug: Never leak internals. Provide machine-safe codes and human-readable detail.
- Error catalog: Maintain stable error codes (
code: "duplicate_car",code: "forbidden_scope") with docs for remediation. - Partial failures: For bulk operations, return per-item statuses inside a multi-status envelope; avoid all-or-nothing 207 unless justified.
- Validation discipline: Return all field errors at once with JSON pointers/paths so clients can fix multiple issues.
Pagination, filtering, sorting, and searching
- Pagination: Cursor-based preferred (
?cursor=+links.next), with page size limits. Offset is acceptable for small, stable datasets; avoid when items can shift under you (it causes skips/dupes). - Filtering: Prefix with fields (
?owner_id=...,?status=pending). Avoid ad-hocfilterblobs unless you version them. - Sorting:
?sort=created_ator?sort=-created_at(descending). - Sparse fieldsets:
?fields=make,model,yearto reduce payloads. - Search: Use explicit endpoints (
/cars/search) or query params (?q=...) with documented semantics. - Deterministic ordering: Always define a tie-breaker (e.g.,
created_at desc, id desc) to keep pagination stable under inserts. - Total counts: Expensive counts should be optional (
?include_count=true) or eventually consistent; avoid blocking lists for exact totals.
Concurrency, caching, and retries
- Optimistic concurrency:
ETagon responses; requireIf-Matchon mutating requests to avoid lost updates. - Idempotency keys: For POST/command endpoints, accept
Idempotency-Keyheader; respond with the same status/body for retries. - Caching: Use
Cache-Control,ETag, andLast-Modified; never cache unsafe methods. Document which endpoints are cacheable. - Rate limits and backpressure: Return
429withRetry-AfterandX-RateLimit-*headers. Keep timeouts documented so clients set proper deadlines. - Replay safety: Treat network retries as normal; design handlers to be idempotent or key-protected.
- Conditional GET: Encourage
If-None-Matchto reduce bandwidth; pair with strong ETags to prevent stale reads in high-churn areas.
Async and long-running operations
- Pattern:
POST /reports→202 AcceptedwithLocation: /operations/{op_id}→GET /operations/{op_id}returnsstatus: pending/succeeded/failed,resultlink when done. - Do not block: Avoid holding connections for long tasks; push to queues/workers.
- Callbacks/webhooks: If pushing, sign webhooks, include timestamps and replay protection; expose
GET /webhook-deliveries/{id}for troubleshooting. - Cancellation: Provide
DELETE /operations/{op_id}when feasible; respond with409if not cancellable.
Bulk, partial, and batch operations
- Batch create/update: Accept arrays with per-item results; include
id,status, anderrorsper element. - Atomicity contract: Declare whether the batch is best-effort or all-or-nothing; default to best-effort and communicate failures clearly.
- Pagination for batch outputs: Large results should paginate or stream; avoid megabyte responses that exhaust clients.
Versioning and evolution
- Bias toward additive changes: New fields, new link rels, and new resources should not break clients.
- Deprecations are explicit:
DeprecationandSunsetheaders with dates, plus docs. - Version strategies: Pick one and stay consistent.
- URI versioning (
/v1/cars) keeps caches straightforward. - Header versioning (
Accept: application/vnd.example.cars+json;version=1) keeps URLs clean and plays well with content negotiation.
- URI versioning (
- Contracts as schemas: Machine-readable (OpenAPI/JSON Schema). Validate in CI and at runtime for boundary enforcement.
- Compatibility rules:
- Safe: adding optional fields, adding link relations, widening enums with default handling, adding endpoints.
- Breaking: removing/renaming fields, changing types, tightening validation, changing error codes without migration.
- Sunset playbook: Announce deprecations via headers + changelog, offer dual-write or shim periods, provide migration guides and timelines.
Security and trust
- Auth: OAuth2/OIDC or signed tokens. Reject missing/expired tokens with
401; reject unauthorized scopes/roles with403. - Transport: Always HTTPS; HSTS enabled.
- Input hygiene: Size limits, timeouts, and structured validation. Normalize encodings and forbid unexpected fields if you cannot safely ignore them.
- Auditability: Emit audit events for access to sensitive resources; include actor, target, action, and correlation IDs.
- Least privilege: Scope tokens to minimal permissions; prefer fine-grained scopes over role strings baked into endpoints.
- PII handling: Redact sensitive fields in logs; tokenize where possible; document data retention.
Testing, docs, and DX
- Contract tests: Consumer-driven contracts for critical clients; schema checks for every deployment.
- Golden examples: Provide runnable snippets and fixtures for each endpoint, including error cases.
- Mockable defaults: Offer sandbox keys and deterministic fixtures so clients can test without production data.
- Observability: Log request IDs, auth principal, latency buckets, and status codes; expose health/readiness endpoints.
- Drift detection: Monitor schema/object drift between prod responses and specs; alert on undocumented fields or missing fields.
- Load/latency SLOs: Publish SLOs and error budgets; test under those budgets with chaos for dependency failures.
- Docs shape: Each endpoint needs purpose, auth, request schema, response schema, examples, error codes, and pagination semantics in one place.
Hypermedia representation examples
- HAL-style
{ "_links": { "self": { "href": "/cars/car_123" }, "owner": { "href": "/users/user_9" }, "collection": { "href": "/cars" } }, "id": "car_123", "make": "Subaru", "model": "Forester"}- JSON:API-style collection with pagination
{ "data": [ { "type": "cars", "id": "car_123", "attributes": { "make": "Subaru", "model": "Forester" }, "relationships": { "owner": { "links": { "related": "/users/user_9" }, "data": { "type": "users", "id": "user_9" } } }, "links": { "self": "/cars/car_123" } } ], "links": { "self": "/cars?page[limit]=1&page[cursor]=abc", "next": "/cars?page[limit]=1&page[cursor]=def" }, "meta": { "count": 1 }}- Async operation pattern
// POST /reports// 202 Accepted, Location: /operations/op_789{ "status": "pending", "links": { "self": "/operations/op_789", "result": null }, "meta": { "poll_after_seconds": 3 }}
// GET /operations/op_789 (later){ "status": "succeeded", "links": { "self": "/operations/op_789", "result": "/reports/rpt_456" }}Payment hypermedia examples (intent → authorize → capture → refund)
- JSON:API-style payment
{ "data": { "type": "payments", "id": "pay_123", "attributes": { "status": "authorized", "amount": 4200, "currency": "USD", "capture_method": "manual", "authorized_at": "2024-06-01T12:00:00Z", "capturable_amount": 4200, "refundable_amount": 0 }, "relationships": { "customer": { "data": { "type": "customers", "id": "cus_9" }, "links": { "related": "/customers/cus_9" } }, "refunds": { "links": { "related": "/refunds?payment_id=pay_123" } } }, "links": { "self": "/payments/pay_123" } }, "links": { "capture": "/payments/pay_123/capture", "void": "/payments/pay_123/void" }}- HAL-style payment with operations and state
{ "_links": { "self": { "href": "/payments/pay_123" }, "customer": { "href": "/customers/cus_9" }, "refunds": { "href": "/refunds?payment_id=pay_123" }, "capture": { "href": "/payments/pay_123/capture", "method": "POST" }, "void": { "href": "/payments/pay_123/void", "method": "POST" }, "operations": { "href": "/operations?subject=pay_123" } }, "id": "pay_123", "status": "authorized", "amount": 4200, "currency": "USD", "authorized_at": "2024-06-01T12:00:00Z", "capturable_amount": 4200, "refundable_amount": 0}- Capture flow with async operation
POST /payments/pay_123/captureHeaders: Idempotency-Key: a1b2Body: { "amount": 3000 }→ 202 AcceptedLocation: /operations/op_555
GET /operations/op_555{ "status": "succeeded", "links": { "self": "/operations/op_555", "result": "/payments/pay_123" }, "meta": { "captured_amount": 3000, "remaining_capturable": 1200 }}- Refund example
{ "id": "ref_777", "payment_id": "pay_123", "amount": 1200, "currency": "USD", "status": "succeeded", "created_at": "2024-06-01T12:10:00Z", "links": { "self": "/refunds/ref_777", "payment": "/payments/pay_123" }}Common footguns to avoid
- Deep nesting that encodes workflow: Couples clients to your org chart and blocks refactors.
- Verb-heavy endpoints:
/doThing,/getUserCars—hard to reason about safety and caching. - 200 with error objects: Breaks intermediaries and client heuristics.
- Silent breaking changes: Renaming fields or changing types without version bumps.
- Leaky validation: Returning first error only; omit field paths; hide the cause. Provide actionable validation errors.
- Inconsistent pagination: Mixing offset and cursor in the same surface; omitting
next/prevlinks. - Enum surprises: Treat enums as open; clients must ignore unknown values, servers must not reject future-safe inputs without version gates.
- File uploads ad hoc: Use
POST /uploadswith signed URLs or multipart; avoid base64 blobs in JSON. - Implicit timezones: Always explicit UTC; never local server time.
Domain pattern: Payments (auth/capture/refund)
- Resources
/payments(canonical payment resource: auth/capture/refund status)/payment-intents(if you separate intent from execution)/refunds(top-level, linked to payments)
- States (example):
requires_payment_method→authorized→captured→partially_refunded/refunded→voided→failed. - URLs and actions
POST /payments(create + optionally authorize) →201withstatus.POST /payments/{id}/capture(capture authorized funds; supportsamount) →200or202with operation link.POST /payments/{id}/void(cancel uncaptured auth) →200;409if already captured.POST /refundswith{ payment_id, amount }→201; links to payment.GET /payments/{id}(includes links to captures/refunds).GET /payments?customer_id=...&status=authorized(query instead of deep nesting).
- Idempotency and concurrency
- Require
Idempotency-KeyonPOST /payments,capture, andrefundsto survive retries. - Use
ETag+If-MatchonPATCH /payments/{id}if you support mutable fields (e.g., metadata).
- Require
- Partial operations
- Capture: allow partial capture with remaining capturable amount tracked; subsequent captures allowed until fully captured or expired.
- Refund: allow multiple partial refunds until total refunded equals captured amount. Return
409if refund exceeds remaining refundable balance.
- Errors to standardize
422with codes likecard_declined,insufficient_funds,expired_auth,already_captured,refund_exceeds_balance.409for state conflicts (capture after void, refund before capture).
- Async considerations
- Network-dependent processors: return
202with operation resource for captures/refunds; includestatus,failure_reason,links.result. - Webhooks: sign them; include
event_id,created_at,type(payment.captured,payment.failed), and apaymentresource snapshot link.
- Network-dependent processors: return
- Security & PCI boundaries
- Do not accept raw PANs unless in a compliant vault; prefer tokens. Mask sensitive fields in logs and responses.
- Reconciliation
- Expose
GET /payouts//settlementsor export endpoints for finance teams; include stable IDs andcreated_at/available_ontimestamps.
- Expose
Shipping checklist
- Resource map with canonical URLs and one-level nesting rules documented.
- OpenAPI/JSON Schema validated; request/response examples (happy + error) checked in.
- Status code and error contract reviewed against RFC 7807 shape.
- Idempotency and concurrency story confirmed (ETag/If-Match or idempotency keys).
- Pagination/filtering/sorting documented with limits and examples.
- Deprecation/version policy stated; backwards-compatible changes verified.
- Observability: request ID, metrics, and rate limit headers present; health/readiness endpoints covered.
- Async: long-running operations return 202 + operation resource, with polling/webhook contract documented.
- Security: scopes/roles mapped to endpoints; sensitive fields redacted in logs; rate limits and abuse controls defined.
End of document — API Design & Structure Playbook