Decision
The v3 OpenAPI spec underdocs/api-v3-reference/src/ is the single source of truth for v3 request and
response shapes. Zod schemas and their TypeScript types are generated from it, not written by hand and not
the other way round (ENG-3335).
This replaces the earlier plan to generate the spec from Zod with zod-openapi, as v2 does. Spec-first keeps
the contract as the reviewed artifact that Schemathesis, Mintlify and the MCP server already read. It also keeps
the prose, examples and error documentation where they live today, and makes contract drift a build failure
instead of something a bespoke drift test may or may not cover.
v2 keeps its code-first zod-openapi setup. Only v3 is in scope.
Generator
@hey-api/openapi-ts (MIT) with its zod plugin, exact-pinned in packages/api-v3-schemas. It was chosen
after running every serious candidate against the real bundle:
If hey-api’s pre-1.0 churn ever outweighs its fidelity, orval is the fallback, with strictness and
cuid2 moved
into the refinement layer.
Pipeline
pnpm api:v3:schemas runs packages/api-v3-schemas/scripts/generate.ts:
1
Read the committed bundle
docs/api-v3-reference/openapi.yml — the same file Schemathesis reads, so the schemas and the contract tests
can never be generated from different inputs.2
Scope to the adopted tags
scripts/adopted.ts lists each tag with its exact operationIds. Only those operations and the components they
reach are generated, so an edit elsewhere in the contract cannot break the pipeline, and a renamed tag or
operation fails it instead of silently generating less.3
Normalize
scripts/normalize.ts rewrites the 3.1 idioms hey-api mis-generates: it flattens allOf +
unevaluatedProperties: false into one closed object, collapses branches that only carry keywords the
generator ignores, drops many-to-one discriminator mappings once the members’ own enums prove them redundant,
and strips readOnly. What may reach the generator is an allowlist of keywords; anything outside it, and any
recursive schema, throws — a construct the generator silently drops is a validation hole.4
Generate and format
hey-api runs in memory with two resolvers:
format: cuid2 becomes z.cuid2(), and additionalProperties: false becomes z.strictObject. Without the second, unknown keys would be stripped instead of answering 400
unsupported_field — a mass-assignment guard lost. The output is formatted with the repository’s Prettier
config into src/generated/zod.gen.ts.Guards
- Freshness.
pnpm lintrunspnpm api:v3:check(bundle vssrc/) andpnpm api:v3:schemas:check(generated module vs bundle, byte for byte) on every PR. After editing the spec,pnpm api:v3:syncregenerates both. - Fidelity.
src/generated.contract.test.tsdiffs the JSON Schema of every generated component and operation schema against the spec, path by path: closedness, required, nullability, formats, bounds, enums, union members (each one, including duplicates), conditionals (fingerprinted) and closedallOfbranches that others extend. Because JSON Schema shows az.unknown()key as required while Zod accepts it missing, it also asks each required key at runtime. It does not model recursion — the normalizer refuses recursive schemas until it does. - Pinned gaps. hey-api cannot express
uniqueItems,minProperties/maxProperties,if/then, or a required key with no type. Each such constraint is pinned insrc/testing/unenforced.tswith the value the spec states and the module that enforces it. The fidelity test fails on any unpinned difference, on a pin whose value moved, and on a pin that no longer differs.
Consuming The Schemas
A route keeps its own small module (Responses:apps/web/app/api/v3/responses/lib/schemas.ts and
resources.ts) that imports the generated schemas and adds only what they cannot carry:
- refinements for the pinned constraints, with the messages clients read in
invalid_params[].reason; - strict parameter objects (
z.strictObject(generated.shape)), because OpenAPI cannot close a parameter list; - compositions the generator cannot emit, such as a many-to-one discriminated union rebuilt from the generated
members (
z.discriminatedUnion("elementType", zResponseAnswer.options)); - security declarations such as the ENG-2861 reference manifest.
filter[finished][eq] need coercion and repeated-key handling the generated typed schemas do
not do. So GET /api/v3/responses and /count keep their hand-written parser
(parse-v3-responses-list-query.ts), and the generated zGetResponsesV3Query / zCountResponsesV3Query are
unused until a shared query adapter derives that parsing from them.
Compose first, refine last: Zod 4 refuses to .extend over a key once a schema has refinements, and .refine
returns a new instance — so anything keyed on schema identity must be attached to the final instance.
Adopting A Resource
- Add the tag and its operationIds to
packages/api-v3-schemas/scripts/adopted.ts, then runpnpm api:v3:sync, which bundlesdocs/api-v3-reference/src/before generating, so spec edits made for the resource are included. If the normalizer throws, extend it deliberately, with a unit test. - Pin every new difference the fidelity test reports in
src/testing/unenforced.ts, naming your module as the enforcer. - Rebuild the route schemas on the generated ones. Add a counterexample for each pin, a contract diff of each
route schema, and parse the operation’s examples through them (see
write-schemas.test.ts). - Before generating request bodies, the resource needs a reference-roster test like Responses’
reference-manifest.test.ts. Generation means a spec edit alone can add an accepted field, top-level or inside a nested object such asmeta; the roster classifies both, by set equality, so it fails until that field’s tenancy is classified and refuses fields the server derives. - Delete the resource’s drift test once nothing in it is left that generation does not prove.
Upgrading The Generator
The version is exact-pinned and the output is committed, so an upgrade is a deliberate PR whose diff shows every change in the generated code. The fidelity and closedness tests are what catch a regression. The pipeline usesctx.gen.render() with dryRun, which is not a documented API — check it on every bump. A TypeScript or Prettier
catalog bump can also change the formatting and require a regenerate; pnpm lint reports it.
The generator is a build-time dependency only. ESLint forbids importing it from src/, so no generator code can
reach a served bundle, and a test fails if the generated module ever imports anything but zod. The
@formbricks/api-v3-schemas/testing entry point reads the bundle from disk and is lint-banned outside tests in
apps/web. The pipeline passes an explicit config file that does not exist, so hey-api’s loader executes
nothing it finds in the working directory — a test plants a throwing openapi-ts.config.mjs and index.mjs to keep
it that way. The loader still reads a .openapi-tsrc from the working directory — hey-api offers no way to turn that off, and
the file’s keys survive wherever the pipeline sets none — so the renderer refuses to run when one is present.
The generator’s packages require Node 22.18 or newer, which the repository’s Node 24 pin (engines, .nvmrc, CI
and the Docker image) satisfies. Generator tooling must be under a permissive licence (MIT, Apache-2.0, BSD or ISC):
the generated code is part of this AGPL repository, but the tool itself is never distributed.