Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

OpenAPI workflow

api/openapi.yaml is the single source of truth for the HTTP API. make gen regenerates four things from that one file: the Go server interface, the TypeScript types, the @tanstack/react-query hooks, and the spec copy served to external consumers at /openapi.yaml. CI fails the build if any generated file drifts from what the spec describes.

This is the same shape Stripe, Twilio, GitHub and Kubernetes use to keep their SDKs aligned with their HTTP surface.

  1. Edit api/openapi.yaml. Add the path, the operationId, the request body schema (under components.schemas if reusable, inline otherwise), and the response shape. Include error responses such as 404, so the generated TS types narrow on status and callers get the right body type per status code. A POST, PUT or PATCH under /v1 also lists $ref: "#/components/parameters/IdempotencyKey" in its parameters. The exceptions are /v1/auth/*, /v1/public/*, the inbound webhooks, /v1/tenants and /v1/bootstrap: the server ignores the header there.
  2. Run make gen from the repo root. It runs both Go and TS codegen, refreshes web-v2/public/openapi.yaml, and rewrites the generated files in place.
  3. Implement the handler. On the Go side, add a method on the relevant handler that matches the generated ServerInterface signature, for example func (h *Handler) GetInvoice(w, r, id string). A var _ <interface> = (*Handler)(nil) assertion (a compile-time check that the handler implements the generated interface) turns spec-vs-code drift into a build error rather than a runtime 404.
  4. Move the dashboard to the generated hook. Replace ad-hoc useQuery({ queryFn: () => api.foo() }) calls with the generated hook, for example useGetInvoice(id). The hook handles the URL, the query keys and the typed response shape. The mutator (orvalClient, the function every generated request goes through) sends the request through apiRequest, so cookies, error-envelope parsing and Hardcount-Request-Id capture keep working unchanged.
  5. Run the local gates: go build ./..., go vet ./..., go test ./... -short, cd web-v2 && npx tsc -b. Use tsc -b: --noEmit is a no-op under this repo’s composite project references.
  6. Commit the spec change and the regenerated files together. See CI gate.
Path What it is Hand-edit?
api/openapi.yaml OpenAPI 3.0.3 source of truth Yes — this is the contract
api/cfg.yaml oapi-codegen config (Go) Rarely
tools/tools.go Build-tagged import that pins oapi-codegen to go.mod When bumping codegen
internal/api/generated/api.gen.go Generated Go types + chi-compat ServerInterface No — regenerate
web-v2/orval.config.ts orval config (TS hooks) Rarely
web-v2/scripts/copy-openapi-spec.mjs Copies api/openapi.yaml into web-v2/public/openapi.yaml, so the served spec and codegen read the same file No
web-v2/src/lib/api.gen.ts Generated raw TS types from openapi-typescript No — regenerate
web-v2/src/lib/gen/queries.gen.ts Generated react-query hooks from orval No — regenerate
web-v2/src/lib/gen/schemas/ Generated TS schema types (one file per components.schemas entry) No — regenerate
web-v2/src/lib/orvalClient.ts Hand-written orval mutator that delegates to the existing apiRequest fetch wrapper. See Why a custom orval mutator Yes

.github/workflows/ci.yml runs make gen, then git diff --exit-code. The first command regenerates everything from the spec. The second fails if that changed anything in the working tree. So both of these fail CI:

  • a spec edit without the regenerated files;
  • a hand edit to a generated file, such as api.gen.go.

The rule: generated files must equal what make gen produces from the committed spec.

A unit test also ties the spec to the router. TestOpenAPISpec_MatchesRouter in internal/api/openapi_routes_test.go walks the chi router and fails when:

  • a route the server answers is not in the spec with the same method and path parameter names;
  • an operation in the spec matches no route.

A route that no caller should use can go in routesNotInSpec in that test instead, with the reason. Today that is only the methods other than GET on /metrics, which exist because /metrics is registered for every method.

TestOpenAPISpec_IdempotencyKeyHeader in the same file checks the Idempotency-Key header. It fails when:

  • a POST, PUT or PATCH route behind the idempotency middleware does not list the IdempotencyKey parameter in the spec;
  • an operation lists it but the middleware does not run on that route, or skips it (the usage routes, isIngestPath), so the server would ignore the header.

TestOpenAPISpec_RequestBodies in openapi_bodies_test.go checks request bodies. requestBodies maps each operation that has a JSON body to the Go type its handler decodes. The test compares the type’s JSON tags with the spec’s properties, nested objects included, and fails when:

  • the type reads a field the spec does not list, so generated clients cannot send it;
  • the spec lists a field the type does not have, so the server drops it, or refuses it on a strict route;
  • one side is an array or an object with fields and the other is not, at any depth;
  • an operation with a JSON body is in neither requestBodies nor bodiesNotChecked.

It compares field names and shape, not value types: a spec string against a Go int64 passes.

A field left out of the spec on purpose, such as a read-only id on a type the handler decodes whole, goes in the entry’s undocumented with the reason. The test starts from the spec, so it cannot see a handler that decodes a body the spec does not declare. When you add one, add its requestBody too.

Why a build-time copy of the spec into web-v2/public/

Section titled “Why a build-time copy of the spec into web-v2/public/”

The spec is served to external consumers at a stable same-origin URL (/openapi.yaml). The embedded Scalar viewer that rendered it at /docs/api was cut 2026-04-29. web-v2/scripts/copy-openapi-spec.mjs copies api/openapi.yaml into web-v2/public/openapi.yaml, and runs as a predev / prebuild / gen hook in web-v2/package.json.

A symlink would break on hosts that flatten symlinks during checkout. An explicit copy is portable, and it shows in the package.json scripts as a clear dependency.

orval builds its own Axios client by default. We delegate to apiRequest (in web-v2/src/lib/api.ts) instead, because it already carries:

  • session-cookie auth;
  • parsing of the Stripe-style error envelope (the { error: {…} } JSON wrapper on failure responses) into ApiError;
  • capture of the Hardcount-Request-Id header into setLastRequestId, for “Report an issue” trace handles.

Reusing the wrapper keeps generated hooks on the same auth posture as every hand-written caller in the dashboard. Stripe-node makes the same trade-off: the SDK is generated from the spec, and the transport is hand-written and reused.

They regenerate from the same spec on make gen, and they do different jobs:

  • openapi-typescript writes web-v2/src/lib/api.gen.ts: one flat type tree indexed by URL path and method. A page that has not moved to the typed hooks can still take a precise payload type from paths['/v1/foo']['get'].
  • orval writes the typed react-query hooks.
  • Optional fields are plain types, not pointers. prefer-skip-optional-pointer: true in api/cfg.yaml renders optional fields with omitempty JSON tags rather than *string. A *"" non-nil pointer marshals as "", which would break wire-shape parity with the existing domain.Invoice JSON output. The cost: callers cannot tell “field absent” from “field present and empty” at the type level. For Hardcount’s domain types, every empty string means the same as “absent”, so that is the right call.
  • Generated hooks cannot send Idempotency-Key yet. The spec declares the header, but orval leaves header parameters out of the hooks it generates, and orvalClient passes only the method, path and body to apiRequest. A dashboard call that must send the header stays on apiRequest until the mutator forwards headers.
  • Hooks return the bare body. output.override.fetch.includeHttpResponseReturnType: false keeps the orval hook return types as the body (Promise<InvoiceWithLineItems>), not the { data, status, headers } envelope. The mutator throws ApiError on non-2xx and resolves with the body on 2xx. The bare-body type matches what it returns, and removes a .data step at every call site.
  • Generated files live in web-v2/src/lib/gen/, not beside the hand-written files in web-v2/src/lib/. orval’s clean: true would delete the parent of target recursively. Pointed at src/lib/queries.gen.ts, it would delete every hand-written file in src/lib/ on the next regen. The gen/ subdirectory limits the damage even if clean is ever turned back on. npm run gen deletes src/lib/gen/ before orval runs, because orval only rewrites the files it generates: a schema whose route left the spec would otherwise stay behind.