Blogs

One API Catalog, Many Audiences

How one Zod schema per endpoint is replacing a hand-maintained OpenAPI file in our Express backend, and giving every audience a reference it can trust.

Sushanth Kummitha, Associate Engineering Manager

Nov 2, 2026

12 min read

One API Catalog, Many Audiences

Our API referenceThe document developers read to learn what an API accepts and returns. Ours follow the OpenAPI standard and render in Swagger UI. told integrators that conference-call search results carry a field called sentiment_analysis_link. The server has never sent it. It sends sentiment_link. No one made a mistake on any particular day. The reference and the code simply stopped agreeing, and no one noticed. We only found out when we wrote a SchemaA precise description of the shape of some data: which fields exist, their types, which are required. for that EndpointOne operation an API offers, such as listing a user's workspaces..

That reference was a 3,597-line OpenAPI file we maintained by hand, and everyone who builds on Needl.ai depended on it: our own applications, enterprise customers with API keys, and partners who embed Needl.ai in their platforms. Here's how we made sure the next mismatch gets noticed.

92endpoints defined as contracts so far
1 → 3one catalog, three audience-specific references
9 weeksfrom the first contract to 92
4production mismatches caught on live traffic

Part one · The idea

Nine terms this post uses
Endpoint
One operation an API offers, such as listing a user's workspaces.
API reference
The document developers read to learn what an API accepts and returns. Ours follow the OpenAPI standard and render in Swagger UI.
Schema
A precise description of the shape of some data: which fields exist, their types, which are required.
Zod
An open-source JavaScript library for writing schemas as code and checking data against them.
Contract
Our name for the schema of one endpoint, covering its inputs and every response it can return.
Validation
Checking real data against a schema, and rejecting it when it does not match.
Drift
When the reference and the running code disagree.
Audience
A group of readers who get their own reference: our applications, external customers, or a specific partner.
CI
The automated checks every change must pass before it can merge.

Why hand-maintained references drift

Every endpoint used to be described twice. Request rules lived in ValidationChecking real data against a schema, and rejecting it when it does not match. code. The published reference lived in the JSON file, and response shapes existed only there, as prose and examples that nothing checked against the server. Every change had to land in both places. In practice, it rarely did.

No single mismatch was a crisis. Together they broke the one promise a reference makes: that you can build against it without checking. An integrator reading ours would have coded against a field that never arrives.

The reference and the code stopped agreeing

WhereWhat the reference saidWhat the code does
Search resultssentiment_analysis_linksentiment_link
Search filterthree allowed category valuesaccepts more than three
Session listeach row has created_atthe query never selects it

Figure 1. Three places where the hand-maintained reference disagreed with the code. We found all three while writing contracts, not from bug reports.

One contract drives validation, response checks and docs

Before

Validation codeexpress-validator chains
kept in sync
by memory
API docs3,597-line hand-written JSON

After

Contractone Zod schema
validates
Request checks
observes
Response checks
generates
Docs per audience

Figure 2. Before, validation and the reference were two unconnected descriptions of each endpoint. Now one contract drives validation, response checks and the reference.

One definition, three jobs

A contract is a plain JavaScript object that declares what an endpoint accepts and what it returns, written in Zod. Because it runs on every request, it can't go stale without something noticing. We get three things from it:

  • Request validation. Bad input gets a structured 400 that names the field and the rule it broke.
  • Response checks. In tests, a response that doesn't match its contract fails the build. In production, we log it after the response has gone out.
  • Generated references. We build an OpenAPI 3.1 document for each audience, with descriptions, examples and error responses taken from the same definitions.

Drift no longer waits for a customer to find it. In tests, it breaks the build. In production, it files a warning.

One catalog, many audiences

The harder problem was that we don't have one reader. Needl.ai ships one backend to many customers. Some endpoints are for everyone, some exist for a single partner, and some are internal interfaces our apps rely on but no customer should build against. The old approach filtered one JSON file by tag and patched the result with per-customer overrides. Every audience inherited the DriftWhen the reference and the running code disagree. of the source file, plus the drift of its overrides.

Now we group contracts by product capability and register each group once, in a single catalog. An audience is a short file that picks the groups it publishes, the server they live on and how callers authenticate. Exposing a capability to one customer is a one-line change to that customer's audience file. No one else's reference moves.

Visibility also works below the endpoint. A shared endpoint can carry a field that only one integration uses. Instead of forking the endpoint, we tag the field with the audiences allowed to see it. Every other reference drops the field and its required entry. The tags shape what each reference documents. They don't change what the server returns, and access control still lives in middleware.

The contract never changes

Pick an audience to see what its generated reference contains

