Consistency beats cleverness
The hardest part of API design isn't any single endpoint — it's keeping the whole surface area consistent. Consumers build mental models. Every exception to your patterns breaks that model.
Pick conventions and document them. Ours:
- Resources are plural nouns:
/invoices,/purchase-orders - Actions that don't fit CRUD use POST with a verb suffix:
/invoices/:id/approve - Dates are always ISO 8601 in UTC:
2026-04-22T14:30:00Z
Error responses need as much care as success responses
A 400 that says "Bad Request" is useless. We standardized on:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request body contains invalid fields.",
"details": [
{ "field": "amount", "issue": "Must be a positive number." }
]
}
}
Consumers can programmatically handle code, show message to users, and map details to form fields. All three serve different needs.
Version in the URL, not headers
/v1/invoices is easier to test in a browser, easier to log, and easier to route at the gateway level than Accept: application/vnd.api+json;version=1. We've never regretted this choice.
Don't break consumers silently
Additive changes (new fields, new endpoints) are safe. Everything else is a breaking change:
- Removing a field
- Changing a field's type
- Changing the meaning of a status code
- Renaming a resource
We treat breaking changes as requiring a new major version. We keep old versions alive for at least 12 months and send deprecation notices via email and a Deprecation response header.
Pagination on every list endpoint, from day one
Adding pagination to an endpoint that previously returned everything is a breaking change. We default to cursor-based pagination:
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTIzfQ==",
"has_more": true
}
}
Cursor pagination handles insertions and deletions between pages correctly — offset pagination doesn't.
The rule we always come back to
If you wouldn't want to consume this API yourself, don't ship it.