An internal API can be fixed by deploying both sides together. A public one cannot — once an integrator depends on a field, it is permanent in a way no internal contract ever is. Most of the painful decisions in API design are cheap to get right at the start and structurally expensive to change afterwards.
Cursor pagination, not offsets
Offset pagination has two defects that only appear at scale. Deep offsets force the database to read and discard everything before the requested page, so page 900 is far more expensive than page 1. And because the underlying data changes between requests, a client paginating through a list while new rows are inserted will see duplicates and miss records — silently, which is worse.
// Opaque cursor: encodes the sort key of the last item, plus the sort order.
// Opaque because clients must not construct or reason about it, which keeps
// you free to change the encoding later.
{
"data": [ { "id": "inv_8821", "issued_at": "2026-03-19T09:14:00Z" } ],
"page": {
"next": "eyJpc3N1ZWRfYXQiOiIyMDI2LTAzLTE5VDA5OjE0OjAwWiIsImlkIjoiaW52Xzg4MjEifQ",
"has_more": true
}
}
// The query underneath is a keyset comparison the index can serve directly:
// WHERE (issued_at, id) < ($cursor_issued_at, $cursor_id)
// ORDER BY issued_at DESC, id DESC LIMIT 50
// Constant cost per page, and stable under concurrent inserts.Version the change, not the API
A wholesale version bump — v1 to v2 — means maintaining two complete implementations and migrating every integrator for the sake of one field. It is the most common versioning approach and the most expensive one. Prefer additive change: new optional fields, new endpoints, never removing or repurposing what exists. Most evolution fits within that rule.
For genuinely breaking changes, date-based versions pinned per integrator scale better: a client declares the version they were built against, and a small transformation layer translates newer internal responses back to older shapes. You maintain one implementation and a stack of transformations rather than parallel codebases.
Errors are part of the contract
An error that only a human can interpret forces integrators to match on message strings, which then become part of your contract by accident. Return a stable machine-readable code, a human-readable message, and — for validation failures — per-field detail. Whether a client should retry must be unambiguous from the response, not inferred from the status code alone.
- Stable error codes that never change wording-independently. The message may be improved; the code may not.
- Distinguish retryable from terminal explicitly, and include Retry-After when a delay is expected.
- Include a correlation ID in every response, success or failure. It turns a support conversation into a log query.
- Validate strictly and reject unknown fields on write. Silently ignoring a misspelled field creates a bug the integrator cannot see.
- Document with examples generated from the tests, so documentation drift becomes a test failure rather than a discovery.
Every field you return is a promise. Integrators will depend on the ones you consider internal, and on the ones you meant to remove.
The two decisions I would most want to get right on day one are cursor pagination and expansion — letting clients request related resources in one call rather than forcing a sequence of round trips. Both are hard to retrofit, both determine how the API performs at volume, and both are invisible on the first integration and unavoidable by the hundredth.