A practical guide to REST API design: resource naming, status codes, error handling, versioning, pagination, security, idempotency, caching, and docs — with links to the standards behind each one.
This is a practical, framework-agnostic tour of those conventions, from URL design to security. The examples are plain HTTP and JSON, so they apply whether you’re on Node, Go, Python, Java, or anything else.
- Name resources with nouns and make collections plural. REST is about resources (nouns), not operations (verbs). The HTTP method already carries the verb, so your URLs shouldn’t.
✅ GET /invoices
✅ POST /invoices
✅ GET /invoices/123
✅ DELETE /invoices/123
❌ GET /getInvoices
❌ POST /createInvoice
❌ POST /invoices/123/delete
A few rules that keep things predictable:
Use plural nouns for collections — /invoices, not /invoice.
Nest to show relationships, but keep it shallow. /customers/45/invoices is fine; /customers/45/invoices/123/lines/8/taxes is too deep — link to the sub-resource instead.
Use kebab-case in paths (/purchase-orders) and pick one casing for JSON fields (camelCase or snake_case) and never mix.
- Use proper HTTP methods to define API operations Each method has defined semantics. Respecting them means clients, proxies, and caches behave the way everyone expects.
HTTP already defines the vocabulary. Use it instead of inventing your own.
text
GET read (safe, cacheable)
POST create / non-idempotent actions
PUT full replace (idempotent)
PATCH partial update
DELETE remove (idempotent)
Safe means it never changes state — so GET must never mutate data; crawlers and prefetchers will hit it. Idempotent means calling it repeatedly has the same effect as calling it once, which is what makes retries safe.
The authoritative reference for all of this is RFC 9110 (HTTP Semantics).
- Implement proper HTTP status codes The status code is part of your API contract. Don’t return 200 OK with { "success": false } in the body — clients, load balancers, and monitoring all read the status line.
2xx — success: 200 OK, 201 Created (with a Location header), 204 No Content (for deletes).
4xx — the client’s fault: 400 Bad Request, 401 Unauthorized (you're not authenticated), 403 Forbidden (you are, but you can't), 404 Not Found, 409 Conflict, 422 Unprocessable Entity (validation), 429 Too Many Requests.
5xx — your fault: 500 Internal Server Error, 503 Service Unavailable.
Clients, proxies, and monitoring tools all key off the status code:
200 OK — success with a body
201 Created — resource created (include a Location header)
204 No Content — success, nothing to return
400 Bad Request — malformed input
401 Unauthorized — missing/invalid credentials
403 Forbidden — authenticated, but not allowed
404 Not Found — resource doesn't exist
409 Conflict — state conflict (e.g., duplicate)
422 Unprocessable — valid JSON, invalid semantics
429 Too Many Requests — rate limited
500 Internal Server Error — your fault, not the client's
📎 RFC 9110 — HTTP Semantics · MDN: HTTP response status codes
Note the 401 vs 403 distinction — "who are you?" versus "I know who you are, and no." Mixing them up confuses every consumer.
- Return errors in a standard, machine-readable format Inconsistent errors are the fastest way to make your API miserable to integrate with. Pick one structure and use it everywhere. Better yet, use the IETF standard: Problem Details for HTTP APIs, defined in RFC 9457 (which replaced the older RFC 7807 in 2023).
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation failed",
"status": 422,
"detail": "The 'amount' field must be a positive number.",
"instance": "/invoices",
"errors": [
{ "field": "amount", "message": "must be >= 0" }
]
}
Two rules: be consistent, and never leak internals. Stack traces, SQL, and file paths in error responses are an information-disclosure vulnerability.
- Use filtering, sorting, and pagination to retrieve the data requested Any collection that can grow needs pagination — GET /invoices returning everything works in development and falls over in production. Adding pagination later is a breaking change, so build it in from the start.
GET /invoices?status=paid&sort=-issuedDate&limit=50
Filtering and sorting go in query parameters. A - prefix for descending sort (sort=-issuedDate) is a common convention.
Pagination: offset-based (?page=2&limit=50) is simple but re-scans rows and can skip or duplicate records when data changes mid-page. Cursor-based pagination is more robust for large or active datasets:
GET /invoices?limit=50
GET /invoices?limit=50&cursor=eyJpZCI6Ijk5In0
Always return pagination metadata (next cursor, or total count) so clients aren’t guessing.
- Version your API You will ship a breaking change eventually. Without versioning, that change breaks every existing client at once.
GET /v1/invoices
URI versioning (/v1/) is the most common and the most visible to consumers. Header-based versioning (Accept: application/vnd.example.v1+json) is cleaner in theory but harder to test and debug. Whichever you choose, put it in place before launch — and only bump the version for genuinely breaking changes. Adding an optional field is not breaking; removing or renaming one is.
- Secure it properly Security is where APIs get into real trouble. The essential reference is the OWASP API Security Top 10 (2023). The highlights:
HTTPS everywhere. No exceptions, not even internally.
Authentication vs authorization are different problems. Authenticate with OAuth 2.0 / OpenID Connect or signed tokens (JWT); then separately check that this user may touch this resource.
Broken Object Level Authorization (BOLA) is the #1 risk. Authenticating a user doesn’t mean /invoices/123 belongs to them. Scope every lookup by owner/tenant on the server — never trust an ID from the client as proof of ownership. "They can't guess the ID" is not access control.
Validate and whitelist all input at the boundary. Reject unknown fields to prevent mass-assignment; never pass raw input into queries.
- Make unsafe operations idempotent Networks fail after the server processed a request but before the client got the response. The client retries — and now you’ve created two invoices or charged a card twice.
Write on Medium
The standard fix is an idempotency key: the client generates a unique key (a UUID) and sends it in a header; the server processes each key exactly once and returns the saved response on any retry. Stripe’s implementation is the reference everyone copies — all POST requests accept an Idempotency-Key header (docs here).
POST /payments
Idempotency-Key: 1d8e4c2a-3f6b-4a91-9c0e-7b2f5a8d1e34
For anything touching money, stock, or external submissions, this is the difference between “safe to retry” and “support ticket.”
Document with OpenAPI
Treat the OpenAPI specification as the single source of truth for your API. Generate it from your code (most frameworks can) so it never drifts from what actually runs, and use it to auto-generate docs, client SDKs, and contract tests. Hand-written docs go stale the instant someone ships a hotfix; a generated spec doesn’t. See the OpenAPI specification.Don’t reinvent conventions
Almost every design question you’ll face has already been settled by teams running APIs at massive scale. Adopt a published style guide instead of relitigating each decision:
Microsoft REST API Guidelines
Google API Design Guide
Zalando RESTful API Guidelines — practical, with explicit MUST/SHOULD/MAY rulings
Which one you pick matters far less than picking one and applying it consistently across the whole API.
The quick checklist
Before shipping an endpoint:
Is the URL a noun, with the verb expressed by the HTTP method?
Right method, right status code, idempotent where it should be?
Do errors come back in one consistent, standard shape — with no internals leaked?
Can this collection grow? Then is it paginated?
Is there a version in the path?
Is every resource scoped to its owner in the query, not just behind auth?
Does this create or move something? Then does it accept an idempotency key?
Is input validated and whitelisted at the boundary?
Is the OpenAPI spec generated from the code?
Get these right and your API will be consistent, secure, and a pleasure to build against — which is the entire point.
Further reading
OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/en/0x11-t10/
RFC 9110, HTTP Semantics — https://www.rfc-editor.org/rfc/rfc9110.html
RFC 9457, Problem Details for HTTP APIs — https://www.rfc-editor.org/rfc/rfc9457.html
Stripe — Idempotent Requests — https://docs.stripe.com/api/idempotent_requests
OpenAPI Specification — https://swagger.io/specification/
Microsoft / Google / Zalando API guidelines (linked above)
What’s the convention you wish you’d adopted from day one? Drop it in the comments — I’d like to hear what bit you.
Top comments (0)