# Loom API Documentation

> Document REST APIs with OpenAPI/Swagger specifications, endpoint references, authentication flows, error handling, and SDK guides.

- Skill: `cosmix/loom-api-documentation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add cosmix/loom-api-documentation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/cosmix/loom-api-documentation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: cosmix (https://skillmd.com/u/cosmix)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/cosmix/loom-api-documentation

---


# API Documentation

## Overview

Produce API docs developers can actually use: an accurate OpenAPI spec as the source of truth, plus reference/auth/error/versioning guides generated or kept in sync with it. Correctness and drift-prevention matter more than prose.

## What every API must document

Auth · base URLs per environment · every endpoint + operation · request/response schemas · **all** response codes (incl. errors) · rate limits (with headers) · pagination · versioning/deprecation policy.

## Spec-first vs code-first (choose deliberately)

| Approach | How | Drift risk | Use when |
| -------- | --- | ---------- | -------- |
| **Spec-first** | Hand-write OpenAPI, generate server stubs + clients + mocks | Runtime can diverge from spec unless validated | New APIs, contract negotiated across teams, mock-driven frontend |
| **Code-first** | Annotate handlers; framework emits spec (FastAPI, springdoc, drf-spectacular, tsoa) | Spec stays close to code, but annotations can lie | Existing codebase, small team, code is the truth |

Either way, **enforce the contract in CI** (lint + validate examples + breaking-change diff). Docs that aren't tested against the running API are fiction.

## OpenAPI 3.1 — what changed from 3.0 (get these right)

- **Fully aligned with JSON Schema 2020-12.** A schema is now a valid JSON Schema; you can set `jsonSchemaDialect` and use `$schema` per-schema.
- **`nullable: true` is GONE.** Use a type array: `type: [string, "null"]`.
- **Type can be an array**: `type: [string, integer]`.
- **`exclusiveMinimum`/`exclusiveMaximum` are numbers**, not booleans (draft-4 behavior removed).
- **Top-level `webhooks`** describe events the API *sends* (see below).
- **Examples split by object**: Schema Objects use JSON Schema's `examples` (an **array**); Media Type / Parameter Objects use `example` (singular) or `examples` (a **map of named Example Objects** with `summary`/`value`). Don't confuse the two.
- `info.license.identifier` accepts an **SPDX** id (e.g., `MIT`) instead of a URL.
- `$ref` may now sit alongside sibling keywords (e.g., `description`).

⚠ Tooling lag: Swagger UI / some generators still have partial 3.1 support. Verify your renderer and codegen handle 3.1 before committing to `type: [..., "null"]` everywhere.

## Documentation quality rules

- Write for competent developers: skip patronizing basics; lead with a working example, then explain.
- Keep schemas DRY with `$ref`; reuse `parameters`, `responses`, `securitySchemes` from `components`.
- **Every operation needs a unique `operationId`** — it becomes the generated client's method name. Renaming it is a breaking change for SDK users.
- Tag endpoints for navigation; realistic example data (not `foo`/`bar`); document rate limits with concrete numbers **and** the exact headers.
- Validate every example against its schema (Redocly/Spectral catch this).

## Examples

### OpenAPI 3.1 spec (trimmed to the load-bearing shapes)

```yaml
openapi: 3.1.0
info:
  title: User Management API
  version: 2.0.0
  license: { name: MIT, identifier: MIT }   # 3.1 SPDX identifier
servers:
  - { url: https://api.example.com/v2, description: Production }
  - { url: https://api.staging.example.com/v2, description: Staging }
security:
  - BearerAuth: []
tags:
  - { name: Users, description: User management }

paths:
  /users:
    get:
      summary: List users
      operationId: listUsers          # stable → SDK method name
      tags: [Users]
      parameters:
        - $ref: "#/components/parameters/LimitParam"
        - name: status
          in: query
          schema: { type: string, enum: [active, inactive, pending] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserListResponse" }
              examples:              # media-type: MAP of named Example Objects
                page1:
                  summary: First page
                  value:
                    data: [{ id: usr_123, email: john@example.com, status: active }]
                    pagination: { limit: 20, next_cursor: "eyJpZCI6MTIzfQ", has_more: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create user
      operationId: createUser
      tags: [Users]
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateUserRequest" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/User" } } } }
        "409":
          description: Email already exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }

