Skip to content

API Contract Design

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/api-design.md
DescriptionNot specified

Source Content

API Contract Design

Use this reference when designing a new API, adding endpoints to an existing service, choosing between protocols, deciding when to cut a /v2, or generating openapi.yaml from Zod schemas. APIs are the longest-lived contracts in the system — lock in the shape, versioning story, error envelope, and a Prism mock before a single handler ships.

This reference covers REST/OpenAPI 3.1 (the default for polyglot or public APIs), tRPC (TS-only internal monorepos), GraphQL (graph-shaped reads), and gRPC (perf-critical internal services). See the parent SKILL.md for the design workflow and elicitation points.

Quick reference: Five rules for any API

  1. One error envelope. RFC 9457 Problem Details, no ad-hoc shapes.
  2. Cursor pagination, not OFFSET, for any list endpoint that may exceed 10k rows.
  3. Idempotency keys on every state-changing POST (RFC 7231).
  4. Versioning story stated — additive forever, or /v2 with a 90-day deprecation window.
  5. Mock command emitted so frontend isn’t blocked on handler implementation.

Protocol choice matrix

ProtocolBest forTrade-offs
REST / OpenAPI 3.1Polyglot services, public APIs, heterogeneous clientsSlightly verbose; well-understood; HTTP semantics matter; tooling mature
tRPCTS-only internal monorepos, shared schema, speed-to-shipTypeScript-only; smaller ecosystem; assumes shared runtime
GraphQLGraph-shaped reads (many small joins per client), federation across teamsComplex validation; N+1 query trap; requires resolver discipline; slower mutation design
gRPC + bufPerf-critical internal services, binary protocol, streamingSteep learning curve; less human-friendly; requires proxy for browser clients

One question to ask: “Who consumes this — internal TS only, polyglot, or public third-parties?” The answer almost always picks the protocol.

REST / OpenAPI 3.1 (the default)

Resource modeling

  • Use plural nouns for collections: /invoices, /users, /subscriptions.
  • Use nested resources for relationships: /invoices/{id}/line_items (not /line_items?invoice_id={id}).
  • Map to correct HTTP verbs:
    • GET /resource — list (paginated)
    • GET /resource/{id} — fetch one
    • POST /resource — create (always idempotent)
    • PUT /resource/{id} — replace (all fields required)
    • PATCH /resource/{id} — partial update (sparse fields)
    • DELETE /resource/{id} — delete

Error envelope: RFC 9457 Problem Details

Every error response uses this shape (never bare strings, never ad-hoc):

{
"type": "https://api.example.com/errors#payment-failed",
"title": "Payment Processing Failed",
"status": 402,
"detail": "Card declined: insufficient funds",
"instance": "/invoices/inv-123"
}

Map all errors to HTTP status codes:

HTTP StatusProblem TypeWhen
400bad-requestValidation failed (malformed JSON, missing required field)
401unauthorizedAuthentication missing or invalid
403forbiddenAuthenticated but not authorized for this resource
404not-foundResource does not exist
409conflictState conflict (duplicate, precondition failed)
422validation-errorSemantic validation (e.g., past date for a future deadline)
429rate-limit-exceededRate limit hit; include Retry-After header
500internal-errorServer error; never echo internal details to client

Example OpenAPI schema for Problem Details:

components:
schemas:
ProblemDetails:
type: object
required:
- title
- status
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
minimum: 400
maximum: 599
detail:
type: string
instance:
type: string
format: uri

Pagination: cursor-based, never OFFSET

Cursor pagination (correct):

GET /invoices?cursor=next_token_from_previous_response&limit=50
Response:
{
"items": [...50 invoices...],
"next_cursor": "eyJpZCI6ICJpbnYtMTIzIn0="
}

OFFSET pagination (wrong for >10k rows):

GET /invoices?offset=1000&limit=50

Why? OFFSET scans and discards 1000 rows for every request — O(n) per page. A cursor with an index does not.

For small lists (<1k rows), OFFSET is tolerable; use cursor everywhere else.