Contract, written once
// group B · illustrative
record = z.object({
  id:         z.uuid(),
  name:       z.string(),
  updated_at: timestamp,
  partner_reference: z.string()
    .meta({ "x-audiences": ["partner"] }),
})
Generated reference · our applicationsGenerated reference · a partnerGenerated reference · public API
groups: A B C D E

Record:
  id          string (uuid)
  name        string
  updated_at  string (date-time)
  partner_reference  // pruned
  required: [id, name, updated_at]
groups: A B C D E

Record:
  id          string (uuid)
  name        string
  updated_at  string (date-time)
  partner_reference  string
  required: [id, name, updated_at, partner_reference]
groups: A B C D E

Record
// group B not published

Publishes every group. The record schema appears without partner_reference, and the field is dropped from required too.

Publishes groups B and D. The record schema appears with partner_reference, required, because this is the one audience allowed to see it.

Publishes group A only. The record schema belongs to group B, so it is not part of this reference at all.

Figure 3. Pick an audience to see what its generated reference contains. The contract never changes. Each audience file picks its groups, and field tags decide which fields, and which required entries, survive. Names are illustrative.

Nobody wrote this page separately

GET /user/workspaces as its generated reference renders in Swagger UI

GET/user/workspacesList workspaces the user belongs to

Returns every workspace (organization) the authenticated user is a member of or has a pending invitation to. Member workspaces are listed first, followed by invited ones. The workspace marked active: true is the one the current session operates in.

When the user belongs to at most one workspace, workspaces is an empty array and the client should stay in the default workspace.

ParametersTry it out

No parameters

Responses
CodeDescriptionLinks
200

Workspaces the user belongs to or is invited to

Media type

application/json

Controls Accept header.

Example ValueSchema

{
  "workspaces": [
    {
      "id": "7b0e5b8a-3f6d-4a2e-9c1b-2f8f4a6d9e01",
      "name": "Acme Corp",
      "domain": "acme.com",
      "organization_id": 42,
      "status": "member",
      "active": true
    },
    {
      "id": "0d9c2a4e-8b1f-4c3d-a5e6-1f2b3c4d5e6f",
      "name": "Globex",
      "domain": "globex.com",
      "organization_id": 57,
      "status": "invited",
      "active": false
    }
  ]
}
No links

Figure 4. GET /user/workspaces in a generated reference. The description and example come from the contract shown in Part two. Nobody wrote this page separately.

What this means for teams building on Needl.ai

For every migrated endpoint, the reference you integrate against comes from the code that serves your requests, and every tested path is checked against it before release. Partners get a reference scoped to their integration, with their authentication and only the capabilities open to them.

Part two · The engineering

Why we chose Zod and zod-openapi

Our requirements were narrow. We wanted one definition per endpoint for both validation and docs, no second specification file, and a migration we could run route by route without pausing feature work. We weighed six approaches against that.

ApproachWhy it lost
Spec-first validation (express-openapi-validator)It reads a specification instead of producing one, so the hand-written file stays.
Traffic capture (express-oas-generator)It only documents endpoints someone has already called, and isn't safe for production.
Zod middleware only (express-zod-safe)Validation without docs, and it doesn't fit how our controllers read input.
Framework switch (Hono, Fastify)Rewiring the whole middleware chain for something Express can already do.
Other schema libraries (Joi, Valibot, TypeBox, AJV)No mature OpenAPI path for Joi and Valibot. No transform pipeline for TypeBox and AJV.
Reading our existing validatorsThey compile to opaque closures that nothing can inspect.
Zod + zod-openapi + our own middlewareChosen. No framework change, native to Zod 4, plain JavaScript.

We picked zod-openapi because it builds on Zod 4's own JSON Schema conversion rather than keeping a separate engine. New schema features reach our docs without waiting on anyone. The Express glue is about 100 lines of middleware that we own.

Anatomy of a contract

Here's an abridged production contract for the endpoint in Figure 4:

JavaScript
const WORKSPACE_SCHEMA = z
  .object({
    id: z.uuid().meta({ description: "Unique identifier of the workspace" }),
    name: z.string().meta({ description: "Display name of the workspace" }),
    organization_id: z.int().meta({ description: "Numeric id of the backing organization" }),
    status: z.enum(["member", "invited"]),
    active: z.boolean().meta({ description: "True for the workspace the session operates in" }),
  })
  .meta({ id: "UserWorkspace" });   // becomes components/schemas/UserWorkspace

const contracts = {
  getWorkspaces: {
    method: "get",
    path: "/user/workspaces",
    summary: "List workspaces the user belongs to",
    request: { query: AUTH_QUERY_SCHEMA },
    responses: {
      200: { content: { "application/json": { schema: GET_WORKSPACES_RESPONSE_SCHEMA, example } } },
      403: FORBIDDEN_RESPONSE,
      500: INTERNAL_SERVER_ERROR_RESPONSE,
    },
  },
};