webhooks:                            # 3.1: events the API SENDS
  userCreated:
    post:
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/User" }
      responses:
        "200": { description: Receiver acknowledged }

components:
  securitySchemes:
    BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
    ApiKeyAuth: { type: apiKey, in: header, name: X-API-Key }
    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://auth.example.com/authorize
          tokenUrl: https://auth.example.com/token
          scopes: { "users:read": Read users, "users:write": Manage users }
  parameters:
    LimitParam:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
  schemas:
    User:
      type: object
      required: [id, email, status]
      properties:
        id: { type: string, example: usr_123 }
        email: { type: string, format: email }
        status: { type: string, enum: [active, inactive, pending] }
        deletedAt: { type: [string, "null"], format: date-time }   # 3.1 nullable
    CreateUserRequest:
      type: object
      required: [email, name, password]
      properties:
        email: { type: string, format: email }
        name: { type: string, minLength: 2, maxLength: 100 }
        password: { type: string, format: password, minLength: 8 }
    UserListResponse:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/User" } }
        pagination:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: [string, "null"] }
            has_more: { type: boolean }
    Problem:                          # RFC 9457 application/problem+json
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              code: { type: string }
              message: { type: string }
  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Rate limit exceeded
      headers:
        X-RateLimit-Limit: { schema: { type: integer }, description: Requests per window }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset: { schema: { type: integer }, description: Unix epoch when window resets }
        Retry-After: { schema: { type: integer }, description: Seconds to wait }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Problem" }
```

### Endpoint reference (Markdown template)

````markdown
## Create User — `POST /users`

Auth: Bearer token. Idempotent via `Idempotency-Key` header.

**Body**

| Field    | Type   | Req | Notes                                             |
| -------- | ------ | --- | ------------------------------------------------- |
| email    | string | yes | Valid email                                       |
| name     | string | yes | 2–100 chars                                        |
| password | string | yes | ≥8 chars; upper+lower+digit+symbol                |

```bash
curl -X POST https://api.example.com/v2/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f1c...-once" \
  -d '{"email":"jane@example.com","name":"Jane Smith","password":"SecureP@ss123"}'
```

**201 Created** → `{ "id": "usr_abc123", "email": "jane@example.com", "status": "pending" }`

| Status | Code             | Meaning                  |
| ------ | ---------------- | ------------------------ |
| 401    | UNAUTHORIZED     | Missing/invalid token    |
| 409    | USER_EXISTS      | Email already registered |
| 422    | VALIDATION_ERROR | See `errors[]` per field |
| 429    | RATE_LIMITED     | Honor `Retry-After`      |
````

### Auth flows (document the full lifecycle, not just the header)

````markdown
## Bearer (JWT)

`POST /auth/login` → `{ "accessToken": "...", "refreshToken": "...", "expiresIn": 3600 }`
Send `Authorization: Bearer <accessToken>`. Access token 1h, refresh 30d; `POST /auth/refresh` to renew.

## API key (server-to-server)

`X-API-Key: sk_live_...`. Never ship keys client-side; scope minimally; rotate ≤90 days; one key per environment.
````

### Documenting errors, rate limits, versioning consistently

Reference the RFC 9457 `Problem` schema for **every** error response; maintain one canonical error-code table:

| Code | HTTP | Meaning | Client action |
| ---- | ---- | ------- | ------------- |
| UNAUTHORIZED | 401 | No/invalid token | Re-auth |
| INSUFFICIENT_SCOPE | 403 | Token lacks scope | Request scopes |
| VALIDATION_ERROR | 422 | Field validation failed | Inspect `errors[]` |
| NOT_FOUND | 404 | Missing resource | Verify id |
| ALREADY_EXISTS | 409 | Duplicate/conflict | Use unique key |
| RATE_LIMITED | 429 | Throttled | Wait `Retry-After` |

Rate limits: always document the window, the limit, and the `X-RateLimit-*` + `Retry-After` headers. Versioning: publish supported versions + sunset dates, classify breaking vs non-breaking, and give side-by-side migration examples plus `Deprecation`/`Sunset` response headers.

## Tooling

| Job | Tool | Command |
| --- | ---- | ------- |
| Render (3-panel) | Redoc | `npx @redocly/cli build-docs openapi.yaml -o docs.html` |
| Render (try-it) | Swagger UI | `docker run -p 80:8080 -e SWAGGER_JSON=/api/openapi.yaml -v $(pwd):/api swaggerapi/swagger-ui` |
| Embed | Stoplight Elements | `<elements-api apiDescriptionUrl="./openapi.yaml" router="hash" />` |
| Lint | Spectral / Redocly | `spectral lint openapi.yaml` · `npx @redocly/cli lint openapi.yaml` |
| Bundle | Redocly | `npx @redocly/cli bundle openapi.yaml -o bundled.yaml` |
| Client gen | OpenAPI Generator | `openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o ./client` |
| Postman | openapi-to-postmanv2 | `openapi2postmanv2 -s openapi.yaml -o collection.json` |
| Mock | Prism | `prism mock openapi.yaml` |
| Contract test | Dredd / Schemathesis | `dredd openapi.yaml http://localhost:3000` · `schemathesis run openapi.yaml` |
| Breaking-change diff | oasdiff | `oasdiff breaking old.yaml new.yaml` |