Cursor implementation:

// Encode: base64(json of last-row's sort key)
next := base64.StdEncoding.EncodeToString([]byte(`{"id":"inv-999"}`))
// Decode: extract sort key and fetch the next page
var cursor Cursor
json.Unmarshal(base64.StdEncoding.DecodeString(req.Cursor), &cursor)
rows := db.Query("SELECT ... WHERE id > ? ORDER BY id LIMIT ?", cursor.ID, limit)

Idempotency keys (RFC 7231 § 4.2.2)

Every POST that mutates state must accept an Idempotency-Key header. The server uses it to deduplicate retries:

POST /invoices
Idempotency-Key: 8e03978c-51f4-49d3-9a8f-24b6c4d70f98
{
"amount": 1000,
"customer_id": "cust-123"
}

Implementation:

  1. On first request, generate a response ID
  2. Store (idempotency_key, response_id) in a dedup table
  3. On retry with same key, fetch and return the cached response (same status code, same body)
  4. Expire cache entries after 24–48 hours

This makes retries safe — the client can retry forever without fear of double-charging, double-creating, etc.

Versioning strategy

Option A: Additive forever (recommended for most services)

  • New fields are always optional
  • Old fields are never removed (mark deprecated with x-deprecated: true)
  • New endpoints are added under the same /api/ root
  • No /v2 endpoint ever created
  • Example: add a metadata field to an invoice without breaking v1 clients

Option B: Major versions (only when breaking change tolerance is zero)

  • Breaking changes trigger a new /v2 endpoint
  • The /v1 endpoint is supported for 90 days (window announced in advance)
  • After 90 days, /v1 is removed or returns 410 Gone
  • Example: invoice schema fundamentally changes; a /v2 endpoint with incompatible types is created

State your versioning decision in the spec (info.version in OpenAPI):

openapi: 3.1.0
info:
version: 1.0.0
title: Invoice API
description: |
**Versioning strategy:** Additive forever. We never break old clients.
All new fields are optional; old fields are never removed.

tRPC (TypeScript-only monorepos)

For TS-only internal services, tRPC is faster than OpenAPI:

import { z } from 'zod';
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const invoiceRouter = t.router({
list: t.procedure
.input(z.object({ cursor: z.string().optional() }))
.output(z.object({
items: z.array(invoiceSchema),
next_cursor: z.string().optional(),
}))
.query(async ({ input }) => {
// implementation
}),
create: t.procedure
.input(invoiceCreateSchema)
.output(invoiceSchema)
.mutation(async ({ input }) => {
// implementation
}),
});
export const appRouter = t.router({ invoices: invoiceRouter });

Why tRPC?

  • Schema inferred from Zod → full type safety on client and server
  • One source of truth (the router, not a separate OpenAPI spec)
  • No code generation step

When to use: Internal monorepo, frontend and backend in the same repo, TS/Node stack.

When NOT to use: Public APIs, polyglot teams, or when clients need a human-readable contract (OpenAPI spec).

GraphQL (graph-shaped reads)

Use GraphQL when clients need flexible shape control and many small joins per request:

type Query {
invoice(id: ID!): Invoice
invoices(
after: String
first: Int = 50
): InvoiceConnection!
}
type InvoiceConnection {
edges: [InvoiceEdge!]!
pageInfo: PageInfo!
}
type Invoice {
id: ID!
amount: Int!
customer: Customer!
lineItems: [LineItem!]!
}
type Customer {
id: ID!
name: String!
# client can fetch this without a separate call
}

Why GraphQL?

  • Clients fetch exactly the fields they need (no over-fetching)
  • One roundtrip for related entities (customer + line items together)
  • Schema is self-documenting and introspectable

Gotchas:

  • N+1 query trap: naive resolvers fetch related entities one-at-a-time → slow. Use batch loaders or dataloader-style batching.
  • Mutations are second-class: design the mutation input shape carefully, validate at the field level.
  • No built-in pagination: use cursor-based pagination (Relay style) with Connection and Edge types.

