REST API Design Best Practices I Use Every Day
A good API is easy to use and hard to use wrong. These are the REST API design rules I follow every day as a backend developer: names, status codes, errors, pagination, versioning and safe retries.
An API is a product. Other developers use it: frontend developers, mobile developers, other teams, partners and, more and more, AI agents. If your API is confusing, they lose time, they make mistakes and they ask you questions on Slack.
A good API has one main quality: it is predictable. When a developer learns one endpoint, they can guess how the others work.
In this post I share the rules I follow when I design REST APIs. They are not new or revolutionary. They are simple rules that make a big difference.
1. Use nouns for resources, not verbs
The URL describes what you work with. The HTTP method describes what you do with it.
Bad:
POST /createUser
GET /getUserById?id=42
POST /deleteUser/42
Good:
POST /users
GET /users/42
DELETE /users/42
Some other rules for URLs:
- Use plural nouns:
/users,/invoices,/wallets. - Use lowercase and hyphens:
/payment-methods, not/paymentMethodsor/payment_methods. - Use nesting for clear relations, but not more than one or two levels:
/users/42/walletsis fine;/users/42/wallets/7/transactions/99/notesis too deep.
What about actions?
Some operations are not simple CRUD. For example, "cancel a transfer" or "send an invoice". Two common solutions:
- Model the action as a sub-resource:
POST /transfers/99/cancellation. - Use a state change:
PATCH /transfers/99with{"status": "cancelled"}.
Both are valid. Choose one style and use it everywhere.
2. Use HTTP methods correctly
| Method | Use | Safe? | Idempotent? |
|---|---|---|---|
| GET | Read | Yes | Yes |
| POST | Create / actions | No | No |
| PUT | Replace completely | No | Yes |
| PATCH | Update partially | No | Not always |
| DELETE | Delete | No | Yes |
Safe means it doesn't change anything. Idempotent means that doing it two times has the same effect as doing it one time.
Never change data with a GET. Browsers, proxies and crawlers can call GET requests at any moment, and they expect no side effects.
3. Return the right status codes
Status codes are the first thing a client checks. You don't need to use all of them, but use the correct ones.
Success:
200 OK– the request worked, and there is a body.201 Created– a resource was created. Include the new resource (or its URL in theLocationheader).202 Accepted– the request was accepted, but the work happens later (for example, in a queue).204 No Content– it worked, and there is no body.
Client errors:
400 Bad Request– the request is malformed.401 Unauthorized– the client is not authenticated.403 Forbidden– the client is authenticated but has no permission.404 Not Found– the resource doesn't exist (or the user can't see it).409 Conflict– the request conflicts with the current state (for example, a duplicated email).422 Unprocessable Entity– the format is correct, but the data is not valid (validation errors).429 Too Many Requests– rate limit.
Server errors:
500 Internal Server Error– something broke on your side.502 / 503 / 504– a dependency failed or the service is not available.
The most common mistake I see: returning 200 OK with {"success": false} in the body. Please don't do this. Clients, monitoring tools and retry libraries look at the status code.
4. Design good error responses
When something fails, the client needs to know what failed and how to fix it. Use a consistent error format in all your endpoints. A good standard is RFC 9457 (Problem Details for HTTP APIs):
{
"type": "https://api.example.com/errors/insufficient-balance",
"title": "Insufficient balance",
"status": 422,
"detail": "Wallet w_123 has 50.00 EUR, but the transfer needs 120.00 EUR.",
"instance": "/transfers",
"trace_id": "4bf92f3577b34da6"
}
For validation errors, include the list of fields:
{
"type": "https://api.example.com/errors/validation",
"title": "Invalid request",
"status": 422,
"errors": [
{ "field": "amount", "message": "Must be greater than 0" },
{ "field": "currency", "message": "Must be one of: EUR, USD" }
]
}
Some rules:
- Use a stable error code or type. Clients write
ifconditions with it. Don't change it. - Don't expose internal details: no stack traces, no SQL, no server paths.
- Include a
trace_id. When a client reports a problem, you can find the logs in seconds. More about this in Observability for Backend Developers.
5. Paginate every list
Every endpoint that returns a list must have pagination. Today the list has 20 items. In one year, it has 200,000, and your endpoint takes 30 seconds and uses all the memory.
There are two common types:
Offset pagination: GET /transactions?page=3&per_page=50
- Easy to understand and to implement.
- Slow with large tables, because the database needs to skip many rows.
- Can show duplicates or skip items if new data arrives between pages.
Cursor pagination: GET /transactions?limit=50&after=eyJpZCI6MTIzfQ
- Fast with any size, because it uses an index.
- Stable when new data arrives.
- You can't jump to "page 37".
For feeds, logs and transactions, I prefer cursor pagination. Return the next cursor in the response:
{
"data": [ ... ],
"next_cursor": "eyJpZCI6MTczfQ",
"has_more": true
}
Also, always set a maximum page size on the server. Never trust per_page=100000.
6. Filtering, sorting and fields
Keep the query parameters simple and consistent:
GET /transactions?status=completed&from=2026-01-01&sort=-created_at
- Filters with simple names:
status,from,to. - Sorting with a minus for descending:
sort=-created_at. - Dates in ISO 8601 format, always with a time zone:
2026-10-23T10:00:00Z. - Money as integers in the smallest unit (cents) or as strings, never as floats.
0.1 + 0.2is not0.3in floating point.
7. Make retries safe
Networks fail. Mobile apps lose connection. Clients retry. If a client retries a POST /payments, can you charge the customer twice?
This is why I use an Idempotency-Key header for important operations. The client sends a unique key, and the server returns the same result if it sees the same key again. I wrote a full post about this: Use an Idempotency-Key for HTTP Requests.
In fintech, this is not optional. It is one of the first things I check in any API that moves money.
8. Version your API
Your API will change. Some changes break clients: removing a field, renaming it, changing its type. You need a plan.
The most common options:
- Version in the URL:
/v1/users. Simple and very visible. My default choice. - Version in a header:
Accept: application/vnd.example.v2+jsonor a custom header. Cleaner URLs, but less visible. - Date-based versions (like Stripe): the client sends a date, and the server responds with the behavior of that date.
More important than the method is the rule: don't break clients without warning. Adding a new field is fine. Removing or renaming is a breaking change. Announce deprecations early, use the Deprecation and Sunset headers, and give clients time.
9. Security basics
Some rules that every API needs:
- HTTPS always. No exceptions.
- Authenticate every request. Use standard solutions (OAuth 2.0, JWT with short expiration, API keys for server-to-server).
- Check permissions on every resource. The most common vulnerability in APIs is accessing another user's data by changing an ID in the URL (
/invoices/1001→/invoices/1002). Always check that the resource belongs to the user. - Rate limit your endpoints, and return
429with aRetry-Afterheader. - Validate input strictly: types, ranges, sizes.
- Don't return more data than needed. Never return password hashes, internal flags or other users' data "because it was in the entity".
10. Document with OpenAPI
Write an OpenAPI specification for your API. It gives you:
- Interactive documentation (Swagger UI, Redoc).
- Client generation for many languages.
- Contract tests that check that the implementation matches the documentation.
And there is a new reason: AI agents read API documentation. A clear OpenAPI spec with good descriptions makes your API easy to use for tools like coding assistants and MCP servers. Good descriptions for humans are good descriptions for models too. It is the same idea as good schema descriptions in Structured Outputs and Prompts as Code.
My checklist
Before I publish a new endpoint, I check:
- Nouns in the URL, correct HTTP method.
- Correct status codes (no
200with errors). - Consistent error format with a stable error type and
trace_id. - Pagination with a maximum page size.
- ISO 8601 dates and safe money values.
- Idempotency for operations that create or move things.
- Authentication and permission checks on every resource.
- Rate limiting.
- OpenAPI documentation updated.
- No breaking changes without a new version.
Summary
A good API is predictable, consistent and safe to retry. Use nouns and HTTP methods correctly, return honest status codes, give useful errors, paginate everything, protect money operations with idempotency, and document it well.
Your API users will not thank you every day. But they will never send you angry messages either. And that is the best compliment an API can receive.
Related posts
Use an Idempotency-Key for HTTP Requests
When you build an API, one important question is: **What happens if the same request is sent twice?** Sometimes, nothing bad happens. But sometimes, sending the same request twice can create duplicate data or perform the same operation twice.