API Design
Overview
Define the project's API conventions before endpoints proliferate. Output is .forge/api-design.md — verbs and status codes, the error envelope schema, versioning + deprecation policy, pagination/filter contract, rate-limiting envelope, auth/authz envelope, idempotency rules for mutations, and the public-vs-internal API boundary. Consumed by architecture-and-contracts (per-module contracts inherit these), incremental-implementation (endpoints must conform), and code-review-and-quality (PR review checklist).
When to Use
- A new service or new public endpoint is being designed
- Two services need a stable interface and there's no project-wide envelope
- Error shapes vary across endpoints and clients have to special-case each one
- A breaking change is about to ship and there's no versioning policy
- A list endpoint is being added and pagination shape is unclear
- A mutation endpoint will be retried by clients and there's no idempotency story
When NOT to Use
- A single trivial endpoint is being added to a service that already has documented conventions
- Internal-only RPC inside one service (use module contracts via
architecture-and-contracts) - GraphQL or gRPC — adapt the principles but the file should call out the protocol
Common Rationalizations
| Thought | Reality |
|---|---|
| "We'll version when we break something" | By then clients are coupled to the unversioned shape. Versioning costs nothing on day one, costs a migration on day 200. |
| "Every endpoint is different, no need to standardize" | Inconsistency is a bug, not flexibility. Every divergent error shape costs clients a special case. |
| "Just return 200 with an error field" | Clients can't distinguish success from failure without parsing the body. Proxies and CDNs cache 200s aggressively. |
| "Internal APIs don't need contracts" | Internal APIs become external APIs the moment a second team touches them. Two consumers = production interface. |
| "PATCH replaces the resource" | RFC 7396 says PATCH merges. Clients that depend on merge semantics will break silently if you replace. |
| "Idempotency keys are only for payments" | Network retries happen on every endpoint. Without idempotency, retries create duplicates everywhere. |
Red Flags
- Different error shapes per endpoint (
{ error: "..." }here,{ message: "..." }there, raw string elsewhere) - No version prefix or header anywhere — first breaking change requires a fork
200 OKwith{ "success": false }in the bodyPATCHthat replaces the resource entirelyPOST /paymentswith no idempotency key support- An "internal" endpoint with no auth, reachable from the public internet
OFFSET/LIMITpagination on a feed that will exceed 10k rows- A field added to a response is the only change — no version bump, no deprecation notice
Precedence with architecture contracts
.forge/contracts/<module>.md is the authoritative source for module boundaries, operations, types, and error cases. api-design.md is the subordinate that defines how those operations map to HTTP (verbs, paths, envelope shape, error codes, versioning).
Rule: If .forge/contracts/ exists, read every contract first. Your API design must be consistent with the operations and error types they define. If a contract specifies RefundError with cases TransactionNotFound | RefundWindowExpired | AlreadyRefunded, the matching HTTP design must surface those exact codes — no inventing new ones, no collapsing them into a generic 400.
If a contract is missing an operation you need to expose (e.g., the PRD calls for refunds but PaymentService contract has no refund), do NOT add it to api-design.md alone — file /feedback targeting the contract so it gets updated upstream. Otherwise the two artifacts will diverge and forge-sync will flag a CONFLICT.
If no contracts exist yet, api-design.md may define the envelope policy in isolation, but the module boundaries it references will be back-filled when architecture-and-contracts runs.
Core Process
Step 0: Read existing contracts (if any)
Glob .forge/contracts/*.md. For each contract found:
- Extract every operation in
Provides - Extract input/output types
- Extract error types and their conditions
Hold these as the source of truth. Every endpoint you design must trace back to a contract operation (or be flagged as new and worth a feedback entry).
Step 1: Define base conventions
- Verbs:
GET(read),POST(create or non-idempotent action),PUT(replace),PATCH(merge per RFC 7396),DELETE(remove). - Status codes:
2xxsuccess only.4xxfor client errors.5xxfor server errors. Never200 OKwitherror: true. - Resource naming: plural nouns (
/users/{id},/orders). - Snake or camel: pick one for the wire format and never mix.
Step 2: Write the error envelope schema
Every error response uses the same shape. Write it in .forge/api-design.md:
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "User not found.",
"field": "user_id",
"request_id": "01HABC...",
"details": {}
}
}
code— stable, machine-readable, screaming-snake-case. Clients pattern-match on this, never onmessage.message— human-readable, non-technical when surfaced to end users.field— populated for validation errors only.request_id— always populated; matches the correlation ID fromobservability.details— optional, structured per error code.
Cross-reference the error-handling-and-resilience taxonomy (transient / permanent / user-correctable maps to retryable HTTP codes).
Step 3: Set versioning and deprecation policy
Choose one mechanism (URI /v1/, header Accept: application/vnd.api+json;version=1, or query ?version=1) and apply it everywhere. Document:
- How a version is introduced (always start at
v1, neverv0). - When a version is required to bump (any field removed, renamed, or type-changed; status-code semantics changed; required-vs-optional flip).
- Deprecation window (minimum 6 months for public, 30 days for internal-only).
- Deprecation signaling:
Deprecation: <date>header + warnings indetails.
Step 4: Pagination, filtering, sorting
- Pagination: cursor preferred. Standard shape:
{ items: [], next_cursor: string|null, prev_cursor: string|null }.OFFSET/LIMITonly allowed for small admin endpoints. - Filtering:
?status=activefor exact match,?status=active,pendingforOR,?created_after=...for ranges. No nested-query DSL unless the product justifies one. - Sorting:
?sort=fieldascending,?sort=-fielddescending. Multi-sort:?sort=-created_at,id.
Step 5: Idempotency, rate limiting, auth envelopes
- Idempotency: every mutation accepts
Idempotency-Key: <client-generated>. Server stores result keyed by(endpoint, key)for at least 24h. Replays return the original response. - Rate limiting: every response includes
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset.429on exceedance withRetry-After. - Auth: standard
Authorization: Bearer <token>. Errors use stable codes (AUTH_MISSING,AUTH_EXPIRED,AUTH_INVALID,FORBIDDEN). Never leak existence —403and404for "exists but you can't see it" both return404at the public boundary.
Step 6: Document the public/internal boundary
In .forge/api-design.md: list every endpoint as public (versioned, deprecation-aware, hardened) or internal (faster iteration, still auth-gated, never reachable from internet). Internal endpoints get their own subdomain or VPC routing. Mixing the two on the same hostname guarantees future leaks.
Step 7: Header
Prepend a forge:meta header (generated_by: api-design, generated_at: <ISO 8601 UTC with Z>, depends_on: [.forge/architecture.md] — paths only, never hashes, generated_from: {.forge/architecture.md: <upstream content_hash AT generation time>}, content_hash: <sha256 first 8 of THIS file's body>). See forge-dependency-graph.
Verification
- Existing
.forge/contracts/*.mdread (or noted as absent); every endpoint traces back to a contract operation - No endpoint invents an error code that contradicts the contract's named errors
- If a needed operation is missing from contracts, a
/feedbackentry was filed targeting the contract — not silently added here -
.forge/api-design.mdwritten - Every endpoint returns errors in the standard envelope (code, message, request_id)
- Every endpoint has a version (URI, header, or query) — none unversioned
- Every mutation supports an
Idempotency-Keyheader (or has a documented reason it doesn't need one) - Every paginated list uses the standard cursor envelope
- No
200 OKwith{ "success": false }anywhere - Every response includes
RateLimit-*headers and arequest_id - Public-vs-internal labels assigned for every endpoint, with separate hostnames
- Breaking changes have a documented deprecation window before the cut-over