TL;DR: An API contract is everything a consumer can observe and depend on — not just the schema. Name that surface explicitly, publish a compatibility policy consumers can plan around, standardize error shapes, version by necessity rather than habit, and guard the whole thing with a small, boring test matrix that runs on every change.
Name the contract surface
Every integration outage I have investigated traces back to the same misunderstanding: the provider thought the contract was the OpenAPI file, and the consumer thought the contract was everything the API had ever observably done. The consumer is right — by Hyrum's Law, every observable behavior will eventually be depended upon, whether you documented it or not.
So the first act of contract design is naming the full surface, which is much larger than the schema:
- Status codes and their meaning — including which codes are retryable and which are terminal.
- Pagination behavior — ordering stability, cursor validity windows, what happens past the last page.
- Auth behavior — token expiry semantics, what a 401 versus 403 actually distinguishes.
- Idempotency — which operations are safe to retry, and with what key mechanism.
- Error shapes — the structure of failure, which consumers parse whether you like it or not.
- Rate limits — the ceilings, the headers announcing them, the behavior on breach.
- Timestamps, nulls, and encodings — timezone conventions, absent-versus-null semantics, number precision.
- Lifecycle rules — how the contract itself changes: deprecation notice, sunset windows, migration support.
Anything on this list left unspecified does not remain flexible — it becomes an accidental contract, frozen by the first consumer who depends on today's incidental behavior.
Compatibility is a product promise
Consumers do not care that a field was convenient to rename. They care whether their integration keeps working while they are on vacation. Compatibility is therefore a product promise, and it deserves the explicitness of one — a written policy stating what may change without notice, what triggers a version, and how long old behavior survives.
The additive rule covers most of it: adding optional fields, new endpoints, and new enum values is compatible; removing or renaming anything, changing types, tightening validation, or altering semantics is breaking. Two of those deserve a warning from experience. New enum values break consumers that switch exhaustively on the old set — so declare enums open in the contract and require an "unknown" branch. And tightened validation is the stealthiest breaking change in the list: requests that worked yesterday failing today is a breaking change regardless of what the schema file says.
Implicit policy
"We try not to break things." Consumers discover the policy empirically, incident by incident, and price the risk into every integration decision.
Explicit policy
"Additive changes ship anytime. Breaking changes get a new version, 6 months of parallel operation, and deprecation headers from day one." Consumers plan; trust compounds.
Error shapes are part of the contract
Error responses are parsed by machines and read by tired humans at 2 a.m., and both audiences deserve consistency. A stable error envelope — one shape across every endpoint — is among the cheapest quality investments an API team can make, and RFC 9457 gives you a standard so you do not have to invent one:
{
"error": "validation_failed",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"fields": {
"email": ["must be a valid email address"]
}
}
Three properties matter more than the exact shape. Stable machine-readable codes — consumers branch on validation_failed, never on message text, so message text stays free to improve. A trace identifier in every error, which turns "it doesn't work" support tickets into five-minute lookups. And messages safe to display: no stack traces, no internal hostnames, no SQL — the error shape is also a security surface.
Versioning and lifecycle
Versioning debates consume far more energy than they deserve; the mechanism (path, header, media type) matters much less than the policy. My defaults: version by necessity, not by calendar — a new version is an admission that you must break someone, and each live version multiplies maintenance and support forever. Prefer evolving compatibly within a version for as long as honesty allows.
When a break is genuinely necessary, the lifecycle does the heavy lifting: announce with Deprecation and Sunset headers plus changelog and direct outreach to known consumers, run old and new in parallel through a stated window, and watch the old version's traffic — deprecation without adoption monitoring is a countdown to a surprise outage, just with extra steps.
Keep a small test matrix
The contract is real only if something fails when it is violated. A small matrix of provider-side checks, run in CI on every change, covers the areas where regressions actually happen:
| Area | Contract check |
|---|---|
| Error shape | Snapshot sample error responses per endpoint class |
| Pagination | Empty result, first, middle, last page; cursor reuse after expiry |
| Idempotency | Retried create with the same key produces exactly one effect |
| Schema diff | Generated OpenAPI compared against the committed one — breaking diff fails the build |
| Enum tolerance | Documented enums declared open; sample "unknown" value round-trips |
| Auth semantics | Expired token yields 401 with the documented error code, not a 500 |
The schema-diff check deserves special mention: automated breaking-change detection (there are off-the-shelf tools for OpenAPI) converts compatibility from a review-time judgment call into a build failure, which is exactly where you want that argument to happen.
Consumer-driven checks
Provider-side tests verify what you promised; consumer-driven contract tests verify what consumers actually use — which is routinely a different, smaller, and occasionally surprising set. In Pact-style workflows each consumer publishes the interactions it depends on, and the provider replays them in CI: a change that passes your own suite but would break a real consumer fails before merge instead of in production.
The honest cost assessment: contract-testing infrastructure earns its keep when consumers are external or numerous, or when provider and consumer deploy independently. For two internal services owned by one team, a shared integration test is cheaper and catches the same class of problem. Adopt the machinery when the coordination cost it replaces is real.
Production considerations
- Watch for de facto contracts. Periodically compare real traffic against the documented contract. Undocumented behavior with heavy usage is a contract you already have — either document it or schedule its removal as a proper breaking change.
- Changelog as a first-class artifact. Consumers integrate against the changelog more than the reference docs. Date every change, mark additive versus breaking, link migration notes.
- Rate limit responses are contract too. The 429 shape, the
Retry-Afterheader, and limit-remaining headers should be documented and tested like any endpoint — consumers build their backoff logic on them. - Sandbox parity. A test environment whose behavior diverges from production teaches consumers wrong lessons that surface as production incidents on their side and support load on yours.
Summary
- The contract is everything observable — name the full surface or watch it freeze by accident.
- Publish a compatibility policy; treat tightened validation and closed enums as the stealth breaks they are.
- One error envelope everywhere: stable codes, trace IDs, display-safe messages.
- Version by necessity; when breaking, run parallel windows and monitor old-version adoption down to zero.
- Guard the contract with a small CI matrix — schema diff, error shapes, pagination, idempotency.
- Add consumer-driven tests when consumers are external, numerous, or independently deployed.