All articles
54 articles · updated weekly See our Tools
All articles
All

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.

COVER · All

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=rest is more portable than ?tags=api,rest
  • sort with field:direction is 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.

RD
Author
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
View profile