API Contract (Schema-First)
Purpose
Establish one consistent contract — error envelope, pagination, status codes, JSON shape — so clients can be written against a stable, extensible API and breaking changes are caught before they ship.
Universal — RFC 9457 problem+json, cursor-vs-offset pagination, status-code semantics, and "object-as-root" are HTTP/spec-level conventions that apply to any backend.
Procedure
Standardize the error envelope (RFC 9457 application/problem+json)
{ type, title, status, detail, instance } + domain extensions
- One envelope for ALL errors — clients parse it once
- Never return bare strings or inconsistent shapes per endpoint
Always return a top-level JSON object (never a bare array)
{ "items": [...], "nextCursor": "..." } not [...]
- Lets the contract add fields (pagination meta, warnings) without breaking clients
Pick a pagination strategy and apply it everywhere
- Cursor-based (preferred) — stable under concurrent writes, scales
- Offset-based — simple, acceptable for small/static datasets
- Don't mix strategies across endpoints
Use status codes by their HTTP semantics
200/201/204 success; 400 validation; 401 unauthenticated; 403 unauthorized; 404 not found; 409 conflict; 422 semantic validation; 429 rate-limited; 5xx server
4xx = client must change request; 5xx = client may retry
429 must include Retry-After (seconds or HTTP-date) — without it clients retry blindly and amplify the storm; rate-limit metadata in RateLimit-* headers (IETF draft) helps well-behaved clients self-throttle
4b. Idempotency header for non-idempotent endpoints
- Accept an
Idempotency-Key: <uuid> header on POSTs that create/charge — store key→result so a retried request returns the original result, not a duplicate effect (see resilience-patterns)
- Document it in the contract: clients can safely retry POST without double-effects
4c. Optimistic concurrency via ETag / If-Match
- Read endpoint returns
ETag: "<version>"; write endpoint requires If-Match: "<version>" and returns 412 Precondition Failed on mismatch
- Prevents lost updates without coordination — the client sends back the version it read, the server rejects if anyone else wrote in between
Schema-first: generate, don't hand-write
- Define OpenAPI 3.1 schema (or generate from typed code) as source of truth
- Generate client types + server validation from the schema
- Lint the schema in CI (Spectral / Zally)
Validate (validation loop)
- Run the schema linter; if rule violations, fix and re-run until clean
- Diff the new schema against the previous version; if a field was removed/renamed/retyped → it's a breaking change → bump version or make additive
Anti-patterns
| ❌ Anti-pattern |
✅ Correct |
| Different error shape per endpoint |
One RFC 9457 envelope everywhere |
Bare array response ([...]) |
{ items: [...], nextCursor } object |
200 OK with { error: "..." } in body |
Correct status code (400/409/etc.) |
| Offset pagination on a high-write table |
Cursor-based pagination |
| Hand-maintained client types drifting from server |
Generate both from one OpenAPI schema |
429 with no Retry-After (clients retry blindly) |
Retry-After (+ RateLimit-* headers) on every 429 |
| POST that creates a charge with no idempotency support |
Accept Idempotency-Key header; store key→result |
| Last-write-wins on concurrent updates (silent loss) |
ETag on read + If-Match on write → 412 on mismatch |
Completion Criteria
Output
- OpenAPI 3.1 schema: source of truth, linted in CI
- Generated artifacts: client types + server validation
- Contract doc: error envelope + pagination + status-code policy
- Commit format:
feat(api): add <endpoint> to contract / fix(api): standardize error envelope
Implementation
TypeScript + NestJS (default)
@nestjs/swagger generates OpenAPI from decorators (code-first)
- Error envelope: global
ExceptionFilter mapping to RFC 9457
- Pagination: cursor helper in a shared interceptor
- CI lint:
spectral lint openapi.json
Other stacks
- Python / FastAPI: OpenAPI auto-generated; RFC 9457 via custom exception handlers
- Go:
swaggo/swag or hand-written OpenAPI; problem+json middleware
- Universal: RFC 9457, cursor pagination, status-code semantics are spec-level
Related skills
api-design — choose protocol/resources first, then formalize the contract here
data-validation — the contract's request schema drives boundary validation
Reference
- Key insight encoded: Standardize one error envelope (
application/problem+json, RFC 9457) and always return a top-level JSON object (never a bare array) so the contract can extend without breaking clients.
1---2name: api-contract3description: Define a schema-first API contract — standardized error envelope (RFC 9457), pagination, status codes, consistent JSON shapes. Use when establishing API conventions, before multiple teams consume an API, or when error responses are inconsistent. Not for choosing the protocol or modeling resources (use api-design) or for runtime input parsing at the boundary (use data-validation).4license: MIT5---67# API Contract (Schema-First)89## Purpose10Establish one consistent contract — error envelope, pagination, status codes, JSON shape — so clients can be written against a stable, extensible API and breaking changes are caught before they ship.1112**Universal** — RFC 9457 problem+json, cursor-vs-offset pagination, status-code semantics, and "object-as-root" are HTTP/spec-level conventions that apply to any backend.1314## Procedure15161. **Standardize the error envelope (RFC 9457 `application/problem+json`)**17 - `{ type, title, status, detail, instance }` + domain extensions18 - One envelope for ALL errors — clients parse it once19 - Never return bare strings or inconsistent shapes per endpoint20212. **Always return a top-level JSON object (never a bare array)**22 - `{ "items": [...], "nextCursor": "..." }` not `[...]`23 - Lets the contract add fields (pagination meta, warnings) without breaking clients24253. **Pick a pagination strategy and apply it everywhere**26 - **Cursor-based** (preferred) — stable under concurrent writes, scales27 - **Offset-based** — simple, acceptable for small/static datasets28 - Don't mix strategies across endpoints29304. **Use status codes by their HTTP semantics**31 - `200/201/204` success; `400` validation; `401` unauthenticated; `403` unauthorized; `404` not found; `409` conflict; `422` semantic validation; `429` rate-limited; `5xx` server32 - `4xx` = client must change request; `5xx` = client may retry33 - **`429` must include `Retry-After`** (seconds or HTTP-date) — without it clients retry blindly and amplify the storm; rate-limit metadata in `RateLimit-*` headers (IETF draft) helps well-behaved clients self-throttle34354b. **Idempotency header for non-idempotent endpoints**36 - Accept an `Idempotency-Key: <uuid>` header on POSTs that create/charge — store key→result so a retried request returns the original result, not a duplicate effect (see `resilience-patterns`)37 - Document it in the contract: clients can safely retry POST without double-effects38394c. **Optimistic concurrency via `ETag` / `If-Match`**40 - Read endpoint returns `ETag: "<version>"`; write endpoint requires `If-Match: "<version>"` and returns `412 Precondition Failed` on mismatch41 - Prevents lost updates without coordination — the client sends back the version it read, the server rejects if anyone else wrote in between42435. **Schema-first: generate, don't hand-write**44 - Define OpenAPI 3.1 schema (or generate from typed code) as source of truth45 - Generate client types + server validation from the schema46 - Lint the schema in CI (Spectral / Zally)47486. **Validate (validation loop)**49 - Run the schema linter; if rule violations, fix and re-run until clean50 - Diff the new schema against the previous version; if a field was removed/renamed/retyped → it's a breaking change → bump version or make additive5152## Anti-patterns5354| ❌ Anti-pattern | ✅ Correct |55|---|---|56| Different error shape per endpoint | One RFC 9457 envelope everywhere |57| Bare array response (`[...]`) | `{ items: [...], nextCursor }` object |58| `200 OK` with `{ error: "..." }` in body | Correct status code (`400`/`409`/etc.) |59| Offset pagination on a high-write table | Cursor-based pagination |60| Hand-maintained client types drifting from server | Generate both from one OpenAPI schema |61| `429` with no `Retry-After` (clients retry blindly) | `Retry-After` (+ `RateLimit-*` headers) on every 429 |62| POST that creates a charge with no idempotency support | Accept `Idempotency-Key` header; store key→result |63| Last-write-wins on concurrent updates (silent loss) | `ETag` on read + `If-Match` on write → `412` on mismatch |6465## Completion Criteria66- [ ] Single error envelope (RFC 9457) used by all endpoints67- [ ] All list responses are objects with pagination meta68- [ ] One pagination strategy applied consistently69- [ ] OpenAPI schema linted clean in CI70- [ ] Breaking-change diff check in place7172## Output73- **OpenAPI 3.1 schema**: source of truth, linted in CI74- **Generated artifacts**: client types + server validation75- **Contract doc**: error envelope + pagination + status-code policy76- **Commit format**: `feat(api): add <endpoint> to contract` / `fix(api): standardize error envelope`7778## Implementation7980### TypeScript + NestJS (default)81- `@nestjs/swagger` generates OpenAPI from decorators (code-first)82- Error envelope: global `ExceptionFilter` mapping to RFC 945783- Pagination: cursor helper in a shared interceptor84- CI lint: `spectral lint openapi.json`8586### Other stacks87- **Python / FastAPI**: OpenAPI auto-generated; RFC 9457 via custom exception handlers88- **Go**: `swaggo/swag` or hand-written OpenAPI; problem+json middleware89- **Universal**: RFC 9457, cursor pagination, status-code semantics are spec-level9091## Related skills92- `api-design` — choose protocol/resources first, then formalize the contract here93- `data-validation` — the contract's request schema drives boundary validation9495## Reference96- **Key insight encoded**: Standardize one error envelope (`application/problem+json`, RFC 9457) and always return a top-level JSON object (never a bare array) so the contract can extend without breaking clients.