An API is a promise you make to people you will never meet, that you have to keep for years. Most of the pain we have seen comes from three areas that feel boring at design time and turn into emergencies later: versioning, pagination, and error shape.
Version on the boundary, not the entity
We version the contract, not individual fields. A URL prefix like /v1 or a media type both work; what matters is that a client written against v1 keeps working untouched when v2 ships. Adding a field is not a breaking change and never gets a new version. Removing or renaming one is, and it does. We treat "is this additive?" as the only question that decides whether we need a new version.
- Additive changes (new optional field, new endpoint): no version bump
- Breaking changes (removed field, changed type, stricter validation): new version
- Never reuse a field name for a different meaning - that breaks clients silently
- Publish a deprecation window with a real date before retiring a version
Paginate with cursors, not offsets
Offset pagination (page=3&size=20) looks simple and falls apart under writes. If a row is inserted while a client is paging, they see duplicates or skip records. Cursor pagination - return an opaque token that encodes "where you were" - is stable under inserts and lets the database use an index instead of counting rows it throws away. The cost is that you cannot jump to page 47, which almost no real client needs.
Offset pagination is a demo that works until the first concurrent write, which in production is immediately.
Errors clients can actually handle
A 500 with an HTML stack trace is useless to a client. We return a consistent JSON error body with a stable machine-readable code, a human message, and enough context to act. The code is part of the contract - clients branch on it - so we treat changing a code as a breaking change.
{
"code": "INSUFFICIENT_FUNDS",
"message": "Balance 12.40 EUR is below requested 50.00 EUR",
"traceId": "a1b2c3"
}The traceId is the detail that pays for itself. When a client opens a support ticket, that one field lets us find the exact request in our logs instead of guessing. We generate it once per request and return it on every response, success or failure.