Most of the REST APIs I have had to rescue were not slow. They were unpredictable. One endpoint returned {"data": [...]}, the next returned a bare array, and the third returned {"result": {"items": [...]}} — all in the same product, all written by the same team, six months apart. The frontend developer ended up writing a different parser for every route.
API design is not really about REST purity. It is about how much guessing the next developer has to do. Here are the practices that have actually saved my teams time, with the specifics of why.
1. Lock the response envelope before you write the second endpoint
The single highest-leverage decision is agreeing on one response shape and never breaking it. Pick something boring:
{ "data": ..., "meta": {...}, "errors": [...] }for every 2xx and every 4xx.datais an object for a single resource, an array for a collection — never both for the same route.- Errors are always an array, even when there is exactly one, so clients never branch on the type.
On a Laravel project we did this by making every controller return through a single ApiResponse helper and adding a test that hits every registered route and asserts the top-level keys. That test caught four envelope drifts in the first month — all from developers who had genuinely never seen the convention because it lived in a Confluence page nobody opened.
Conventions that are not enforced by code are not conventions. They are hopes.
2. Use HTTP status codes properly, then stop being clever
The rule I give my teams is short: the status code tells the client what to do, the body tells the human what happened.
- 200 — it worked, here is the thing.
- 201 — created, with a
Locationheader pointing at the new resource. - 204 — worked, nothing to send back (deletes).
- 400 — the request itself is malformed.
- 401 — you are not authenticated. 403 — you are, but you may not do this.
- 404 — resource does not exist, or you are not allowed to know it exists.
- 409 — a conflict with current state (duplicate email, already-cancelled order).
- 422 — the request is well-formed but the values fail validation.
- 429 — rate limited, with a
Retry-Afterheader.
Two anti-patterns I keep meeting. The first is 200 { "success": false } — which means every HTTP client, every monitoring tool, and every load balancer thinks your failures are successes. Your error rate dashboard will show a flat green line while customers are stuck. The second is inventing statuses like 499 or overloading 400 for everything. If a client has to read your prose to know whether to retry, the status code has failed at its one job.
Do not obsess over 401 vs 403 edge cases for a week. Pick a rule, write it down, move on.
3. Design errors for the developer who is debugging at 2am
A useful error has four parts: a stable machine-readable code, a human message, the field it relates to, and something to correlate with your logs. For example:
code:VALIDATION_FAILED— never change this string once shipped; clients will branch on it.message: “The email address is already registered.” — written for a human, safe to show or log.field:email— so the frontend can highlight the right input without string-matching your message.request_id: a UUID you also write to your application log.
That last one is worth more than the rest combined. When a client emails “your API is broken”, the difference between a three-hour investigation and a three-minute one is being able to paste an ID into your log search. Generate it in middleware, attach it to every log line for that request, and return it on every response — success and failure both.
And be careful about what leaks. Stack traces, SQL fragments, and internal file paths in a production error body are a genuine security finding, not a debugging convenience. Log them server-side; return the request ID.
4. Version from day one, and paginate before you need to
Versioning is cheap on day one and brutal on day 400. Put /v1/ in the path from the first commit. Path versioning is not the most elegant option — header-based content negotiation is arguably purer — but it is visible in logs, in browser address bars, in curl commands, and in bug reports. Purity loses to debuggability.
What counts as a breaking change is worth writing down explicitly, because teams disagree: removing a field, renaming a field, changing a type, making an optional request parameter required, or narrowing an enum. Adding a new optional field is not breaking — as long as your clients ignore unknown fields, which you should state in your docs.
On pagination: every collection endpoint gets it, including the ones that return four rows today. I have watched a /users endpoint that was fine in staging return 40,000 records in production and time out the mobile app. Retrofitting pagination is a breaking change; adding it upfront costs twenty minutes.
Offset pagination (?page=2&per_page=25) is fine for admin tables and anything a human clicks through. Cursor pagination is what you want for feeds, exports, and anything where rows are being inserted while the client is reading — offset pagination will silently skip or duplicate records under writes. Whichever you pick, cap per_page server-side. Someone will eventually request per_page=100000, and it will not be malicious, it will be a well-meaning integration developer.
5. Make the boring safety details non-negotiable
These are the ones that get skipped under deadline pressure and then show up in a penetration test report:
- Authorization on every single endpoint. Not just “is this user logged in”, but “does this user own this record”. IDOR — changing
/invoices/1043to/invoices/1044and seeing someone else’s data — remains one of the most common real-world API vulnerabilities, and it happens because auth was checked at the route level but not the row level. - Rate limiting on authentication and anything expensive, with
429andRetry-After. - Idempotency keys on POST endpoints that create money-adjacent things. A client that times out will retry, and you will have two charges.
- Validate and whitelist input rather than blacklisting. Mass-assignment bugs — where an extra
"role": "admin"in the JSON body gets written straight to the database — come from accepting whole payloads. - Never accept a filename, path, or URL from a client without validating it.
The practical takeaway
You do not need a design committee. You need a one-page document in your repo that states: the response envelope, the status codes you use and what each means, the error object shape, the versioning rule, and the pagination defaults. Write it before the second endpoint. Then add a contract test that enforces the envelope so the document does not quietly become fiction.
The measure of a good API is not how elegant it looks in your documentation. It is whether a developer who has never spoken to you can integrate against it without opening a support ticket. Everything above is in service of that one thing.