API Design Checklist
Use this before you ship — your future self (and your users) will thank you.
A well-designed API is a contract. Once consumers depend on it, every mistake you shipped becomes a legacy you maintain forever. Run through this checklist before any endpoint goes live: during the Investigate, Loop, and Deploy phases of the B.U.I.L.D. method, or any time you're reviewing a pull request that touches your API surface.
Resource Design
- URLs use nouns, not verbs (
/orders, not/getOrdersor/fetchOrder) - Collection endpoints are plural (
/users,/products,/invoices) — consistently, always - Singleton sub-resources use singular form where it makes sense (
/users/42/profile) - Nesting is at most one level deep for sub-resources (
/orders/7/itemsis fine;/users/3/orders/7/items/2/notesis not) - Resource IDs are in the path, not the query string, when identifying a specific record
- URLs are lowercase, hyphen-separated (
/order-items, not/orderItemsor/order_items) - A stranger can guess the URL for a related resource without reading the docs
HTTP Correctness
- GET — reads only; never modifies state; safe and idempotent
- POST — creates a resource or triggers an action; not idempotent by default
- PUT — full replacement of a resource; idempotent (calling it twice has the same effect as once)
- PATCH — partial update; document whether it is idempotent
- DELETE — removes the resource; idempotent (deleting twice = same result as deleting once)
- Status codes are semantically correct:
-
200 OK— successful read or update with a response body -
201 Created— resource was created;Locationheader points to the new resource -
204 No Content— success with no response body (e.g., DELETE, some PATCHes) -
400 Bad Request— client sent malformed or unparseable data -
401 Unauthorized— caller is not authenticated (no valid token/session) -
403 Forbidden— caller is authenticated but not authorized for this resource -
404 Not Found— resource does not exist (or you are hiding it from unauthorized callers) -
409 Conflict— state conflict (e.g., duplicate unique field, concurrent edit) -
422 Unprocessable Entity— data parsed fine but failed business/schema validation -
429 Too Many Requests— rate limit exceeded; includeRetry-Afterheader -
500 Internal Server Error— never returned deliberately; only for unhandled exceptions
-
- You never return
200 OKwith{ "success": false }buried in the body — use the status code
Request Validation
- Every endpoint validates input server-side — no exceptions, no matter what the frontend promises
- Required fields are checked for presence (not just truthy —
0and""may be valid) - Field types are enforced (string vs number vs boolean, not just "it was sent")
- String lengths have max bounds to prevent oversized payloads
- Numeric fields have range bounds where the domain demands it (age ≥ 0, quantity ≥ 1)
- Enum fields are validated against the allowed set — unknown values are rejected, not silently ignored
- Nested objects and arrays are validated recursively (validating the outer shape is not enough)
- Validation errors return structured details: which field failed, why, what was received
- You use a schema library (Zod, Joi, Pydantic, class-validator, JSON Schema, etc.) — hand-rolled if-checks drift
- You reject unknown/extra fields or at minimum do not pass them downstream
Responses
- All endpoints return a consistent envelope shape (e.g.,
{ data, error, meta }— pick one and stick to it) - List endpoints are paginated — no endpoint returns unbounded arrays; document the default and max page sizes
- Pagination uses a consistent strategy (cursor-based preferred for large/changing sets; offset acceptable for small sets)
- Pagination metadata is included in the response (
total,nextCursor,hasMore, etc.) - Filtering and sorting are allowlisted — callers can only filter/sort on fields you explicitly permit
- Response objects never include internal database fields (
internal_id,stripe_secret_key,password_hash,deleted_atif soft-delete) - Response objects never include secrets or credentials even in error messages
- Timestamps are in ISO 8601 UTC (
2026-06-03T14:30:00Z), not Unix epoch integers mixed with locale strings - Empty collections return
[], notnullor a missing key - Error responses include a machine-readable code (e.g.,
"error": "DUPLICATE_EMAIL") alongside the human message
Authentication & Authorization
- Every protected endpoint explicitly checks authentication before touching any data
- Every protected endpoint explicitly checks ownership/authorization — authenticated ≠ authorized
- Returning
404instead of403for resources the caller doesn't own is a deliberate choice (security through obscurity); document it - Auth tokens are validated on every request — no "we checked it when they logged in" assumptions
- Token expiry is enforced; expired tokens return
401 - JWTs (if used) are verified with the secret/public key —
alg: noneattacks are not possible - Admin-only and elevated-privilege routes are gated by role/scope checks, not just authentication
- Password/secret fields are never returned in any response, even for the account owner
- CORS policy is explicit and restrictive — not
Access-Control-Allow-Origin: *on authenticated routes
Resilience & Safety
- Rate limiting is applied at the API layer (per IP, per API key, or per user — document the limits)
-
429responses include aRetry-Afterheader so clients can back off correctly - Non-idempotent operations (POST, some PATCHes) support an idempotency key header so clients can safely retry
- All outbound HTTP calls (to third-party APIs, databases, queues) have explicit timeouts
- Timeouts and downstream failures return a consistent error, not a hanging connection
- Large file uploads are size-capped at the HTTP layer, not just in application logic
- SQL queries use parameterized statements — never string interpolation with user input
- Sensitive operations are logged (auth failures, permission denials, admin actions) for audit purposes
- Logs never contain plaintext secrets, passwords, or full credit card numbers
Versioning & Evolution
- The API has a versioning strategy before the first consumer ships (
/v1/, header versioning, etc.) - New fields added to responses are additive — existing clients are not broken
- Fields are never removed or renamed without a new version
- Request shapes are never made more restrictive (new required fields) without a new version
- Deprecation is communicated via a
DeprecationorSunsetresponse header before removal - Breaking changes ship under a new version path; old version stays alive through the deprecation window
- You have a documented sunset policy (e.g., "old versions supported for 12 months after new version ships")
Documentation
- Every endpoint is documented: method, path, description, request shape, response shape, error codes
- Each field in the docs includes its type, whether it's required, and valid values/range
- Docs include at least one working example per endpoint (request + response, realistic values)
- Auth requirements are documented per endpoint, not just in a preamble
- Rate limits are documented
- An OpenAPI (Swagger) spec exists, is version-controlled, and stays in sync with the implementation
- The OpenAPI spec is used to generate client SDKs or documentation — not maintained separately by hand
- A changelog exists so consumers know what changed between versions
Pre-Ship Final Check
- Run the API through a tool like Postman, Insomnia, or HTTPie — manually hit every new endpoint
- Test the unhappy paths: missing auth, wrong owner, invalid body, duplicate create, deleted resource
- Confirm status codes are correct in each unhappy path (not all
400or all500) - Confirm error responses are structured and informative, not stack traces or raw DB errors
- Confirm nothing sensitive leaks in error messages or response bodies
- Peer review: have someone who did not write this code read the endpoint and the docs
Build It Right, Or Don't Build It At All. 🏛️