Engineering
API Design Principles We Actually Follow
Most API design guides tell you what good APIs look like. This is about the decisions we actually debate, and how we resolve them in practice.
API design is one of those topics where there is no shortage of opinion but a significant shortage of concrete decision criteria. Most guides agree on the surface-level principles - use nouns not verbs, be consistent, version your API. What they rarely address is how to make the difficult calls: what to do when correctness conflicts with usability, when backward compatibility becomes a burden, or when a technically sound design is operationally unmaintainable.
This is a record of the principles that actually guide decisions in our work - not a comprehensive API design tutorial, but a view into where the real trade-offs lie.
Treat the API contract as a promise, not a suggestion
The most consequential decision in API design is treating the public interface as a genuine commitment. Once a field is in a response, clients will build on it. Once an endpoint exists, something will depend on it. The discipline of treating the contract as binding forces better design choices upfront - because you know you will be living with them.
In practice, this means:
- New fields can be added to responses without a version bump (additions are non-breaking)
- Changing the type, semantics, or name of existing fields requires a new version
- Removing fields from responses requires a deprecation period with communication
We mark deprecated fields in response payloads with a _deprecated boolean and a _sunset_date string. This gives clients a machine-readable signal to act on, not just documentation they might miss.
Error responses are part of the API contract
Error handling is consistently the weakest part of API design, because it gets the least attention. A well-designed error response provides enough information for the caller to take corrective action without requiring a support ticket.
The structure we use:
{
"error": {
"code": "PAYMENT_METHOD_EXPIRED",
"message": "The payment method associated with this invoice has expired.",
"field": "invoice.payment_method_id",
"docs_url": "https://docs.example.com/errors/PAYMENT_METHOD_EXPIRED"
}
}
Three principles here:
Machine-readable codes, not just HTTP status codes. HTTP 422 tells you the request was unprocessable. PAYMENT_METHOD_EXPIRED tells you exactly what to do about it. Client code can branch on the error code without parsing human-readable messages.
Field attribution for validation errors. If a request fails because of a specific field, say which one. Do not force clients to guess.
Stable, linkable documentation. If you have public docs, link to the relevant section from the error response. This is especially valuable for third-party integrators who do not have access to your internal team.
Consistency trumps local optimisation
The temptation in API design is to optimise each endpoint for its specific use case. If a particular resource is always retrieved with a related resource, why not include the related data in the primary response?
The problem is that every special case has a cost: the caller now has to understand when the special case applies. Every deviation from a consistent pattern is a thing your integrators have to remember.
Our rule: be inconsistent only when the operational cost of consistency is genuinely prohibitive. Returning related data by default because it is "usually needed" is not a sufficient reason. It creates implicit coupling between resources, makes response schemas harder to document, and creates unintended performance implications when the caller only needed the primary resource.
Use sparse fieldsets or explicit include parameters for related data instead.
Pagination is not optional
We have a standing rule: any endpoint that returns a collection must support pagination. No exceptions for "small" collections.
The argument against is usually: "this collection never has more than 50 items." This is an empirical claim about current data, not a guarantee about future data. An endpoint without pagination has no graceful degradation path - when the collection grows beyond what fits comfortably in a response, the only options are breaking changes.
For most cases, cursor-based pagination (?after=<cursor>) is preferable to offset-based pagination (?page=2&per_page=50) because it is stable under insertion. If a new record is inserted between page 1 and page 2 being fetched, offset pagination will return a duplicate. Cursor pagination will not.
Idempotency is a feature, not an implementation detail
For mutation endpoints - anything that creates or modifies state - we require idempotency keys. The pattern:
POST /payments
Idempotency-Key: client-generated-uuid
If a request with this key has already been processed, return the original response. If it has not, process it and store the response against the key.
This solves a genuine distributed systems problem: the client sent a request, the network timed out, and the client does not know if the operation completed. Without idempotency keys, retrying the request might create duplicate state. With them, retrying is safe.
The implementation cost is low. The operational benefit - eliminating entire categories of double-processing bugs and support tickets - is significant.
One last thing: document the intent, not just the interface
Schema documentation tells you what a field is. It does not tell you why it exists, what it means in business terms, or what the caller is expected to do with it.
The most useful API documentation we have written includes:
- The business concept the field represents
- Valid values and what each implies for the caller's workflow
- Known gotchas (rate limiting behaviour, eventual consistency windows, fields that interact with each other)
Documentation as an afterthought is documentation that does not get read. Write it when you design the endpoint, not when someone files a support ticket asking what status: 3 means.
Stay Current
Engineering notes, when we have something worth saying.
No weekly newsletters. No content marketing. When a post goes up, you'll get it.