Your API Has No Contract. It Has Vibes.
Why contract discipline is the fastest path to scalable integrations and lower support overhead
Most APIs are not designed. They're accumulated.
An endpoint gets added. Then another. Error handling is inconsistent — some endpoints return {"message": "not found"}, others return {"error": "Record not found"}, and one returns a 200 with an empty body. Pagination works one way on /memories and a different way on /projects. Authentication semantics are documented in a Notion page that's six months out of date.
Every integration with this API is a negotiation. Developers don't read a spec — they read source code, test endpoints manually, and reverse-engineer behavior from observed responses. When something changes, they find out when their integration breaks.
This is not an API. It's a set of vibes that behave like an API most of the time.
A contract changes this. A contract says: this is what I will do, this is what I will return, this is how I will behave when something goes wrong, and I will not change this without telling you. Every integration built against a contract is integration against a guarantee, not a guess.
I – Contract-First vs Code-First: Pick One
There are two ways to produce an API specification. The first: write the spec, then write the code. The second: write the code, then generate the spec from annotations.
Both are valid. The difference is in what gets prioritized.
Contract-first forces you to design the API surface before you're constrained by an implementation. You can review the spec, discuss it, and change it before writing a single handler. The spec is the source of truth. The implementation must conform to it.
Code-first is faster to start. Annotations on existing handlers generate the spec automatically. The risk: the spec reflects the implementation, including its inconsistencies. If your error handling is inconsistent in code, the spec will document that inconsistency. You get a spec that describes what you have, not what you want.
For greenfield APIs or for adding new resources to an existing API: use contract-first. Write the OpenAPI spec, review it, merge it, then implement it.
For adding specs to an existing API that wasn't designed with contracts: use code-first to get a baseline, then audit the generated spec for inconsistencies and fix them in the implementation.
II – OpenAPI as the Canonical Artifact
The OpenAPI spec is not documentation. It is the authoritative description of what the API does.
That means:
- SDKs are generated from it, not written by hand
- Mock servers for testing are generated from it
- Contract tests validate the implementation against it
- Client documentation is generated from it
- Any discrepancy between the spec and the implementation is a bug in the implementation
When you treat OpenAPI as documentation — something you write after the fact to describe what you built — it drifts. The implementation changes, the spec doesn't get updated, and integration developers are reading something that's no longer accurate.
When you treat it as the canonical artifact — something the implementation must match — it stays current by necessity.
The enforcement mechanism is contract testing in CI. Every API handler has a test that validates its response schema against the OpenAPI spec. If the spec says the endpoint returns {"id": "string", "content": "string"} and the handler returns {"id": "string", "body": "string"}, the test fails. The implementation cannot drift from the spec.
III – Error Model That Every Developer Understands
The most common API design failure: inconsistent errors.
A consistent error model:
{
"error": "not_found",
"message": "Memory with ID mem_abc123 was not found in project proj_def456.",
"status_code": 404,
"request_id": "req_xyz789"
}
Four fields. Always the same. Every error, everywhere.
error: a machine-readable error code. Snake case. Past tense or noun. Examples: not_found, validation_failed, rate_limit_exceeded, unauthorized, scope_insufficient. This is what client code switches on.
message: a human-readable description. Complete sentences. Includes the relevant identifiers. This is what developers read when debugging.
status_code: the HTTP status code, also in the body. Useful for clients that inspect the body rather than the status code.
request_id: a trace ID that developers can include in support tickets. This is how you correlate a client-reported error to a server-side log entry.
Define the error code enum in your OpenAPI spec. Every handler that can fail lists the specific error codes it returns. Integration developers can build switch statements that handle all enumerated error codes explicitly, with a default case for unexpected errors.
Never return a 200 with an error body. Never return a 500 for a client error. 4xx is for client errors (the client did something wrong). 5xx is for server errors (you did something wrong). This distinction matters because clients can retry on 5xx automatically but should not retry on 4xx.
IV – Pagination and Filtering Conventions
Two endpoints with different pagination behavior force developers to write two different pagination loops. One that expects next_cursor. Another that expects page and total_pages. Both in the same API.
Pick one. Apply it everywhere.
Cursor-based pagination is the right default for most APIs:
{
"data": [...],
"pagination": {
"cursor": "eyJpZCI6Im1lbV9hYmMxMjMifQ==",
"has_more": true,
"limit": 20
}
}
To get the next page: GET /memories?cursor=eyJpZCI6Im1lbV9hYmMxMjMifQ==&limit=20.
Cursor pagination is stable under concurrent writes. If new items are created while a client is paginating, they don't shift the page boundaries the way offset pagination does. Offset pagination returns missed or duplicate items when the underlying data changes between page requests.
Filter parameters should be consistent across resources. If /memories supports status=active, then /projects should also use status=active to filter by active projects — not state=enabled or is_active=true. Consistency across endpoints means developers can predict how filtering works on a new endpoint without reading the docs.
V – Backward Compatibility Strategy
A backward-compatible change is one that doesn't break existing integrations.
Safe changes:
- Adding a new optional field to a response (clients that don't know about it ignore it)
- Adding a new optional query parameter
- Adding a new endpoint
- Adding a new error code that wasn't previously possible
Breaking changes:
- Removing a field from a response
- Changing a field's type (string to number, array to object)
- Changing the semantic meaning of a field (changing what
status: "active"means) - Removing or renaming an endpoint
- Changing which query parameters are required
Before shipping any change, classify it. If it's breaking, it requires a new API version or a migration window.
The migration window approach: announce the breaking change, give a deprecation date (minimum 90 days), keep both behaviors working during the migration period, remove the old behavior after the deadline. This gives integration developers time to migrate without being broken unexpectedly.
VI – Deprecation Headers
When an endpoint or parameter is deprecated, communicate it in the response headers:
Deprecation: true
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
Link: <https://docs.example.com/migration/v2>; rel="deprecation"
Deprecation: true signals to HTTP clients and monitoring tools that this response came from a deprecated endpoint.
Sunset is the date after which the endpoint will no longer respond.
Link points to migration documentation.
These headers are not commonly used, but they're part of RFC 8594 and are the standard way to communicate deprecation programmatically. Build tooling that alerts when your integration calls deprecated endpoints.
VII – What Breaks First
Drift between implementation and spec. The spec says id is a string. The handler returns a UUID object. The generated client converts it to a string in some languages and fails to parse it in others. The drift is invisible until a client reports a bug. Fix: automated contract tests that validate every response against the spec on every CI run.
Inconsistent error payloads. One endpoint returns {"error": "not_found"}. Another returns {"message": "not found", "code": 404}. A third returns a plaintext error string with a 400. Client code that tries to handle errors uniformly fails for one of these cases. Fix: a single error serializer used by every handler. No exceptions. Tests that validate error response shape.
Breaking changes shipped without notice. A field is renamed. No deprecation header. No announcement. Integration developers discover it when their parsing fails. They file a support ticket. You can't take the change back — it's already in production and other clients have adapted to the new name. Fix: breaking change detection in CI. OpenAPI diff tools (oasdiff, breaking-change-detector) can identify breaking changes before merge.
API Style Guide Checklist
- All resources use consistent naming (snake_case fields, kebab-case URLs)
- All errors use the shared error model
- All list endpoints use cursor-based pagination with the same field names
- All timestamp fields use ISO 8601 format
- All IDs use the
type_prefix format (e.g.,mem_,proj_,key_) - OpenAPI spec updated before implementation ships
- Contract tests run in CI
- Breaking changes require review from two engineers before merge
Deprecation Playbook
- Add
Deprecation: trueandSunsetheaders to affected endpoints - Create a migration guide (what changed, why, how to update)
- Announce in changelog and email notification to integration developers
- Wait 90 days (minimum)
- Add a runtime warning logged server-side for calls to deprecated endpoints
- After sunset date: return 410 Gone with a pointer to the migration guide
- After 30 additional days: remove the endpoint entirely
The contract is a promise. Keep it.
0 comments