What you'll learn
Quick Answer
Name resources as plural nouns and use HTTP methods for the verbs. Return meaningful status codes rather than 200 with an error inside. Keep error responses consistent and machine-readable. Paginate any list that can grow. Validate every input at the boundary. Version the API before you need to break it, and never change existing behaviour without a new version.
Resource Naming and Methods
The core REST idea is that URLs identify things and HTTP methods describe the action. Putting verbs in URLs is the most common departure from that.
BAD GOOD
GET /getUsers GET /users
GET /getUserById/5 GET /users/5
POST /createUser POST /users
POST /updateUser/5 PUT /users/5
POST /deleteUser/5 DELETE /users/5Use plural nouns consistently. /users and /users/5, not /user for one and /users for many. Mixing them forces consumers to remember which is which.
Nest for genuine relationships, but not deeply:
GET /users/5/orders good — orders belonging to user 5
GET /users/5/orders/12/items/3/reviews too deep — use /reviews?itemId=3Two levels is usually the limit before URLs become unwieldy.
Use query parameters for filtering, sorting and pagination, not new endpoints:
GET /users?role=admin&sort=-created&page=2&limit=20Know which methods promise what. GET must be safe — never change data in a GET, because browsers, caches and crawlers will call it. PUT and DELETE should be idempotent: calling them twice has the same effect as once. POST is not, which is why double-submitting a form can create two records.
Status Codes and Errors
Return the status code that matches what happened. The most common API mistake is returning 200 with an error in the body:
HTTP/1.1 200 OK
{ "success": false, "error": "User not found" } ← wrongEvery layer that reads status codes — monitoring, retry logic, caches, client libraries — now believes this succeeded. Failures become invisible and errors get cached.
The ones you need:
- 200 OK, 201 Created (include a
Locationheader), 204 No Content for a successful DELETE. - 400 malformed request, 401 not authenticated, 403 authenticated but not permitted, 404 not found, 409 conflict such as a duplicate, 422 validation failed, 429 rate limited.
- 500 your bug, 503 temporarily unavailable.
401 versus 403 is the pair most often confused: 401 means log in, 403 means logging in will not help.
Make errors consistent and machine-readable, with the same shape everywhere:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request contains invalid fields",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "age", "issue": "must be at least 18" }
]
}
}A stable code lets clients branch on the error type without parsing English. Return all validation failures at once rather than one at a time, so a form can show every problem in a single pass.
Pagination and Response Shape
Paginate any list that can grow. An endpoint returning every record works with fifty rows in development and fails with fifty thousand in production.
GET /users?page=2&limit=20
{
"data": [ ... ],
"pagination": {
"page": 2, "limit": 20, "total": 4137, "totalPages": 207
}
}Always cap the limit server-side. Without it, ?limit=1000000 is a denial-of-service vector any visitor can trigger.
Offset pagination is simple but has a real flaw: if rows are inserted while a user pages through, items shift and can be skipped or repeated. Cursor pagination — passing the last seen id — avoids this and performs better on large tables, at the cost of not being able to jump to an arbitrary page.
Be consistent about the envelope. Either every response wraps data in a data key or none do. Mixing them forces consumers to special-case endpoints.
Use consistent field naming — pick snake_case or camelCase and apply it everywhere, including nested objects.
Use ISO 8601 for dates in UTC — 2026-08-06T14:30:00Z. Never return a locale-formatted string or an ambiguous 06/08/2026, which means different days in different countries.
Never return sensitive fields. Password hashes, internal flags and tokens must be stripped before serialising, ideally by selecting fields explicitly rather than deleting them from the model.
Validation and Security
Validate every input at the boundary. Anything from a client is untrusted, including from your own frontend — a browser is not a trusted environment.
Check types, required fields, ranges, formats and lengths. Reject unknown fields rather than ignoring them, which catches typos in client code early.
Never use the request body directly. Mass assignment is a real vulnerability:
// Dangerous — a client can set { "role": "admin" }
await User.create({ ...req.body });
// Safe — take only what you expect
const { name, email } = req.body;
await User.create({ name, email, role: 'user' });Authorise on every request, not just in the UI. Hiding a delete button is not security. Check on the server that this user may act on this resource — broken access control is consistently among the most common real-world vulnerabilities.
Rate limit public and authentication endpoints, and return 429 with a Retry-After header so well-behaved clients back off rather than hammering.
Use parameterised queries everywhere. String concatenation into SQL is how injection happens, and it has appeared in every edition of the OWASP Top Ten.
Do not leak internals in errors. A stack trace tells an attacker your framework, versions and file paths. Return a generic message with a reference id and log the detail against that id.
Versioning and Documentation
Version from the start. Adding a version later is far harder than including one from day one:
/api/v1/usersWhat counts as a breaking change: removing or renaming a field, changing a type, adding a required parameter, or changing what a status code means. Adding an optional field or a new endpoint is not breaking, which is why clients should ignore unknown fields rather than failing on them.
When you must break something, publish v2 and keep v1 running for a stated period. Silently changing behaviour breaks every consumer at once, and they will discover it in production.
Document it. An undocumented API is unusable by anyone who did not write it. OpenAPI generates browsable documentation and client code from a specification, and generating it from your route definitions keeps it from drifting out of date — which hand-written documentation always does.
Include working examples. A curl command a developer can paste and run is worth more than a table of parameter names.
The test for a good API: can a developer who has never seen it make a successful request within five minutes, using only the documentation? If the answer is no, the problem is usually inconsistency — endpoints that behave differently from each other for no visible reason.
