APIs do not break suddenly, they degrade. They start clean, with three endpoints and a schema that fits on a napkin, and six months later they drag fields nobody remembers adding, status codes that lie, inconsistent pagination across resources and a changelog nobody maintains. Designing an API we will not hate in six months is not about elegance, it is about making boring decisions early and sticking to them.
Explicit versioning from day one. We have two serious options: version in the path (/v1/orders) or version in the Accept header (application/vnd.kimox.v1+json). The path wins on operational clarity, it is visible in logs, in metrics, in any Sentry trace, and clients understand it without documentation. The header wins when the contract changes for the same resource depending on the consumer, typically on platforms with multiple official SDKs and a long lifecycle. For 95% of cases, /v1 in the path is the right answer. What matters is not which, but committing before a single client ships to production.
Pagination: cursor by default, offset only when needed. Offset is comfortable for small admin listings, but it breaks with large datasets, concurrent writes, and any ordering that is not strictly stable. An opaque cursor based on a monotonic field (timestamp plus id) is resistant to inserts, supports infinite feeds and does not force counting records. Default page size of 25, maximum 100, and an explicit next_cursor in the response. We never return a total when it is expensive to compute; we prefer an honest has_more.
Errors that do not lie. We adopt RFC 7807 (application/problem+json) with type, title, status, detail, and an instance correlatable with our logs. A 422 is a 422, a 409 is a 409, a 404 is never a 200 with {"error": "not found"}. Returning 200 with an error body is the decision that destroys the most clients: it breaks retries, it breaks circuit breakers, it breaks success metrics. HTTP codes exist, we use them. For validation errors we include an errors array with a JSON Pointer to the offending field.
Idempotency for every POST that mutates money or external state. We accept Idempotency-Key as a mandatory header on payment creation, transactional email sends, calls to external systems. We store the original response for 24 hours indexed by that key and return it verbatim on retries. This is not optional for inbound webhooks: networks fail, providers retry, and without idempotency we charge twice. The key is generated by the client, typically a UUIDv4 per logical attempt.
Names and resources without baroque flourishes. Plural nouns (/invoices, /customers), never verbs in the path. Maximum nesting depth of two: /customers/{id}/invoices is fine, /customers/{id}/invoices/{id}/lines/{id}/taxes is not. When something does not fit CRUD, we model it as a subresource (/orders/{id}/cancellation with POST) instead of inventing /cancelOrder. Filters, sorting and search go in the query string, never in the path: ?status=paid&sort=-created_at.
Additive evolution and nothing else. Adding optional fields and new endpoints breaks no one. Renaming, removing or changing the type of a field does. When we need to change, we apply expand-then-contract: we add the new field, migrate clients, and only then retire the old one, with a Deprecation: true header and Sunset with an ISO date. Deprecation windows are never shorter than six months for external clients.
Documentation is code. An OpenAPI spec generated from the handlers, executable examples, and a changelog versioned in the same repository. If the documentation lives somewhere else, it lies.