REST API: principles, best practices and common mistakes
Resource design, versioning, correct status codes and pagination — what every API should have right before the first client hits production.
You read the docs, understood what REST is, shipped the endpoints. Six months later the API has /getUser, /updateUserData, /deleteUserById, returns 200 even on failure, and the frontend needs four requests to render a single screen. Not negligence — just the absence of explicit decisions at the start.
This post assumes you already know what an API is. The focus here is design: how to structure resources, version without breaking clients, use status codes correctly, and avoid the mistakes every team makes on their first production API.
Resource design: nouns, not verbs
The most common mistake in new REST APIs is treating the URL like a function call:
GET /getUsers
POST /createUser
DELETE /deleteUser?id=42
REST isn't RPC. The URL identifies a resource, not an action. The HTTP method already carries the action's semantics:
GET /users → list users
POST /users → create user
GET /users/42 → return user 42
PATCH /users/42 → partial update user 42
DELETE /users/42 → remove user 42
A few practical rules that save pointless PR debates:
Consistent plurals. Use /users, not /user. Inconsistency between singular and plural across endpoints causes more integration bugs than it looks.
Hierarchy represents real relationships. /users/42/orders makes sense when an order always belongs to a user and you need to list that user's orders. But /users/42/orders/18/items/3/discounts is too deep — an orders endpoint with filters is more practical there.
Non-CRUD actions are the exception. POST /orders/18/cancel is acceptable when "canceling" has specific business logic and isn't just setting status = cancelled. Use sparingly.
Versioning: decide before you ship
An API without versioning is an API that can't evolve without breaking clients. The "how to version" discussion usually happens too late — after the first client is already in production.
The three most common strategies:
URL versioning (most common):
/api/v1/users
/api/v2/users
Upside: obvious, explicit, easy to test in a browser. Downside: the URL carries information that isn't about the resource itself.
Header versioning:
GET /api/users
Api-Version: 2
More "RESTful" in theory, more painful in practice — caching, testing and documentation get more complex.
Query parameter:
GET /api/users?version=2
Rarely the best choice for major versions. Acceptable for experimental flags.
My preference is URL versioning for v1/v2/v3 and headers for minor variations. But what matters more than the strategy is consistency: pick one and stick to it across the entire API.
On deprecation: never remove an endpoint without a warning period. A reasonable standard is Sunset: Sat, 01 Jan 2027 00:00:00 GMT in the response header for at least three months before shutdown.
Status codes: use the right vocabulary
HTTP has ~70 status codes. In practice you don't need all of them — but the ones you do use, you need to use correctly. Returning 200 with {"error": "User not found"} in the body is the equivalent of shouting "everything's fine!" while the house is on fire.
The ones every API should use:
| Status | When to use |
|---|---|
| 200 OK | Generic success (GET, PATCH with response body) |
| 201 Created | Resource created (successful POST) |
| 204 No Content | Success with no response body (DELETE) |
| 400 Bad Request | Invalid payload, validation failed |
| 401 Unauthorized | Not authenticated |
| 403 Forbidden | Authenticated but not authorized |
| 404 Not Found | Resource doesn't exist |
| 409 Conflict | State conflict (e.g., email already registered) |
| 422 Unprocessable Entity | Well-formed payload but semantically invalid |
| 429 Too Many Requests | Rate limiting |
| 500 Internal Server Error | Unexpected server error |
The 401 vs 403 distinction trips people up: 401 means "I don't know who you are", 403 means "I know who you are but you can't do this". They're different errors requiring different client actions.
For the full specification of each code while you're building, I use the Quick Tools HTTP Status Codes reference — faster than opening MDN in the middle of a PR.
Standardized error bodies
Correct status codes are necessary but not sufficient. The error response body needs to be consistent so clients can handle it programmatically:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request payload",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
}
]
}
}
The code field (a string, not the HTTP status) lets the client handle specific cases without parsing message. The details field is optional but essential for validation errors — without it, the frontend has no idea which field is wrong.
Never expose a stack trace in production. Log internally and return a request_id in the header or body for traceability.
Pagination: cursor vs offset
Returning lists without pagination is the decision that looks harmless on day zero and becomes a 30-second query six months later.
Offset/page is the easiest to implement:
GET /users?page=3&per_page=20
Works well for small lists and when users need to jump directly to page N. The problem is that with frequently changing data, you can skip or repeat records between pages as insertions and deletions happen.
Cursor-based is more robust for feeds and high-write data:
GET /users?cursor=eyJ1c2VyX2lkIjoxMDB9&limit=20
The cursor is an opaque pointer (usually a base64-encoded ID or timestamp) marking the position in the list. No offset problems, but no "jump to page 5" support either.
The response should include pagination metadata:
{
"data": [...],
"pagination": {
"next_cursor": "eyJ1c2VyX2lkIjoxMjB9",
"has_more": true,
"total": 847
}
}
total is optional and expensive on some databases — omit it if the COUNT query is a bottleneck.
Filters and sorting
Filters as query parameters are the standard:
GET /orders?status=pending&created_after=2026-01-01&sort=created_at:desc
A few conventions that make the API easier to use:
- Dates in ISO 8601 (
2026-01-15T10:00:00Z), never Unix timestamps in query strings — they're hard to read and hard to test manually - Multiple values with the same repeated parameter:
?tag=api&tag=restis more portable than?tags=api,rest sortwithfield:directionis readable and extensible:?sort=name:asc,created_at:desc
Common mistakes that cost you later
Breaking the contract without versioning. Removing a field, changing a property type from string to number, renaming an endpoint — any of these without a new version silently breaks clients depending on the old contract.
Authentication in the payload instead of the header. POST /users?api_key=abc123 shows up in logs, browser history and shared URLs. Use Authorization: Bearer <token>.
POST when the operation should be idempotent. Operations that can be repeated without side effects (creating a resource with a deterministic ID, for example) should be PUT, not POST. POST is not idempotent — each call creates a new resource.
Returning the full resource on every operation. DELETE /users/42 doesn't need to return the deleted object. PATCH /users/42 can return the updated resource or just 204 — but pick a standard and keep it.
Ignoring idempotency keys. For financial operations or any critical side effect, the client needs to retry safely. Accepting an Idempotency-Key header and caching the response for 24h solves the retry-without-duplication problem.
FAQ
What's the difference between REST and RESTful?
REST (Representational State Transfer) is a set of architectural principles defined by Roy Fielding in his doctoral dissertation in 2000. RESTful is the adjective for APIs that follow those principles. In practice, most "REST APIs" in the wild are RESTful in the colloquial sense — they use HTTP, resource URLs and JSON — but don't strictly follow Fielding's principles like HATEOAS. That's not necessarily a problem; it's a tradeoff.
REST or GraphQL: when to use each?
REST is the right call for most APIs: simple, cacheable, easy to version and document. GraphQL makes sense when different clients (mobile, web, partners) need very different subsets of the same data and you want to avoid over-fetching — but it adds operational complexity: a query language, protection against expensive queries, and a learning curve. Most APIs don't need GraphQL. If you're unsure, start with REST.
How do you avoid breaking changes in public APIs?
Additive changes (new fields, new endpoints) are generally safe — clients that don't know the new field simply ignore it. Breaking changes (removing fields, changing types, renaming things) require a new version. The safest strategy: keep the old version running for a defined period (minimum 3-6 months for public APIs), announce the deprecation via a Sunset header and documentation, then shut it down.
What is idempotency and why does it matter in APIs?
A method is idempotent when calling it multiple times produces the same result as calling it once. GET, PUT and DELETE are idempotent by definition. POST is not — each call creates a new resource. This matters in retry scenarios: if the client sends a request and gets no response (timeout, network drop), it can retry safely if the operation is idempotent. For critical POST operations, use an Idempotency-Key header.
What most APIs ignore until the first incident
API design is one of the few technical decisions that's hard to reverse — unlike refactoring internal code, changing a public contract has external cost. The resource naming, versioning strategy, and error format decisions that feel small on day zero come back as technical debt or production incidents.
The minimum checklist before publishing a new endpoint: plural noun resource, correct HTTP method, semantic status code, error body with code and message, pagination if it returns a list, and contract documentation — even if it's just a comment in the PR.
A well-designed API is one clients can integrate without pinging you on Slack to ask what status: 1 means.
- 01 VPS, VPC, and Dedicated Server: What's the Difference and When to Use Each VPS, VPC, and dedicated server appear side by side on every hosting comparison page — but they mean different things. Here's where the money goes and how to decide.
- 02 What DevOps Actually Is Beyond the Tools DevOps isn't a pipeline or a job title. It's shared ownership between the people who write code and the people who run it in production — and why most teams get it wrong.