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.
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
| Where | What the reference said | What the code does |
|---|---|---|
| Search results | sentiment_analysis_link | sentiment_link |
| Search filter | three allowed category values | accepts more than three |
| Session list | each row has created_at | the query never selects it |
One contract drives validation, response checks and docs
Before
by memory
After
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
// group B · illustrative
record = z.object({
id: z.uuid(),
name: z.string(),
updated_at: timestamp,
partner_reference: z.string()
.meta({ "x-audiences": ["partner"] }),
})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 publishedPublishes 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.
required entries, survive. Names are illustrative.Nobody wrote this page separately
GET /user/workspaces as its generated reference renders in Swagger UI
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.
No parameters
Workspaces the user belongs to or is invited to
Media type
application/jsonControls 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
}
]
}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.
| Approach | Why 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 validators | They compile to opaque closures that nothing can inspect. |
| Zod + zod-openapi + our own middleware | Chosen. 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:
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:
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
Rejected requests always get the same envelope, with issues grouped by the part of the request that failed:
{
"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
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
SearchNull thumbnails where the contract promised a string, fixed in the response
SearchA list of index names arriving as objects instead of strings
Source browsingA file entry with a null MIME type
1 Sep15 Sep1 Oct15 Oct1 Nov
production log · level=warning · env=prod
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.
| Suite | What it catches | Edited when |
|---|---|---|
| OpenAPI lint | Every audience document is valid OpenAPI. Every example matches its schema, at every depth and status code. Every operation has a description. | Never |
| Contract rules | What OpenAPI can't express: summaries, standard error responses, an example on every JSON response, no two contracts claiming the same route. | Never |
| Audience composition | What each audience publishes: routes, components, servers and security schemes. | When a group joins or leaves an audience |
| Route specs | Runtime 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
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.
- 12 to 24 Aug
Six approaches weighed, foundation merged
- 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.
- 28 Aug to Oct
Most of what the legacy reference documented, then the wider internal surface
- 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
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
- 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.
- Treat the reference as build output.A contract can be inconsistent while every endpoint test passes. Only linting the generated document catches that.
- 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.
- 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.
- 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.

From GPU Servers to Serverless: Reading 300 Pages in a Minute
Smarter Kubernetes Scaling with KEDA and Custom Metrics
Why Needl.ai Doesn't Rely on Vector Databases