When to use: Many small joins per client, heterogeneous data shapes, web-first focus.

When NOT to use: Simple CRUD APIs, public APIs that benefit from OpenAPI docs, or teams unfamiliar with resolver patterns.

gRPC + buf (perf-critical internal services)

Use gRPC for binary protocol, streaming, and microsecond-scale latency:

service InvoiceService {
rpc GetInvoice(GetInvoiceRequest) returns (Invoice);
rpc ListInvoices(ListInvoicesRequest) returns (stream Invoice);
rpc CreateInvoice(CreateInvoiceRequest) returns (Invoice);
}
message Invoice {
string id = 1;
int64 amount_cents = 2;
string customer_id = 3;
repeated LineItem line_items = 4;
}
message GetInvoiceRequest {
string id = 1;
}
message ListInvoicesRequest {
string cursor = 1;
int32 limit = 2 [(buf.validate.field).int32.gte = 1, (buf.validate.field).int32.lte = 100];
}

Why gRPC?

  • Binary protocol → smaller messages, faster parsing
  • HTTP/2 multiplexing → many parallel streams over one connection
  • Streaming → real-time data push

Gotchas:

  • Requires a proxy (grpc-web or Envoy) for browser clients
  • Protobuf learning curve
  • Debugging is harder (not human-readable like JSON)

When to use: Backend-to-backend services, real-time data pipelines, latency-sensitive internal APIs.

When NOT to use: Public APIs, browser clients, or teams unfamiliar with Protobuf.

Linting and validation

Spectral (OpenAPI only)

Spectral is an OpenAPI linter that catches common shape mistakes:

Terminal window
npm i -g @stoplight/spectral-cli
spectral lint api/openapi.yaml

It checks for:

  • Missing description fields
  • Inconsistent error responses across endpoints
  • Missing security schemes
  • Unused schemas

Create an .spectralrc.yaml if you want org-specific rules:

extends: spectral:oas
rules:
operation-operationId-unique: error
path-keys-no-trailing-slash: warn

House rules (this skill’s scripts/lint_openapi.sh)

Automated checks for this skill’s own requirements:

  1. RFC 9457 Problem Details shape — all error responses have type, title, status, detail fields
  2. Cursor pagination — all list endpoints have a cursor parameter (not offset)
  3. Idempotency-Key — all POST operations reference the Idempotency-Key header

Run scripts/lint_openapi.sh api/openapi.yaml to validate.

Prism mocks (frontend can build in parallel)

Once the contract is locked, emit a Prism mock server so frontend doesn’t wait for handlers:

Terminal window
npm i -g @stoplight/prism-cli
prism mock api/openapi.yaml --dynamic # starts a mock server on :4010

Frontend can:

fetch('http://localhost:4010/invoices')
.then(r => r.json())
.then(data => console.log(data))

Prism generates realistic example data from the spec. When handlers are ready, swap localhost:4010 for the real API.

Bruno / Hurl tests (commit next to the spec)

Write portable API tests in Bruno or Hurl, committed alongside the spec:

Bruno (GUI + CLI):

// request GET /invoices
GET http://{{host}}/invoices?cursor={{cursor}}&limit=50
Authorization: Bearer {{token}}
@name get_invoices

Hurl (plain text, git-friendly):

GET http://localhost:8080/invoices?limit=50
Authorization: Bearer token123
HTTP 200
[Captures]
next_cursor: body.next_cursor

Both formats are human-readable, versionable, and runnable in CI.

How to use this reference

  • Follow the workflow in the parent SKILL.md’s “How I work (API contracts)” section
  • Choose your protocol using the matrix above
  • If REST/OpenAPI: follow the RFC 9457, cursor pagination, and Idempotency-Key rules
  • Write the spec first (even if you generate it from code schemas)
  • Emit a Prism mock and commit Bruno/Hurl tests
  • Run scripts/lint_openapi.sh before submission
  • Hand the spec to frontend so they can mock and build in parallel