Spec-First API Documentation

Ledger API

Demonstration piece. Shipped client documentation is under NDA, so this was built to the same standard instead.

11 operations · 7 webhooks · 27 governance rules, each tested against an injected defect.

The sampleOpen Ledger API
Problem
API reference that documents the happy path and leaves every failure state to inference.
Insight
Reference documentation tends to describe what happens when things work. The questions that actually stop an integration are about failure: what broke, and is it safe to try again.
Result
A spec-first billing API documentation system with a governance ruleset that runs in CI and blocks the merge.

The full story

Most published API reference is generated from a specification that no one governs. The happy path is documented, the 200 response has an example, and everything that can go wrong is left for the integrator to discover in production. The Ledger API is a complete working system built to demonstrate the opposite standard: a billing and invoicing API covering invoices, payments, and refunds, documented spec-first, with every failure state named and every design decision recorded.

The specification is the source of truth. OpenAPI 3.1.1 holds 11 operations, 7 webhooks, and 20 schemas. The published reference is generated from it, so the two cannot drift, and the SDK method names come from its operationIds. The error reference, the design record, and the release notes are written by hand against it and checked against it whenever it changes. What makes the specification enforceable rather than aspirational is redocly.yaml: 27 governance rules, 18 built-in and 9 written for this API, that run in CI and fail the build. A float on a money field is blocked at the spec. An operation that does not document its 401, 429, and 500 cannot merge.

The rules were tested the way tests should be tested: four defects were deliberately injected into a copy of the spec, including a float amount, an undocumented 401, and a non-camelCase operationId. All four were reported, each by the rule expected to catch it.

The error reference is written to one constraint: one cause and one fix per entry. A page that lists six possible causes has moved the diagnosis back onto the reader. It opens with the three things an integrator must know before anything else, and it puts the most consequential sentence in the document where a reader in trouble will find it, under 5xx: a 5xx on a write means unknown, not failed, and retrying with a new idempotency key is how duplicate charges are created.

The design decisions are recorded in a separate document, 11 of them, each naming the alternative that was rejected and the cost of the choice that won.

Key decisions

  • A declined payment returns 201, not 402. An HTTP status describes the fate of the request, not the fate of the business operation. Routing declines through 4xx sends them to a generic error handler that cannot tell a declined card from a wrong API key, and into retry middleware that issuers read as an abuse signal.
  • Money is an integer in the currency minor unit, inside a Money object that binds amount to currency. Floats drift invisibly on one invoice and materially across a month of reconciliation. Binding the pair in one object stops an amount from traveling without its currency, and the schema documents the zero-decimal and three-decimal currencies explicitly, because minor unit does not mean divide by 100.
  • A resource owned by another account returns 404, not 403. The literally truthful 403 confirms the identifier is real, which turns a permission check into an enumeration oracle. The ambiguity is deliberate, and the reference says so, because defensive behavior that integrators do not know about comes back as support tickets.
  • Idempotency conflicts split into two 409 codes, not one. In-progress means retry shortly; key-reused means stop and fix your code. Collapsing them forces every integrator to guess, and the common guess turns a caller bug into a retry storm.
  • Webhook verification is documented as four numbered rules instead of one sentence about HMAC. Each of the four omissions, hashing a re-serialized body, comparing in non-constant time, skipping the timestamp tolerance, and not deduplicating on event id, is a live vulnerability or outage, so each one gets its own numbered rule.

What it taught me

The first draft cited the rate-limit response fields as RFC 9773. There is no RFC 9773. I had written the number from memory and not checked it, and it survived my own review because a specific-looking citation reads as evidence that someone already did the checking. I found it by going through every normative reference against its source before publishing, and corrected it to the Internet-Draft the fields actually come from. It is in the decision log, not quietly deleted, because that is the error I am most likely to make again.

← Back to Portfolio