Contract-First API Design
Overview
Contract-first means you design the API before you write implementation code. The API contract is a binding agreement with your clients: once published, it cannot be broken. This approach forces you to think from the client's perspective, establish clear semantics upfront, and create a stable interface that enables independent evolution of client and server.
At scale, APIs are used by clients you don't know, in ways you didn't anticipate, depending on behaviors you never documented. Hyrum's Law is absolute: "With a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody." This means every response header, every error format, every ordering behavior becomes part of your contract whether you intended it or not.
What counts as an API
An API is any contract through which a client consumes functionality. The protocol does not change the rule — but the standard you use to express the contract does change with the protocol. Picking the right standard matters. OpenAPI is not the universal answer; it is the answer for one of the protocols below.
Pick the right contract standard for the protocol
| Protocol / surface |
Recommended contract standard |
Artifact filename example |
| REST / HTTP |
OpenAPI 3.x (canonical for REST). Smithy if you're in the AWS service style and want SDK code-gen. |
openapi.yaml, service.smithy |
| GraphQL |
GraphQL SDL — the schema is the contract; .graphql schema file with queries, mutations, subscriptions, types. Optionally publish via Apollo Federation / Schema Registry. |
schema.graphql |
| gRPC |
Protocol Buffers (.proto). Use proto3. The .proto is the contract; everything else (clients, servers, docs) is generated from it. |
service.proto |
| Async / event-driven (SQS, Kinesis, MSK / Kafka, EventBridge, SNS, RabbitMQ) |
AsyncAPI 3.x for the channel-and-operation level (the equivalent of OpenAPI for async). For the payload schema itself, pair with JSON Schema, Avro, or Protobuf depending on your serializer. EventBridge: also publish the event schema in the EventBridge Schema Registry. |
asyncapi.yaml + schemas/payment-requested.avsc |
| Data contracts (warehouse tables, lakehouse, topics, file formats consumed by downstream pipelines) |
Open Data Contract Standard (ODCS) when available — the emerging standard for producer ↔ consumer agreements on data products. Otherwise use the schema-language native to the store: dbt contracts for warehouse tables, Iceberg / Delta schema for lakehouse, Avro / Protobuf / JSON Schema for topics. |
data-contract.yaml (ODCS), model.yml (dbt) |
| MCP tool surface |
MCP server manifest — the tool definitions exposed by the server (name, parameters, return shape, side-effect declaration, JSON Schema for arguments). |
mcp-tools.json |
| AI agent tool definitions |
Agent tool spec — provider-native (Anthropic tool use, OpenAI tools, Bedrock agent action groups). Underlying input shape is JSON Schema. |
tools.json |
| Webhooks (outbound events you publish to subscribers) |
AsyncAPI (treats publisher-to-subscriber as first-class) or OpenAPI if you describe the receiver-side endpoint contract. JSON Schema for the payload. |
webhooks.asyncapi.yaml |
| CLI public surface |
Commands, flags, exit codes, output schemas (JSON output: JSON Schema). No single industry standard — document explicitly. |
cli-contract.md + output-schemas/ |
| SDK public surface |
Language-native interface definitions (Java interfaces, TypeScript .d.ts, Python .pyi). Generate from the underlying API standard when possible (OpenAPI / Smithy / Proto). |
generated from upstream contract |
Choosing rules
When picking the standard, the agent applies these rules in order:
- Match the protocol's native standard. Don't write OpenAPI for a gRPC service; don't try to express GraphQL in OpenAPI; don't describe Kafka payloads with REST conventions.
- Pick the standard most clients can consume. If your clients already have a Schema Registry or a SDK code-generator, use the standard that feeds those tools.
- One protocol, one canonical artifact. If a service exposes both REST and events, produce both an
openapi.yaml and an asyncapi.yaml — they describe different surfaces. Do not try to merge them.
- Default standards (when the user has no preference and no existing tooling):
- REST → OpenAPI 3.x
- Async / events → AsyncAPI 3.x + JSON Schema (or Avro for Kafka environments)
- gRPC →
.proto (proto3)
- GraphQL → SDL
- Data contracts → ODCS when both producer and consumer can adopt it; otherwise the store's native schema language
Who is the client
Anything that consumes the API: a web UI, a mobile app, a CLI, an SDK, an MCP server, an AI agent, another internal service, a partner integration, a batch ETL job. All clients are equivalent from the contract's point of view. The word "frontend" is too narrow — use "client" or "API consumer".
This is the direct application of Amazon's 2002 API Mandate: every team exposes its functionality only through APIs, and there is no other way to consume what another team built.
When to Use
- Before writing any implementation code for a new service
- Before any client (UI, CLI, SDK, MCP, AI agent, partner) is built
- When adding new endpoints, tools, events, or messages to an existing service
- When modifying existing API behavior (to verify backward compatibility)
- When designing inter-service communication contracts
- When building APIs that will be consumed by external customers
- When creating event schemas for async communication (events are APIs too)
- When exposing tools to AI agents or MCP clients (agent tools are APIs too)
Amazon Context
Amazon services are decoupled and communicate through well-defined APIs. A team owns its API surface completely and is responsible for never breaking clients. AWS public APIs are forever—once released, they cannot be removed or have their behavior changed. This discipline applies internally as well: internal APIs have clients you've never met, in organizations you've never heard of. Breaking them causes cascading failures.
The principle "APIs are forever" drives extreme care in API design. It's far better to ship a minimal API and extend it than to ship a broad API and discover parts of it are wrong. You can always add; you can never remove.
The Process
1. Define the Contract First
Before any implementation:
- Write the API specification (OpenAPI/Swagger, Smithy, Protocol Buffers, or GraphQL schema)
- Write example requests and responses for every operation
- Write error cases — every way a request can fail and what the client receives
- Review the contract with at least one client team (or simulate a client)
- Generate client mocks from the spec so client teams can integrate immediately
The implementation serves the contract. Never let implementation convenience dictate API shape.
2. Backward Compatibility Rules
What You Can Do (Non-Breaking)
- Add a new optional field to a request body
- Add a new field to a response body (clients MUST ignore unknown fields)
- Add a new endpoint/operation
- Add a new optional query parameter
- Add a new enum value (if clients handle unknown values gracefully)
- Loosen a validation constraint (accept more inputs than before)
- Add a new error code (clients must handle unknown error codes)
What You Cannot Do (Breaking)
- Remove or rename a field from a response
- Remove or rename a request parameter
- Change the type of a field
- Make an optional field required
- Change the semantic meaning of a field
- Tighten a validation constraint (reject previously accepted inputs)
- Change the default value of a field
- Change error codes for existing failure modes
- Remove an enum value
- Change the URL/path of an endpoint
- Change authentication requirements
Hyrum's Law in Practice
Even these "safe" changes can break clients in practice:
- Adding a response field can break clients that do strict deserialization
- Changing response ordering (even if undocumented) breaks clients that depend on position
- Changing timing characteristics breaks clients with tight timeouts
- Changing the format of a string field (even if the schema says "string") breaks parsers
Mitigation: Use integration tests with real client traffic patterns. Monitor error rates after every deployment. Use canary deployments for API changes.
3. Versioning Strategy
Choose one strategy and apply it consistently:
URL Path Versioning (Recommended for REST)
/v1/resources
/v2/resources
- When to create v2: Only when a breaking change is absolutely necessary
- v1 lifetime: Minimum 12 months after v2 GA; 24 months preferred
- Migration support: Provide automated migration tooling for clients
Header Versioning
Accept: application/vnd.company.resource.v1+json
- Keeps URLs clean but harder to debug and cache
- Use when you need fine-grained versioning per-resource
Additive Versioning (Preferred at Amazon)
POST /resources
X-Api-Version: 2024-01-15
- Date-based versions
- Each version is a snapshot of behavior
- New clients get latest; existing clients are frozen at their declared version
- Server maintains backward compatibility for all declared versions
Key principle: Never require clients to migrate. If they're on v1 and it works, it should keep working indefinitely.
4. Error Semantics
Standard Error Response Format
{
"type": "https://api.example.com/errors/resource-not-found",
"error_code": "RESOURCE_NOT_FOUND",
"message": "Order with ID 'ord-123' was not found",
"request_id": "req-abc-def-123",
"timestamp": "2024-01-15T10:30:00Z",
"details": [
{
"field": "order_id",
"issue": "No order exists with this identifier",
"suggestion": "Verify the order ID and try again"
}
],
"retry_after_ms": null
}
Error Design Principles
- Use HTTP status codes correctly: 4xx for client errors, 5xx for server errors. Never return 200 with an error body.
- Machine-readable error codes: Clients switch on
error_code, not on message text.
- Human-readable messages: For debugging, not for display to end users.
- Request ID in every response: Enables correlation for debugging.
- Retry guidance: Tell the client if and when to retry (
retry_after_ms).
- Stable error codes: Once published, an error code's semantics cannot change.
Error Categories
| Status |
Error Code Pattern |
Client Action |
| 400 |
INVALID_* |
Fix request, do not retry |
| 401 |
AUTHENTICATION_* |
Re-authenticate, then retry |
| 403 |
AUTHORIZATION_* |
Do not retry; insufficient permissions |
| 404 |
NOT_FOUND |
Resource doesn't exist; may retry if race condition |
| 409 |
CONFLICT_* |
Resolve conflict (e.g., version mismatch), then retry |
| 429 |
RATE_LIMITED |
Retry after retry_after_ms with exponential backoff |
| 500 |
INTERNAL_ERROR |
Retry with exponential backoff |
| 503 |
SERVICE_UNAVAILABLE |
Retry with exponential backoff |
5. Idempotency
Why Idempotency Matters
Networks are unreliable. Clients will retry. If a request succeeds but the response is lost, the client retries what was already processed. Without idempotency, this causes duplicate actions (double charges, double sends, duplicate records).
Implementation Pattern
POST /v1/payments
Idempotency-Key: client-generated-uuid
{
"amount": 100,
"currency": "USD",
"recipient": "user-456"
}
Rules:
- Client provides idempotency key: UUID generated client-side, included in request header
- Server stores result: On first execution, store the response keyed by idempotency key
- Duplicate detection: On subsequent requests with same key, return stored response (don't re-execute)
- Key expiration: Idempotency keys expire after a defined window (e.g., 24 hours)
- Scope: Key is scoped to the authenticated client (different clients can use the same key value)
Which Operations Need Idempotency
| Operation Type |
Naturally Idempotent? |
Needs Idempotency Key? |
| GET |
Yes (reads are safe) |
No |
| PUT (full replace) |
Yes (same input = same state) |
No |
| DELETE |
Yes (deleting deleted = no-op) |
No |
| POST (create) |
No |
Yes |
| PATCH (partial update) |
Depends |
Yes (for non-commutative updates) |
Idempotency Key Storage
Table: idempotency_keys
├── key: string (PK) — The idempotency key
├── client_id: string — Which client submitted it
├── status: enum — PROCESSING | COMPLETED | FAILED
├── request_hash: string — Hash of request body (detect misuse)
├── response: blob — Stored response to return on replay
├── created_at: timestamp
└── expires_at: timestamp (TTL)
6. Pagination
Cursor-Based Pagination (Recommended)
GET /v1/orders?limit=20
→ Response includes: "next_cursor": "eyJpZCI6IjEyMyJ9"
GET /v1/orders?limit=20&cursor=eyJpZCI6IjEyMyJ9
→ Next page
Why cursor-based over offset-based:
- Stable: Adding/removing items doesn't cause duplicates or skips
- Performant: Doesn't require counting rows
- Scalable: Works with distributed databases
Cursor design:
- Opaque to client (base64-encoded, encrypted, or signed)
- Contains: last seen sort key + tiebreaker (never just an offset)
- Signed or encrypted to prevent tampering
Pagination Response Format
{
"items": [...],
"pagination": {
"next_cursor": "eyJpZCI6IjEyMyJ9",
"has_more": true,
"total_count": null
}
}
Rules:
- Default limit: Always have a default (e.g., 20). Never return unbounded results.
- Maximum limit: Enforce a ceiling (e.g., 100). Reject requests above it.
- Total count: Optional. Expensive to compute at scale. Use
has_more boolean instead.
- Cursor lifetime: Document how long cursors remain valid (e.g., 24 hours).
- Empty pages: A page with zero items +
has_more: false = end of results.
7. Additional Contract Concerns
Request Validation
- Validate early, fail fast, return all validation errors at once (not one at a time)
- Document maximum request body size
- Document maximum field lengths
- Document allowed character sets
- Use consistent validation error format across all endpoints
Rate Limiting
Timeouts and Deadlines
- Document expected response times (p50, p99)
- Support client-provided deadlines (
X-Request-Deadline: <timestamp>)
- Server should abandon work if deadline has passed
- Client timeout should be > server p99 latency
Deprecation Process
- Announce deprecation (minimum 6 months before removal)
- Add
Sunset header to deprecated endpoint responses
- Log usage of deprecated endpoints; contact active users directly
- Only remove when zero traffic for 30 consecutive days
- Never remove without replacement functionality available
Mechanisms Over Good Intentions
| Intention |
Mechanism |
| "I'll keep the API backward compatible" |
Contract tests run in CI that compare new schema against published baseline; any breaking change fails the build |
| "I'll document the API properly" |
API spec is generated from code annotations; if the code doesn't have annotations, the endpoint doesn't exist |
| "I'll add idempotency later" |
Framework requires idempotency key for all POST/PATCH operations; implementation fails without it |
| "I'll handle pagination when we have more data" |
Framework enforces maximum response size; unbounded queries are rejected at compile time |
| "I'll communicate deprecations to clients" |
Automated deprecation scanner finds usage and files tickets with consuming teams |
Common Rationalizations
| What They Say |
Why It's Wrong |
What To Do Instead |
| "No one uses that field, we can remove it" |
You don't know every client. Hyrum's Law: if it's observable, someone depends on it. |
Deprecate it: stop documenting, stop using in new clients, but keep returning it. Remove only after zero-traffic verification. |
| "We'll version when we need to" |
By the time you need to version, you've already broken clients. Versioning strategy must exist from day one. |
Design your versioning strategy in the contract. Make the first version explicit (v1, not versionless). |
| "The error message explains what happened" |
Clients parse error codes, not messages. If your error handling depends on message text, you're one rewording away from breaking clients. |
Use machine-readable error codes. Messages are for humans debugging, codes are for machines branching. |
| "We'll add pagination later when the data grows" |
By the time data grows, clients are depending on getting all results in one call. Adding pagination is a breaking change. |
Paginate from day one. Even if there's only 3 items. The contract includes pagination from the start. |
| "Our internal API doesn't need this rigor" |
Internal APIs become external APIs. Internal clients are still clients. Internal breakages still cause outages. |
Apply the same rigor. The only difference is you might iterate faster on internal APIs, but you still don't break them. |
Red Flags
- API spec was written after implementation (spec describes code, not the other way around)
- No contract tests in CI comparing against published baseline
- Error responses are unstructured strings or vary between endpoints
- POST endpoints don't require idempotency keys
- Responses return unbounded lists without pagination
- API has no version indicator (URL, header, or query param)
- Changing response field names or types without version bump
- Rate limiting returns 500 instead of 429
- No request ID in responses (impossible to debug issues)
- Documentation says "this field may be removed in the future" (it won't be; you just made it permanent by shipping it)
- Client SDKs are hand-written rather than generated from the spec
Verification
Tenets
- The API is a promise. Once published, it's permanent. Design with the weight of permanence.
- Design for the client you'll never meet. You don't know all your consumers. Assume the worst-case client: strict parsing, no error handling, depends on response ordering.
- Add, never remove. You can always add new fields, endpoints, and capabilities. You can never take them away.
- Explicit over implicit. Document everything. Undocumented behavior becomes depended-upon behavior, except now you don't know what you're promising.
- Idempotency is not optional. Networks fail. Clients retry. Your API must handle it gracefully from day one.
1---2name: contract-first-api-design3description: Designing APIs contract-first with backward compatibility guarantees, clear versioning strategy, error semantics, idempotency, and pagination. The API is a promise—never break existing clients.4---56# Contract-First API Design78## Overview910Contract-first means you design the API before you write implementation code. The API contract is a binding agreement with your clients: once published, it cannot be broken. This approach forces you to think from the client's perspective, establish clear semantics upfront, and create a stable interface that enables independent evolution of client and server.1112At scale, APIs are used by clients you don't know, in ways you didn't anticipate, depending on behaviors you never documented. Hyrum's Law is absolute: "With a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody." This means every response header, every error format, every ordering behavior becomes part of your contract whether you intended it or not.1314### What counts as an API1516An API is **any contract through which a client consumes functionality**. The protocol does not change the rule — but **the standard you use to express the contract does change with the protocol.** Picking the right standard matters. OpenAPI is not the universal answer; it is the answer for *one* of the protocols below.1718### Pick the right contract standard for the protocol1920| Protocol / surface | Recommended contract standard | Artifact filename example |21|---|---|---|22| **REST / HTTP** | **OpenAPI 3.x** (canonical for REST). **Smithy** if you're in the AWS service style and want SDK code-gen. | `openapi.yaml`, `service.smithy` |23| **GraphQL** | **GraphQL SDL** — the schema *is* the contract; `.graphql` schema file with queries, mutations, subscriptions, types. Optionally publish via Apollo Federation / Schema Registry. | `schema.graphql` |24| **gRPC** | **Protocol Buffers** (`.proto`). Use `proto3`. The `.proto` is the contract; everything else (clients, servers, docs) is generated from it. | `service.proto` |25| **Async / event-driven** (SQS, Kinesis, MSK / Kafka, EventBridge, SNS, RabbitMQ) | **AsyncAPI 3.x** for the channel-and-operation level (the equivalent of OpenAPI for async). For the **payload schema itself**, pair with **JSON Schema**, **Avro**, or **Protobuf** depending on your serializer. EventBridge: also publish the event schema in the EventBridge Schema Registry. | `asyncapi.yaml` + `schemas/payment-requested.avsc` |26| **Data contracts** (warehouse tables, lakehouse, topics, file formats consumed by downstream pipelines) | **Open Data Contract Standard (ODCS)** when available — the emerging standard for producer ↔ consumer agreements on data products. Otherwise use the schema-language native to the store: **dbt contracts** for warehouse tables, **Iceberg / Delta schema** for lakehouse, **Avro / Protobuf / JSON Schema** for topics. | `data-contract.yaml` (ODCS), `model.yml` (dbt) |27| **MCP tool surface** | **MCP server manifest** — the tool definitions exposed by the server (name, parameters, return shape, side-effect declaration, JSON Schema for arguments). | `mcp-tools.json` |28| **AI agent tool definitions** | **Agent tool spec** — provider-native (Anthropic tool use, OpenAI tools, Bedrock agent action groups). Underlying input shape is JSON Schema. | `tools.json` |29| **Webhooks** (outbound events you publish to subscribers) | **AsyncAPI** (treats publisher-to-subscriber as first-class) or **OpenAPI** if you describe the receiver-side endpoint contract. JSON Schema for the payload. | `webhooks.asyncapi.yaml` |30| **CLI public surface** | Commands, flags, exit codes, output schemas (JSON output: JSON Schema). No single industry standard — document explicitly. | `cli-contract.md` + `output-schemas/` |31| **SDK public surface** | Language-native interface definitions (Java interfaces, TypeScript `.d.ts`, Python `.pyi`). Generate from the underlying API standard when possible (OpenAPI / Smithy / Proto). | generated from upstream contract |3233### Choosing rules3435When picking the standard, the agent applies these rules in order:36371. **Match the protocol's native standard.** Don't write OpenAPI for a gRPC service; don't try to express GraphQL in OpenAPI; don't describe Kafka payloads with REST conventions.382. **Pick the standard most clients can consume.** If your clients already have a Schema Registry or a SDK code-generator, use the standard that feeds those tools.393. **One protocol, one canonical artifact.** If a service exposes both REST and events, produce **both** an `openapi.yaml` and an `asyncapi.yaml` — they describe different surfaces. Do not try to merge them.404. **Default standards (when the user has no preference and no existing tooling):**41 - REST → OpenAPI 3.x42 - Async / events → AsyncAPI 3.x + JSON Schema (or Avro for Kafka environments)43 - gRPC → `.proto` (proto3)44 - GraphQL → SDL45 - Data contracts → ODCS when both producer and consumer can adopt it; otherwise the store's native schema language4647### Who is the client4849Anything that consumes the API: a web UI, a mobile app, a CLI, an SDK, an MCP server, an AI agent, another internal service, a partner integration, a batch ETL job. **All clients are equivalent from the contract's point of view.** The word "frontend" is too narrow — use "client" or "API consumer".5051This is the direct application of Amazon's 2002 API Mandate: every team exposes its functionality only through APIs, and there is no other way to consume what another team built.5253## When to Use5455- Before writing any implementation code for a new service56- Before any client (UI, CLI, SDK, MCP, AI agent, partner) is built57- When adding new endpoints, tools, events, or messages to an existing service58- When modifying existing API behavior (to verify backward compatibility)59- When designing inter-service communication contracts60- When building APIs that will be consumed by external customers61- When creating event schemas for async communication (events are APIs too)62- When exposing tools to AI agents or MCP clients (agent tools are APIs too)6364## Amazon Context6566Amazon services are decoupled and communicate through well-defined APIs. A team owns its API surface completely and is responsible for never breaking clients. AWS public APIs are forever—once released, they cannot be removed or have their behavior changed. This discipline applies internally as well: internal APIs have clients you've never met, in organizations you've never heard of. Breaking them causes cascading failures.6768The principle "APIs are forever" drives extreme care in API design. It's far better to ship a minimal API and extend it than to ship a broad API and discover parts of it are wrong. You can always add; you can never remove.6970## The Process7172### 1. Define the Contract First7374Before any implementation:75761. **Write the API specification** (OpenAPI/Swagger, Smithy, Protocol Buffers, or GraphQL schema)772. **Write example requests and responses** for every operation783. **Write error cases** — every way a request can fail and what the client receives794. **Review the contract** with at least one client team (or simulate a client)805. **Generate client mocks** from the spec so client teams can integrate immediately8182The implementation serves the contract. Never let implementation convenience dictate API shape.8384### 2. Backward Compatibility Rules8586#### What You Can Do (Non-Breaking)8788- Add a new optional field to a request body89- Add a new field to a response body (clients MUST ignore unknown fields)90- Add a new endpoint/operation91- Add a new optional query parameter92- Add a new enum value (if clients handle unknown values gracefully)93- Loosen a validation constraint (accept more inputs than before)94- Add a new error code (clients must handle unknown error codes)9596#### What You Cannot Do (Breaking)9798- Remove or rename a field from a response99- Remove or rename a request parameter100- Change the type of a field101- Make an optional field required102- Change the semantic meaning of a field103- Tighten a validation constraint (reject previously accepted inputs)104- Change the default value of a field105- Change error codes for existing failure modes106- Remove an enum value107- Change the URL/path of an endpoint108- Change authentication requirements109110#### Hyrum's Law in Practice111112Even these "safe" changes can break clients in practice:113- Adding a response field can break clients that do strict deserialization114- Changing response ordering (even if undocumented) breaks clients that depend on position115- Changing timing characteristics breaks clients with tight timeouts116- Changing the format of a string field (even if the schema says "string") breaks parsers117118**Mitigation**: Use integration tests with real client traffic patterns. Monitor error rates after every deployment. Use canary deployments for API changes.119120### 3. Versioning Strategy121122Choose one strategy and apply it consistently:123124#### URL Path Versioning (Recommended for REST)125126```127/v1/resources128/v2/resources129```130131- **When to create v2**: Only when a breaking change is absolutely necessary132- **v1 lifetime**: Minimum 12 months after v2 GA; 24 months preferred133- **Migration support**: Provide automated migration tooling for clients134135#### Header Versioning136137```138Accept: application/vnd.company.resource.v1+json139```140141- Keeps URLs clean but harder to debug and cache142- Use when you need fine-grained versioning per-resource143144#### Additive Versioning (Preferred at Amazon)145146```147POST /resources148X-Api-Version: 2024-01-15149```150151- Date-based versions152- Each version is a snapshot of behavior153- New clients get latest; existing clients are frozen at their declared version154- Server maintains backward compatibility for all declared versions155156**Key principle**: Never require clients to migrate. If they're on v1 and it works, it should keep working indefinitely.157158### 4. Error Semantics159160#### Standard Error Response Format161162```json163{164 "type": "https://api.example.com/errors/resource-not-found",165 "error_code": "RESOURCE_NOT_FOUND",166 "message": "Order with ID 'ord-123' was not found",167 "request_id": "req-abc-def-123",168 "timestamp": "2024-01-15T10:30:00Z",169 "details": [170 {171 "field": "order_id",172 "issue": "No order exists with this identifier",173 "suggestion": "Verify the order ID and try again"174 }175 ],176 "retry_after_ms": null177}178```179180#### Error Design Principles1811821. **Use HTTP status codes correctly**: 4xx for client errors, 5xx for server errors. Never return 200 with an error body.1832. **Machine-readable error codes**: Clients switch on `error_code`, not on `message` text.1843. **Human-readable messages**: For debugging, not for display to end users.1854. **Request ID in every response**: Enables correlation for debugging.1865. **Retry guidance**: Tell the client if and when to retry (`retry_after_ms`).1876. **Stable error codes**: Once published, an error code's semantics cannot change.188189#### Error Categories190191| Status | Error Code Pattern | Client Action |192|--------|-------------------|---------------|193| 400 | `INVALID_*` | Fix request, do not retry |194| 401 | `AUTHENTICATION_*` | Re-authenticate, then retry |195| 403 | `AUTHORIZATION_*` | Do not retry; insufficient permissions |196| 404 | `NOT_FOUND` | Resource doesn't exist; may retry if race condition |197| 409 | `CONFLICT_*` | Resolve conflict (e.g., version mismatch), then retry |198| 429 | `RATE_LIMITED` | Retry after `retry_after_ms` with exponential backoff |199| 500 | `INTERNAL_ERROR` | Retry with exponential backoff |200| 503 | `SERVICE_UNAVAILABLE` | Retry with exponential backoff |201202### 5. Idempotency203204#### Why Idempotency Matters205206Networks are unreliable. Clients will retry. If a request succeeds but the response is lost, the client retries what was already processed. Without idempotency, this causes duplicate actions (double charges, double sends, duplicate records).207208#### Implementation Pattern209210```211POST /v1/payments212Idempotency-Key: client-generated-uuid213214{215 "amount": 100,216 "currency": "USD",217 "recipient": "user-456"218}219```220221**Rules:**2221. **Client provides idempotency key**: UUID generated client-side, included in request header2232. **Server stores result**: On first execution, store the response keyed by idempotency key2243. **Duplicate detection**: On subsequent requests with same key, return stored response (don't re-execute)2254. **Key expiration**: Idempotency keys expire after a defined window (e.g., 24 hours)2265. **Scope**: Key is scoped to the authenticated client (different clients can use the same key value)227228#### Which Operations Need Idempotency229230| Operation Type | Naturally Idempotent? | Needs Idempotency Key? |231|---------------|----------------------|----------------------|232| GET | Yes (reads are safe) | No |233| PUT (full replace) | Yes (same input = same state) | No |234| DELETE | Yes (deleting deleted = no-op) | No |235| POST (create) | No | **Yes** |236| PATCH (partial update) | Depends | **Yes** (for non-commutative updates) |237238#### Idempotency Key Storage239240```241Table: idempotency_keys242├── key: string (PK) — The idempotency key243├── client_id: string — Which client submitted it244├── status: enum — PROCESSING | COMPLETED | FAILED245├── request_hash: string — Hash of request body (detect misuse)246├── response: blob — Stored response to return on replay247├── created_at: timestamp248└── expires_at: timestamp (TTL)249```250251### 6. Pagination252253#### Cursor-Based Pagination (Recommended)254255```256GET /v1/orders?limit=20257→ Response includes: "next_cursor": "eyJpZCI6IjEyMyJ9"258259GET /v1/orders?limit=20&cursor=eyJpZCI6IjEyMyJ9260→ Next page261```262263**Why cursor-based over offset-based:**264- Stable: Adding/removing items doesn't cause duplicates or skips265- Performant: Doesn't require counting rows266- Scalable: Works with distributed databases267268**Cursor design:**269- Opaque to client (base64-encoded, encrypted, or signed)270- Contains: last seen sort key + tiebreaker (never just an offset)271- Signed or encrypted to prevent tampering272273#### Pagination Response Format274275```json276{277 "items": [...],278 "pagination": {279 "next_cursor": "eyJpZCI6IjEyMyJ9",280 "has_more": true,281 "total_count": null282 }283}284```285286**Rules:**2871. **Default limit**: Always have a default (e.g., 20). Never return unbounded results.2882. **Maximum limit**: Enforce a ceiling (e.g., 100). Reject requests above it.2893. **Total count**: Optional. Expensive to compute at scale. Use `has_more` boolean instead.2904. **Cursor lifetime**: Document how long cursors remain valid (e.g., 24 hours).2915. **Empty pages**: A page with zero items + `has_more: false` = end of results.292293### 7. Additional Contract Concerns294295#### Request Validation296297- Validate early, fail fast, return all validation errors at once (not one at a time)298- Document maximum request body size299- Document maximum field lengths300- Document allowed character sets301- Use consistent validation error format across all endpoints302303#### Rate Limiting304305- Document rate limits in API docs AND in response headers306- Return `429 Too Many Requests` with `Retry-After` header307- Use token bucket or sliding window algorithm308- Different limits for different operation types (reads vs. writes)309- Provide rate limit headers on every response:310 ```311 X-RateLimit-Limit: 100312 X-RateLimit-Remaining: 47313 X-RateLimit-Reset: 1640000000314 ```315316#### Timeouts and Deadlines317318- Document expected response times (p50, p99)319- Support client-provided deadlines (`X-Request-Deadline: <timestamp>`)320- Server should abandon work if deadline has passed321- Client timeout should be > server p99 latency322323#### Deprecation Process3243251. Announce deprecation (minimum 6 months before removal)3262. Add `Sunset` header to deprecated endpoint responses3273. Log usage of deprecated endpoints; contact active users directly3284. Only remove when zero traffic for 30 consecutive days3295. Never remove without replacement functionality available330331## Mechanisms Over Good Intentions332333| Intention | Mechanism |334|-----------|-----------|335| "I'll keep the API backward compatible" | Contract tests run in CI that compare new schema against published baseline; any breaking change fails the build |336| "I'll document the API properly" | API spec is generated from code annotations; if the code doesn't have annotations, the endpoint doesn't exist |337| "I'll add idempotency later" | Framework requires idempotency key for all POST/PATCH operations; implementation fails without it |338| "I'll handle pagination when we have more data" | Framework enforces maximum response size; unbounded queries are rejected at compile time |339| "I'll communicate deprecations to clients" | Automated deprecation scanner finds usage and files tickets with consuming teams |340341## Common Rationalizations342343| What They Say | Why It's Wrong | What To Do Instead |344|---------------|---------------|-------------------|345| "No one uses that field, we can remove it" | You don't know every client. Hyrum's Law: if it's observable, someone depends on it. | Deprecate it: stop documenting, stop using in new clients, but keep returning it. Remove only after zero-traffic verification. |346| "We'll version when we need to" | By the time you need to version, you've already broken clients. Versioning strategy must exist from day one. | Design your versioning strategy in the contract. Make the first version explicit (v1, not versionless). |347| "The error message explains what happened" | Clients parse error codes, not messages. If your error handling depends on message text, you're one rewording away from breaking clients. | Use machine-readable error codes. Messages are for humans debugging, codes are for machines branching. |348| "We'll add pagination later when the data grows" | By the time data grows, clients are depending on getting all results in one call. Adding pagination is a breaking change. | Paginate from day one. Even if there's only 3 items. The contract includes pagination from the start. |349| "Our internal API doesn't need this rigor" | Internal APIs become external APIs. Internal clients are still clients. Internal breakages still cause outages. | Apply the same rigor. The only difference is you might iterate faster on internal APIs, but you still don't break them. |350351## Red Flags352353- API spec was written after implementation (spec describes code, not the other way around)354- No contract tests in CI comparing against published baseline355- Error responses are unstructured strings or vary between endpoints356- POST endpoints don't require idempotency keys357- Responses return unbounded lists without pagination358- API has no version indicator (URL, header, or query param)359- Changing response field names or types without version bump360- Rate limiting returns 500 instead of 429361- No request ID in responses (impossible to debug issues)362- Documentation says "this field may be removed in the future" (it won't be; you just made it permanent by shipping it)363- Client SDKs are hand-written rather than generated from the spec364365## Verification366367- [ ] API specification exists BEFORE implementation code368- [ ] Contract tests run in CI and block breaking changes369- [ ] Error responses follow a consistent, documented format with machine-readable codes370- [ ] All POST/PATCH endpoints require idempotency keys371- [ ] All list endpoints are paginated with cursor-based pagination372- [ ] Rate limits are documented and enforced with proper 429 responses373- [ ] Versioning strategy is defined and the current version is explicit374- [ ] Request ID is included in every response375- [ ] Deprecation policy is documented with minimum sunset period376- [ ] Client SDKs are generated from the API spec (not hand-written)377- [ ] API spec includes example requests and responses for every operation378- [ ] Backward compatibility is verified automatically on every build379380## Tenets3813821. **The API is a promise.** Once published, it's permanent. Design with the weight of permanence.3832. **Design for the client you'll never meet.** You don't know all your consumers. Assume the worst-case client: strict parsing, no error handling, depends on response ordering.3843. **Add, never remove.** You can always add new fields, endpoints, and capabilities. You can never take them away.3854. **Explicit over implicit.** Document everything. Undocumented behavior becomes depended-upon behavior, except now you don't know what you're promising.3865. **Idempotency is not optional.** Networks fail. Clients retry. Your API must handle it gracefully from day one.