## Keeping docs in sync with code (the real problem)

Docs rot the moment they're decoupled from the running service. Enforce sync mechanically:

- **Runtime validation** — proxy requests/responses through the spec (`express-openapi-validator`, Prism proxy) in dev/staging; a mismatch fails the build.
- **Contract tests in CI** — Dredd (example-driven) or Schemathesis (property-based fuzzing derived from the schema) run against the real API; Schemathesis catches undocumented 500s and schema violations you'd never write by hand.
- **Breaking-change gate** — `oasdiff breaking` (or openapi-diff) on every PR blocks silent contract breaks.
- **Lint gate** — Spectral ruleset enforces house style (descriptions present, `operationId` unique, examples valid, error responses documented).
- **Single source** — generate SDKs and mocks from the spec so they can't disagree; never hand-maintain a second copy of the contract.

## Anti-patterns

- Documenting only happy-path 200s — clients need the 4xx/5xx bodies and codes to handle failure.
- Prose clients must parse (switching on `message` text). Give stable machine-readable `code`/`type`.
- `example` vs `examples` mixups (schema=array, media-type=map) — renders empty or errors in tooling.
- Reusing/renaming `operationId` — silently breaks generated SDKs.
- Fake data (`foo`/`bar`) and fragment-only snippets — show complete, copy-pasteable, realistic requests.
- Screenshots of JSON instead of copyable code blocks.

## Checklists

**Spec quality — before publish:**

- [ ] `openapi: 3.1.x`; nullable via `type: [..., "null"]` (no `nullable:`)
- [ ] Every operation has a unique, stable `operationId` and is tagged
- [ ] All response codes documented incl. every 4xx/5xx, each referencing the shared `Problem` (RFC 9457) schema
- [ ] Rate-limit responses document `X-RateLimit-*` + `Retry-After`; pagination shape documented consistently
- [ ] Security schemes defined and applied (global `security` + per-op overrides); OAuth2 flows/scopes listed
- [ ] `example`/`examples` used correctly per object type; every example validates against its schema
- [ ] Schemas DRY via `$ref` from `components`

**Sync & release — in CI:**

- [ ] Spectral/Redocly lint passes
- [ ] Contract test (Dredd/Schemathesis) runs against the real API and passes
- [ ] `oasdiff breaking` shows no unintended breaking changes (or version bumped + migration guide written)
- [ ] SDKs/mocks regenerated from the spec; `Deprecation`/`Sunset` headers set for retiring versions

