Designing one good endpoint is a solved problem: model the resources, pick status codes, make retries safe, paginate with cursors. The guide to designing an API walks through that. The harder problem appears once an organisation has dozens of teams and hundreds of endpoints. Each API is reasonable on its own, and together they are a mess: three spellings of the same field, four pagination styles, errors that look different in every service, and a field nobody dares rename because some unknown client might break.
This article is about the two properties that decide the long-run cost of an API programme: consistency, meaning a client author who has used one of your APIs can predict the next, and longevity, meaning an integration written today still works in three years. Both are achieved mostly by process and tooling, not by talent. The sections below show how to write a style guide as machine-checked rules, how to detect breaking changes before merge, how to shape payloads so they can grow, and how to retire what must go.
Why consistency and longevity are the expensive part
Every inconsistency is paid for by every client, repeatedly. If one service uses created_at and another creationDate, every SDK, data pipeline and integration writes a mapping. If pagination differs, every client writes several loops. The producing team saves an hour; the organisation pays it back many times over.
Breakage is worse, because its cost is unbounded and lands on people who did nothing wrong. The observation usually called Hyrum's law states it well: with enough users, every observable behaviour of an API will be depended on by somebody, whatever the documentation promises. Field order, the exact text of an error message, the fact that a list happened to be sorted: someone relies on it. Longevity is therefore not a matter of avoiding obviously breaking changes; it is about knowing what you have promised, keeping the promise surface small, and changing it through a slow, visible process.
A style guide is a set of rules, not an essay
Most organisations already have an API style document. Few follow it, because a document can only be enforced by reviewers remembering it. Split the guide into three kinds of content and treat each differently.
| Kind | Examples | How it is enforced |
|---|---|---|
| Mechanical rules | path casing, property casing, date format, list envelope, error schema | lint rule that fails the build |
| Patterns | pagination, long-running operations, idempotency keys, filtering syntax | shared schema components and copyable examples; lint checks that they are referenced |
| Judgement | resource boundaries, naming that matches the domain, what to expose at all | design review with a checklist |
Aim to move as much as possible into the first two rows. A rule a machine checks is applied identically to every team, costs nothing per review, and frees reviewers to argue about what matters. Keep the reasons next to the rules: a short decision record per rule, explaining the choice and the alternatives, stops the same debate from being reopened by each new team.
Encoding the rules with Spectral
For OpenAPI descriptions, Spectral from Stoplight is the common linter. A ruleset is a YAML file. Each rule selects parts of the document with a JSONPath expression in given and applies a function in then. The built-in spectral:oas ruleset catches structural mistakes, and your house rules extend it.
# .spectral.yaml - the house style, enforced on every OpenAPI file in the repo
extends: ["spectral:oas"]
rules:
paths-kebab-case:
description: Path segments are lowercase kebab-case.
severity: error
given: $.paths[*]~
then:
function: pattern
functionOptions:
match: "^(/([a-z0-9-]+|\\{[a-zA-Z]+\\}))+$"
properties-camel-case:
description: JSON property names are camelCase.
severity: error
given: $..properties[*]~
then:
function: casing
functionOptions:
type: camel
operation-has-error-responses:
description: Every operation documents a 4xx response.
severity: warn
given: $.paths[*][get,put,post,patch,delete].responses
then:
function: schema
functionOptions:
schema:
anyOf:
- required: ["400"]
- required: ["404"]
- required: ["422"]
list-responses-are-envelopes:
description: Collection responses are objects with an items array, never bare arrays.
severity: error
given: $.paths[*].get.responses['200'].content['application/json'].schema
then:
field: type
function: pattern
functionOptions:
notMatch: "^array$"The trailing ~ in a JSONPath selects keys rather than values, which is how the path-casing and property-casing rules inspect names. Start with error severity only for rules your existing APIs already pass or can pass quickly; put the rest at warn, report the warning counts per team, and promote rules to error as the counts reach zero. Turning on fifty error rules against a legacy estate on one day just teaches people to disable the linter.
Protobuf and gRPC services get the same treatment from buf, whose buf lint applies configurable rule categories to .proto files. Whatever the format, put the shared schema components in one versioned package that every API imports. The error object, pagination envelope, money type and timestamp conventions should be referenced, not retyped.
What counts as a breaking change
A change is breaking if a correctly written existing client can fail or behave differently after it. The direction of data matters: what you may change in a request differs from what you may change in a response.
| Change | Request side | Response side |
|---|---|---|
| Add an optional field | safe | safe for tolerant readers; breaks strict deserialisers |
| Add a required field | breaking | safe |
| Remove or rename a field | breaking if clients send it | breaking |
| Change a field's type or format | breaking | breaking |
| Add an enum value | safe | breaking for clients with exhaustive switches unless documented as open |
| Remove an enum value | breaking | safe in theory, risky in practice |
| Tighten validation, such as a shorter max length | breaking | not applicable |
| Change a default value | behaviour change; treat as breaking | behaviour change; treat as breaking |
| Change status codes or error codes | not applicable | breaking |
Two rows deserve emphasis. Adding a value to a response enum breaks every client that switches over the values without a default branch, which in generated SDKs for some languages is most of them. Declare from the first version that enums are open and that clients must handle unknown values, and generate SDKs accordingly. Changing a default is the other trap: nothing in the schema changes shape, so tools may not flag it, yet every client that relied on the default now gets different behaviour.
Detecting breaks before merge
Compatibility checks must compare the proposed spec with what is actually deployed, so the CI job extracts the base version from the main branch and diffs against it. For OpenAPI, oasdiff's breaking command classifies changes by severity and its --fail-on flag sets the exit code. For protobuf, buf breaking compares against a git reference.
# CI job: lint, then compare against the spec on the main branch
set -euo pipefail
git show origin/main:api/openapi.yaml > /tmp/base.yaml
npx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity error
oasdiff breaking --fail-on ERR /tmp/base.yaml api/openapi.yaml
# Protobuf services use buf for the same two checks
buf lint
buf breaking --against '.git#branch=main'Treat a failure as a conversation, not an obstacle. Sometimes the break is intended, as when an endpoint is being removed after its sunset date; then the pull request should carry an explicit override with a link to the deprecation record, and the override should be visible in the changelog. Sometimes the break is accidental, such as a regenerated spec that reordered a oneOf or tightened a pattern, and the check just saved an incident.
Spec diffs only see what the spec describes. Behavioural changes such as sort order, rate limits, or which errors appear when, need contract tests that replay recorded client requests against the new build. Keep a corpus of real, anonymised requests per major client and run it in CI as well.
Designing payloads that can grow
Most longevity is decided on the day a payload is first designed, because the shape determines which future changes are additive. A few rules cover most cases.
// Hard to evolve // Easy to evolve
GET /orders -> [ {...}, {...} ] GET /orders -> { "items": [...], "nextCursor": "c2Vx..." }
"price": 12.5 "price": { "amount": "12.50", "currency": "EUR" }
"isCancelled": true "status": "cancelled" // documented as open-ended
"customer": 4711 "customer": { "id": "cus_8Hq2" }
"created": "02/10/2026" "createdAt": "2026-10-02T09:15:00Z"- Return collections inside an object. A bare array cannot later gain a cursor, a total count or a warning without a breaking change.
- Use objects for anything that might gain attributes. A customer id today becomes a customer with an id and a display name tomorrow.
- Prefer a status string documented as open-ended to booleans. Booleans tend to grow a third state, and a second boolean that contradicts the first.
- Represent money as a decimal string plus currency, and timestamps as RFC 3339 strings in UTC. Both choices avoid precision and locale bugs that cannot be fixed later.
- Make ids opaque strings, ideally with a type prefix. Clients that parse numeric ids block you from ever changing the id scheme.
- Document the tolerant-reader rule for clients: ignore unknown fields and handle unknown enum values. Then honour it in your own SDKs.
Versioning policy
With additive evolution as the default, a new major version should be rare, reserved for changes that cannot be expressed additively, such as a different resource model. Write the policy down once for the whole organisation: where the version lives (a path prefix such as /v2 is the most visible and the easiest to route), what a client can rely on within a version, and how long an old version is supported after its successor ships.
State the support window in time, not versions, because clients plan around dates. Keep the old version as a thin adapter over the new implementation rather than a fork, and do not ship a third version while the second is still being adopted. Strict compatibility rules slow every change; loose ones push the cost of breakage onto clients.
The deprecation lifecycle
Deprecation is a timed process with three signals: an announcement in the changelog and documentation, headers on every affected response, and direct contact with the clients your telemetry says still depend on the feature. The headers are standardised. RFC 9745 defines a Deprecation response header whose value is a structured-field date, written as @ followed by Unix seconds, plus a deprecation link relation pointing at human-readable information. RFC 8594 defines Sunset, an HTTP-date after which the resource is expected to stop responding.
from datetime import datetime, timezone
from email.utils import format_datetime
DEPRECATED = {
# route -> (deprecated at, sunset at, migration guide)
("GET", "/v1/orders/{id}/items"): (
datetime(2026, 11, 1, tzinfo=timezone.utc),
datetime(2027, 5, 1, tzinfo=timezone.utc),
"https://developer.example.com/migrate/order-lines",
),
}
def deprecation_headers(method, route, response, metrics, client_id):
entry = DEPRECATED.get((method, route))
if not entry:
return
deprecated_at, sunset_at, guide = entry
# RFC 9745: a structured-field Date, i.e. "@" + Unix seconds.
response.headers["Deprecation"] = f"@{int(deprecated_at.timestamp())}"
# RFC 8594: an HTTP-date.
response.headers["Sunset"] = format_datetime(sunset_at, usegmt=True)
response.headers["Link"] = f'<{guide}>; rel="deprecation"; type="text/html"'
metrics.increment("deprecated_call", tags={"route": route, "client": client_id})The metric at the end is the important line. Count deprecated calls per client identity, and the deprecation becomes a shrinking list of named teams or customers rather than a hope. Close to the sunset date, short scheduled brownouts, during which the endpoint returns its eventual error for a few minutes, find the clients who ignored every other signal while there is still time to help them. The deprecation guide covers the organisational side in more detail.
Design review that adds value
Once lint and diff checks run automatically, review time can go to questions machines cannot answer. Does the resource model match the domain language the clients use? Is anything exposed that should stay internal, since every exposed field is a promise? Is this a new pattern, and if so should it become a shared component? Could this endpoint be expressed with an existing one plus a filter?
A worked example: a pull request adds refund_pending to an order status enum and renames shipDate to shippedAt. Lint fails on the snake_case enum value only if your rules cover enum casing, so add that rule. The diff check flags the rename as breaking. The reviewer suggests adding shippedAt alongside the old field, marking shipDate deprecated in the schema, and confirming that the status enum was documented as open, so the new value is safe for well-behaved clients. The change ships additively, and the rename completes after the sunset. Keep design reviews lightweight with a written checklist, in the same spirit as reviewing a design doc.
Failure modes
- Style guide as prose only. Nobody can remember sixty rules, so drift is guaranteed. Encode the mechanical rules.
- Diffing against the wrong base. Comparing with the previous commit instead of what is deployed misses changes that were merged but not released together.
- Closed enums in SDKs. Generated clients that throw on unknown values turn every additive enum change into an outage.
- Deprecation by announcement. A blog post without headers or per-client telemetry reaches few of the people who need it.
- Lint rules nobody can pass. Enabling every rule at error severity on day one leads to blanket suppressions.
What to do next
- List the conventions your APIs already share and those they disagree on; pick one answer for each disagreement and write a short decision record.
- Encode the mechanical rules in a Spectral ruleset or buf configuration, starting at warn, and publish per-team counts.
- Move the error object, pagination envelope, money and timestamp types into one shared schema package.
- Add a CI job that diffs each spec against the deployed version with oasdiff or buf breaking and fails on errors.
- Declare enums open and generate SDKs that tolerate unknown values and fields.
- Write the versioning policy, including a support window stated in months.
- Instrument deprecated routes with Deprecation, Sunset and Link headers and per-client call counts.
- Replace free-form design review with a short checklist focused on modelling and exposure.