Every field a client can read, every error message they can see, and every status code they get back becomes something somebody depends on. This guide covers the discipline that keeps your public contract close to what you actually ship, not just what you meant to.
Ask five engineers why an API broke a client and four will describe a caller who depended on something that was never part of the documented contract - a field, an error message, a status code that just happened to be there. That's usually not caller error. Anything a client can observe eventually gets depended on, whether it was documented or not, which is why "we never promised that" stops working as a defense the moment someone's production system is reading it.
None of the seven principles below are really about sloppy engineers. They're about the fact that the correct move from inside one service - ship the field, return the object you already have - is different from the correct move for every client who will ever call you, and that gap is invisible from a seat that only has visibility into one service.
A mobile team ships a boolean field called isVerified. Eighteen months later, verification needs three states instead of two, but four client teams and two partner integrations have already written conditionals against a boolean, and there's no version to change into - only the wall of every consumer's existing code. Every public route carries an explicit version, in the path or a required header, and no field or behavior in a published version is ever removed, renamed, or reinterpreted.
app.get('/accounts/:id', getAccount);
// no version segment — this handler
// now owns every future contract changeapp.get('/v1/accounts/:id', getAccountV1);
app.get('/v2/accounts/:id', getAccountV2);
// v1 untouched — added alongside, not editedThe honest tradeoff: running two or three live versions in parallel means doubled test surfaces and a deprecation clock someone has to actually watch. Stripe has kept every version of its API compatible since 2011 by treating each one as a permanent branch of behavior, and even they save a breaking major version for the rare case that's worth the cost.
A /employees/:id endpoint returns whatever the ORM's default serializer produces, including a salary_band column and a password_reset_token added for an unrelated internal tool three sprints ago. Nobody decided to expose those fields - the serializer did, the moment the columns existed. Every endpoint returns a value bound to a declared response type, built by explicit field assignment, never a persistence entity and never a reflective serialization.
app.get('/v1/employees/:id', async (req, res) => {
const employee = await db.employees.findById(
req.params.id
);
res.json(employee);
});interface EmployeeResponse {
id: string; name: string;
department: string; startDate: string;
}
app.get('/v1/employees/:id', async (req, res) => {
const e = await db.employees.findById(req.params.id);
res.json({
id: e.id, name: e.full_name,
department: e.dept,
startDate: e.start_date.toISOString(),
});
});Timestamps get the same explicit treatment for the same reason: a format with no stated UTC offset gets reinterpreted in whatever zone the reader happens to be in, which is exactly the failure mode RFC 3339 exists to close off.
A profile-edit endpoint accepts the request body and passes it straight into an update call. Someone adds "role": "admin" to a routine display-name change, and if nothing strips it, the role updates along with the name, because the update call has no idea which fields were ever supposed to be writable from outside. Every request deserializes into a dedicated request type, validated against a declared schema before any handler logic runs.
const updateSchema = z.object({
displayName: z.string().min(1).max(80)
});
app.patch('/v1/users/:id', async (req, res) => {
const patch = updateSchema.parse(req.body);
// throws before persistence is touched
await db.users.update(req.params.id, {
display_name: patch.displayName
});
res.status(204).send();
});This is what OWASP now folds into Broken Object Property Level Authorization: excessive exposure and mass assignment turned out to share one root cause — an API that doesn't control which object properties a caller can read or write — which is why the 2023 list merged them into a single category.
Validating before persistence means every write path carries a small tax - one schema per operation instead of one entity for everything. The alternative is betting that no caller ever sends a field they shouldn't, which is a bet with a documented loss history.
A cost-saving change adds a GET endpoint that triggers a background export job and decrements a monthly quota as a side effect. A link-preview crawler visits it once. A corporate proxy prefetches it. By the time anyone notices, quota has been consumed by requests no actual user intended to send. Mutating endpoints use POST, PUT, PATCH, or DELETE - never a state change inside a GET or HEAD handler - and status codes match the outcome of the operation.
// wrong: GET /v1/subscriptions/:id/cancel // mutates state through a "safe" method app.post( '/v1/subscriptions/:id/cancellation', cancelSubscription );
Safe methods are ones where the client does not request, and does not expect, any state change on the server — incidental side effects like logging aside. That's the definition RFC 9110 anchors, and it's also where status code meaning lives, which is why returning 200 on a failed operation quietly breaks every cache, proxy, and retry mechanism reading the status line instead of the body.
Moving a mutation off GET sometimes means breaking an integration that was quietly relying on the shortcut - which is exactly the cost of fixing something that should never have been observable in the first place.
A 500 handler returns the raw exception message because it was fast to write, and nobody circled back. Six months later that message includes a table name, a column name, and the exact SQL that failed, delivered straight to whatever client triggered the bug. Every error response, from every endpoint, comes back in one declared shape - a stable code, a human-readable message, and field-level detail where relevant - never an exception message or stack trace.
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message,
stack: err.stack,
});
});app.use((err, req, res, next) => {
logger.error({ correlationId: req.id, err });
res.status(500).json({
type: '.../problems/internal-error',
title: 'Something went wrong',
correlationId: req.id,
});
});Diagnostic worth running: pull ten error responses from ten different endpoints in this API. If they don't share the same field names, there's no error shape here - just ad hoc guesses that happen to return JSON.
A list endpoint returns every order on an account, no limit. It works fine in the demo with twelve orders. Eighteen months later a large customer has 400,000, the endpoint times out, and the fix isn't just adding a page parameter - now every client that assumed it always got everything back in one call has to change too. List endpoints return bounded, cursor-paginated pages, and every public endpoint carries a rate limit keyed to the authenticated principal or API key.
Diagnostic worth running: if a list endpoint's response time scales with total row count rather than page size, it doesn't have pagination - it has a ticking clock. If a single API key can exhaust the service's capacity today by sending requests as fast as the network allows, the rate limit doesn't exist yet regardless of what the docs claim.
Google's AIP-158 pagination standard requires a next-page-token field that's only ever absent once the collection is actually exhausted. The rate-limit half sits inside what OWASP now calls Unrestricted Resource Consumption — real cost that partners need documented clearly enough to design around.
Team A's OpenAPI file describes eleven endpoints. The service actually exposes fourteen — three routes were added directly in code during an incident and nobody circled back to the spec, because it was never checked against reality, only written once and trusted forever after. A machine-readable specification lives in the repository, generated from or validated against the implemented handlers, and no endpoint gets published that's absent from it.
Rule of thumb: pick any route at random and ask if it's in the spec file. Pick any path in the spec and ask if a handler actually implements it as described. If either question makes someone go check, the spec isn't a contract - it's a rumor with a nice YAML format.
Here's the problem with the six principles above: each one is something an individual team can get exactly right in the one service it owns, and still, once an org has forty services owned by a dozen teams, whether the aggregate is actually true isn't something any one person can verify by reading through a quarter's worth of pull requests. That's a different kind of problem than any of the other six.
Every principle above is something one engineer can get right in one service and still not have true anywhere else in the org, because the drift is invisible from inside any single pull request - it only shows up in the aggregate. Pandorian enforces this guide across three surfaces:
A new handler that binds a request payload directly onto a persistence entity, a new GET route that performs a write, or a response built by reflecting an object instead of mapping a declared type surfaces with the specific line, the rule, and a suggested fix.
An on-demand scan across the whole codebase establishes the day-one baseline: every currently unversioned public route, every list endpoint still returning an unbounded collection, every error response still leaking exception detail.
The same scan, run on a cadence, catches drift introduced outside any pull request - a rate limit quietly dropped by a gateway config change, or a published field silently renamed by a migration that never touched the route serializing it.
Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.
A well designed public API is predictable, bounded and stable. Predictable means resources, verbs, status codes and error shapes follow one convention across every endpoint. Bounded means no response can grow without limit and no client can consume without limit. Stable means a published field never changes meaning, and a breaking change is a versioning event rather than a deploy.
The distinguishing constraint of a public API is that you cannot change the consumers. Every design decision becomes permanent the moment someone outside your organization depends on it.
The three used in practice are URI versioning (/v1/orders), header versioning (a version in an Accept or custom header), and date-based versioning where a client pins to a release date and the provider maintains compatibility transforms. URI versioning is the most visible and easiest to debug. Header versioning keeps URLs stable. Date pinning shifts migration burden onto the provider, which is why Stripe-style APIs use it.
Whichever you pick, the rule underneath is the same: a version only ever gains fields, and a change that removes or repurposes one is a new version.
Offset pagination asks for a page by position (?page=4&limit=50) and cursor pagination asks for records after an opaque pointer (?after=eyJpZCI6...). Offset is simple and supports jumping to an arbitrary page. Cursor is stable under concurrent writes and stays fast at depth, because the database seeks rather than counting past skipped rows.
The failure offset hides is duplicate and missing records: if rows are inserted while a client is paging, page 5 will repeat items from page 4. For any collection that changes while being read, cursor pagination is the correct default.
URL versioning wins for most teams, and the reason is operational rather than architectural. A version in the path is visible in logs, in a browser address bar, in a cURL command pasted into a support ticket, and in a CDN cache key. Debugging a client integration is meaningfully easier when the version is impossible to omit.
The purist argument for headers is sound: a URI should identify a resource, not a representation of it, and header versioning lets the same resource URL persist across versions. It is the better model on paper. It is also the one where a client sends no header, silently gets the default version, and nobody finds out until the default moves. Pick URL versioning unless you have a specific reason not to, and if you use headers, reject requests that omit the version rather than defaulting them.
Diff your OpenAPI spec between the last two releases and look for four things: a removed field, a field whose type narrowed, a previously optional request field that became required, and an enum that gained a value clients must now handle. Each of these is a breaking change that a passing test suite will not catch, because your tests were updated in the same commit.
The gap most teams have is that the spec is generated from the code, so it changes silently with it. If nothing compares consecutive spec versions in CI, the answer to this question is that you would not know.
A spec describes what the API is, not what it should be. It will happily document an endpoint with an unbounded collection response, an inconsistent error shape, or a field removed from a published version, because generated specs reflect the code faithfully including its mistakes. And a reviewer reading one endpoint's diff has no view of the convention the other 200 endpoints follow.
Consistency across an API surface is exactly the kind of property that is invisible in a single pull request and obvious in aggregate, which is why it needs a rule checked automatically on every change plus full scans that catch where the surface has already drifted.
You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.
Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns API design discipline like this into CI guardrails — scoped to the repos, teams, or services you choose.