REST is not a standard with a validator: it's an architectural style every team interprets their way, which is how APIs end up with POST /getUsers coexisting with DELETE /borrar?id=3. The conventions in this article aren't aesthetic whims — they're contracts that halve friction, bugs and support tickets.
Resources, not actions
The guiding principle: your URLs name nouns, HTTP verbs express action.
GET /articles ← list
POST /articles ← create
GET /articles/42 ← read one
PUT /articles/42 ← full replace
PATCH /articles/42 ← partial update
DELETE /articles/42 ← delete
Compared with the RPC-over-HTTP antipattern (POST /getArticles, POST /deleteArticle?id=42), you gain predictable uniformity: whoever knows one resource knows how to operate it entirely without extra documentation.
Derived rules ending infinite debates:
- Always plural (
/articles, not/article). - Hierarchy for nesting, two levels max:
/authors/7/articlesreads well;/authors/7/articles/42/comments/9/likesasks for root subresources (/likes?comment=9). - Filtering and ordering via query string, never paths:
/articles?author=7&status=published&sort=-date&page=2. - No verbs in routes, no extensions (.json), no capitals.
Status codes: the semantic contract
Returning 200 OK with { "error": "something broke" } breaks clients, caches, monitors and automatic retries. The code is part of the message. The essentials:
| Code | When |
|---|---|
| 200 OK | Success with body (reads, updates) |
| 201 Created | Resource created; add Location header of new resource |
| 204 No Content | Success without body (deletes, full PUT) |
| 400 Bad Request | Malformed/invalid client syntax |
| 401 Unauthorized | Not authenticated (misleading name: means "unauthenticated") |
| 403 Forbidden | Authenticated but lacking permission |
| 404 Not Found | Nonexistent resource — also when hiding existence |
| 409 Conflict | State conflict (duplicate email, stale version) |
| 422 Unprocessable | Semantically invalid (valid JSON, impossible data) |
| 429 Too Many Requests | Rate limit exceeded |
| 500 | Your fault, never the client's |
The complete system is detailed in our HTTP status codes guide; here just the discipline: 4xx = client's fault (don't retry identically), 5xx = server's fault (retry with backoff makes sense).
Useful errors: the format that actually helps
A well-designed error saves support tickets:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request contains invalid fields",
"details": [
{ "field": "email", "issue": "invalid format" },
{ "field": "birth_date", "issue": "cannot be in the future" }
],
"request_id": "req_8f3k2"
}
}
Consistency across ALL endpoints, traceable request_id in logs, messages saying what to fix — never stack traces or internal details (reconnaissance surface).
Idempotency: the property enabling retries
Idempotent = repeating the request yields the same result. GET, PUT and DELETE naturally are; POST isn't (two POSTs = two resources). That's why automatic retries after timeout are safe on some methods and dangerous on others.
For payments and critical operations over POST there's the Idempotency-Key pattern: client generates a unique key per operation and sends it in a header; your server detects repeats and returns the original result instead of double-charging. Stripe popularized it; it should be the mental default for any API moving money or critical state.
Pagination, filters and sorting from day one
Adding pagination after launch breaks consumers. Design first:
GET /articles?page=2&limit=25&sort=-created,title
- Offset/limit: simple, but skips elements under concurrent inserts.
- Cursor (
?cursor=eyJpZCI6MTAwfQ): stable under concurrent writes, ideal for infinite feeds; opaque to clients. - Always return metadata (
total,next_cursor) or headers (Link: rel="next").
And cap limit server-side: ?limit=1000000 is a resource attack disguised as query param.
Versioning: decide before needing it
Three strategies, one recommendation:
- In path (
/v1/articles): visible, cacheable, trivially routed. Most common and sufficient for nearly everyone. - Header (
Accept: application/vnd.myapi.v2+json): purist, invisible in access logs. - No version: only if internal and ephemeral.
When launching v2: v1 keeps working untouched (breaking changes = new version), announce with deprecation dates and Sunset header. Silently breaking consumers burns the trust every API needs.
Cross-cutting security
Bearer token auth (OAuth2/OIDC per context — full flow here), mandatory HTTPS no exceptions, rate limiting by key/IP (why and how, combined with properly configured CORS), and edge input validation with strict schemas (Zod, Pydantic): trusting that "the client sends good data" is the front door of half the broken internet.
FAQ
Does GraphQL kill REST? They coexist: GraphQL shines for mobile clients needing exact compositions hating over-fetching; REST remains unbeaten in simplicity, HTTP caching and ecosystem. Many platforms offer both.
Is HATEOAS mandatory to be REST? Purists say yes; practice says no. Mature hyperlinked APIs are rare; nobody invalidates pragmatic REST with good verbs, statuses and resources.
How do I document? OpenAPI/Swagger as source of truth, generated from code or vice versa. Out-of-sync docs are worse than none: automate or drown in tickets.
Look up any response code instantly with our HTTP codes reference, free and right in your browser.