And its whole footprint in the routing layer:

JavaScript
router.get("/workspaces", zodValidate(workspaceContracts.getWorkspaces), getWorkspaces);

Controllers still read validated input from req.matchedData, exactly as before. Migrating an endpoint doesn't have to touch its business logic, which is what let us move one route at a time.

Checking both directions

zodValidate(contract) checks the request on the way in and wraps res.json to check the response on the way out.

Bad input never reaches the controller

Request path through zodValidate

Request
Authadds caller identity
zodValidateparams → query → body
invalid
400 Validation failedissues per field
validControllerreads matchedData
res.json(body)
checked against contract
In testsmismatch throws before send → test fails
In prodsent first, warning logged

Figure 5. Bad input never reaches the controller. A drifting response fails the request in tests, and is logged without delaying anyone in production.

Rejected requests always get the same envelope, with issues grouped by the part of the request that failed:

JSON
{
  "error": "Validation failed",
  "details": {
    "params": [
      { "code": "invalid_format", "path": ["report_id"], "message": "report_id must be a valid UUID v4" }
    ]
  }
}

The response check changed how we test. In tests, a mismatch throws before the body is sent and the request fails. That makes every happy-path test of an endpoint a test of its contract too, with no extra assertion to write.

response observer · test environment

Response schema mismatch for GET /user/workspaces 200: path workspaces[0].organization_id code invalid_type Invalid input: expected number, received string path workspaces[0].active code invalid_type Invalid input: expected boolean, received undefined
Figure 6. The response check's message, formatted for reading, when a controller returns organization_id as a string and drops active. In tests, the request fails and so does the route's spec.

Watching production

In production the same check runs after the response has gone out, and only logs a warning. Within weeks it caught something real. Once search moved to a contract, it started logging a mismatch on GET /search every night. Some results carried a null thumbnail where the contract promised a string. Every response was still delivered. When we dug in, the contract turned out to be right and the response wrong, so we fixed search to leave out null thumbnails instead of loosening the schema.

Since the first contracts reached production traffic in September, the check has flagged four distinct mismatches. As of publication, none has fired since 27 October.

Four distinct mismatches, none since 27 October

What the check saw, and when it was seen in production

Seen in productionNone since 27 Oct

User profileAn email value the contract's format check rejected

7 Sep

SearchNull thumbnails where the contract promised a string, fixed in the response

12 Sep to 27 Oct

SearchA list of index names arriving as objects instead of strings

30 Sep to 21 Oct

Source browsingA file entry with a null MIME type

13 Oct

1 Sep15 Sep1 Oct15 Oct1 Nov

production log · level=warning · env=prod

Response schema mismatch for GET /search 200: path results[0].thumbnail expected string, received null path results[1].thumbnail expected string, received null path results[6].thumbnail expected string, received null … 3 more issues

Figure 7. Every distinct mismatch the check has raised on production traffic, as of publication, and one of the nightly thumbnail warnings with trace identifiers removed.

Generating an OpenAPI document per audience

The document builder is about 110 lines. It walks the groups an audience picks, turns each contract into an OpenAPI operation and hands the result to zod-openapi's createDocument. It then removes the parameters our authentication middleware injects, which matter internally but aren't part of the public interface, and prunes fields tagged for other audiences, as Figure 3 shows.

Testing the contracts themselves

A contract is code, and it can be wrong in ways an endpoint's own tests never see. So we treat the generated references as build output and check them in CIThe automated checks every change must pass before it can merge. with four suites. Each owns one concern, so a failure points to one file. Two of them walk the catalog directly, which means a new contract is covered the moment it's registered.

SuiteWhat it catchesEdited when
OpenAPI lintEvery audience document is valid OpenAPI. Every example matches its schema, at every depth and status code. Every operation has a description.Never
Contract rulesWhat OpenAPI can't express: summaries, standard error responses, an example on every JSON response, no two contracts claiming the same route.Never
Audience compositionWhat each audience publishes: routes, components, servers and security schemes.When a group joins or leaves an audience
Route specsRuntime behaviour of each endpoint, asserting full response bodies.For every endpoint

The lint suite failed on its very first run. One published document carried a relative documentation URL, which OpenAPI forbids and our old validation had never caught. Other rules close risks before they happen. Operations are keyed by method and path, so two contracts claiming the same route would silently overwrite each other and drop an endpoint from the reference. CI now rejects that.

spectral · oas recommended ruleset

× audience document has no OpenAPI violations oas3-valid-media-example: "name" property type must be string @ paths./user.post.requestBody.content.application/json.example.name
Figure 8. Give a request example a numeric name where the schema says string, and CI fails before the reference can ship.

What the migration found

