Designing REST APIs That Don't Break: Rules We Build By
An API is a contract. The moment a second client depends on it — a mobile app, a partner integration, your own frontend — every careless change becomes someone else's outage. Most APIs we're asked to fix don't suffer from a lack of features; they suffer from early inconsistencies that hardened into precedent. Here's the set of rules we apply on every SrcTeam project before the first endpoint ships.
Model resources, not actions
The fastest way to an unusable API is to mirror your internal function names. Design around nouns and use HTTP methods for the verbs:
GET /invoices— listPOST /invoices— createGET /invoices/{id}— readPATCH /invoices/{id}— partial updateDELETE /invoices/{id}— delete
When an operation genuinely isn't a CRUD action — "retry a failed export", for example — a scoped action sub-resource reads better than a new endpoint universe:
// Laravel routes — consistent nesting, no verb soup
Route::post('/exports/{export}/retry', [ExportController::class, 'retry']);
Plural names, lowercase, no verbs in paths. Boring consistency is what makes an API guessable.
Version before the first client, not after
You don't need a version header on day one — you need it before your second client arrives. The cheapest insurance is a URL prefix:
/api/v1/invoices
It routes cleanly, appears in logs, and lets you run v1 and v2 side by side during a migration. Header versioning is more elegant and harder to debug — we only reach for it when URL real estate genuinely matters.
Errors the client can act on
A stack trace and a 500 help nobody. Every error response should have the same shape, a correct HTTP status, and a message a developer can act on:
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"fields": {
"email": ["The email must be a valid address."]
}
}
}
The statuses that matter: 400 for malformed input, 401 for missing auth, 403 for insufficient rights, 404 for missing resources, 422 for validation, 429 for rate limits. Free-form text like "Something went wrong" is a support ticket in disguise.
Paginate everything that grows
An endpoint that returns "all rows" works in the demo and dies in production. Pick one pagination style and enforce it on every list endpoint. Cursor pagination is our default for large or frequently-inserted tables, because it stays stable while data changes underneath:
GET /invoices?limit=50&cursor=eyJpZCI6MTAwfQ
Offset pagination is fine for small, rarely-changing lists — but it skips or duplicates rows when items are inserted mid-scroll, and page 4000 gets slower every year.
Idempotency and rate limits are part of the design
Networks retry. Clients double-click. If POST /invoices can fire twice, your API needs to survive it. An Idempotency-Key header, stored against the created resource, turns a retried request into a safe replay instead of a duplicate charge.
Rate limiting belongs in the design too — not as an afterthought when abuse happens. Return 429 with a Retry-After header, and document the limits. Clients that know the rules build better backoff than clients that discover them by ban.
Document the contract, keep it honest
An undocumented endpoint doesn't exist; an outdated document is worse than none. We keep the API reference generated from the code (or OpenAPI specs that the tests validate), so the docs can't silently drift from reality. Every field, every error code, every limit — written once, checked by CI.
The test that saves you later
Write one integration test per endpoint that asserts the response shape, not just the status code. When someone renames a field six months later, the test fails before the mobile app does. That test is the difference between an API that evolves and an API that holds its clients hostage.