We write every contract from the controller and the SQL behind it, never from the old reference. That rule turned the migration into an audit:

  • A validator guarding the wrong field.Session updates were validated against state.settings.metadata, which nothing reads. A client that satisfied the validator got sessions listed with a null type and no document attachment on read.
  • Responses no one had described.An update endpoint could already return 404 and 409. Neither appeared anywhere in the reference.
  • A crash on a normal input.A report-status endpoint returned 500 whenever a report had no steps, which is the normal state for some report types.
  • A flag that couldn't be turned off.A boolean query flag was read with Boolean(value), so the string "false" came out true.
  • An endpoint documented at the wrong address.The reference placed it under a base URL that never served it.
  • A disclosure caught before publishing.A field typed as an enum would have listed other customers' report types in another customer's reference. We publish it as a plain string, a reminder that audience scoping applies to values as well as fields.

Migrating without stopping the line

We never stopped to rewrite. Old and new validation run side by side, and we ordered the work so it paid off early.

  1. 12 to 24 Aug

    Six approaches weighed, foundation merged

  2. 27 to 28 Aug

    Conventions written down in one guide and enforced in CI. Engineers and the AI coding agents we work with follow the same guide, and new endpoints start on contracts.

  3. 28 Aug to Oct

    Most of what the legacy reference documented, then the wider internal surface

  4. Next

    Customers move to the generated references, and the legacy file and its route are deleted

Nine weeks from the first contract to 92

Endpoints defined as contracts, counted from the repository history

Figure 9. Endpoints defined as contracts, from the foundation on 24 August to today, counted from the repository history. The curve climbs in steady steps, with steeper runs when a whole product area moved across.

Trade-offs and limits

  • Two validators during migration. Until the last route moves, every engineer works with both the old and the new style, sometimes in the same file.
  • Extra fields pass. Our response schemas accept fields they don't declare, so an undocumented field won't trip the check. An optional field that never appears passes too, which means a misnamed optional field can slip through. A strict schema closes both gaps.
  • Not every response is checked. Streamed and binary responses are documented but never verified at runtime.
  • The production check isn't free. It runs off the request's path, but parsing still uses the same event loop. If it ever shows up in profiles for the largest payloads, we'll sample.

What we learned

  1. We were specifying responses for the first time.Our validators had only ever looked at requests, so most of what the migration found was hiding in responses.
  2. Treat the reference as build output.A contract can be inconsistent while every endpoint test passes. Only linting the generated document catches that.
  3. Observe in production, enforce in tests.Enforcing response schemas in production on routes with years of history would have turned old drift into customer-facing errors. Observing first let real traffic show us where data and contracts disagreed, while strict tests kept new drift out.
  4. A convention holds only where a test enforces it.Rules that lived in review comments drifted, just as the old reference had. Rules written as tests held for every contributor and every coding agent.
  5. Validation decides well-formedness, nothing more.Ownership, quotas and permissions stay in middleware, where they map to 401, 403 and 404. That way a 400 from our API means exactly one thing.

What's next

When the migration is complete, customers switch to the generated references and we delete the hand-maintained file, its overrides and the legacy route. From then on, every API reference Needl.ai publishes comes from the code that serves it.

Our generated reference already says sentiment_link. We wrote it from the code that sends it, not from the file that got it wrong.

FAQ

Does this approach require TypeScript?

No. Our backend is CommonJS JavaScript, and Zod and zod-openapi both run there without a build step.

Why not write the OpenAPI document first and validate against it?

That keeps a hand-written specification at the centre, which is the problem we set out to remove. With contracts, the specification is something the code produces.

How do you keep examples in the reference accurate?

CI checks every example, request and response, at every depth and status code, against the schema it illustrates. An example that contradicts its schema fails the build.

Can I use this pattern in my own Express app?

Yes. You need Zod 4, zod-openapi and a small middleware that validates params, query and body against a contract and wraps res.json. Ours is about 100 lines. Start with one route, and add the OpenAPI lint to CI before the second.

What happens when an endpoint's shape needs to change?

You change its contract. Validation, the response check and every reference that publishes the endpoint pick up the change in the same commit. If the change alters what an audience publishes, the audience tests fail until you update them, which makes the change visible in review.

Can readers try requests from the reference?

Yes. Each reference renders in Swagger UI with "Try it out", and the examples it shows are the same ones CI validates against the schema.

What does a new customer-specific reference cost?

One audience file listing its groups, server and security schemes, plus one registry entry. The builder and the test suites cover it from there.

Build on an API that documents itself

Integrating with Needl.ai? Talk to us about your use case, or see the product these APIs power.

EngineeringAPIOpenAPIZodExpressValidationAPI documentation

Keep reading

Related on Needl.ai: Integrations

See it on your own documents

Bring the filings, reports or data room your team works with, and see Needl.ai answer with a source on every claim.

Book a demo