# How to validate responses against the OpenAPI document at runtime

> Build a validator from the response schema in your OpenAPI document and run it over real responses, so a service that stops matching its description fails.

Source: https://voxgig.com/howto/validate-responses-against-the-openapi-document

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## Short answer

Read the response schema out of the OpenAPI document, build a validator from it once at startup, and run every response through it in test and staging. Allow properties the document does not describe, because JSON Schema does. Log a failure with the property path rather than throwing, so a drifting field is reported without taking the response away from the caller.

---
## You will need

Node 22 or later, and an OpenAPI 3 document whose responses carry schemas. A document with no
response schemas has nothing to validate against, and adding them is the first step rather than a
detail.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Ajv](https://ajv.js.org/) | The document is JSON Schema and you want the whole vocabulary supported | It compiles schemas at runtime, and its error objects need mapping before a person can read them | You want the validator to double as a hand-written schema |
| [`express-openapi-validator`](https://github.com/cdimascio/express-openapi-validator) | An Express service that wants request and response checking as middleware | It ties validation to one framework, so a worker or a queue handler is left out | The service is not Express, or checks run outside the request path |
| [shape](https://github.com/voxgig/shape) | You want one validator style for the document and for hand-written checks | A smaller ecosystem, and the JSON Schema subset you support is yours to grow | The document uses JSON Schema features beyond the common subset |
| [Zod](https://zod.dev/) | TypeScript services that want the response type inferred from the validator | Converting a document into its builder syntax is code generation rather than a walk | You want to read the document at startup rather than generate from it |

Voxgig maintains shape. It is one of four options here, not the recommendation.

Ajv and the middleware are the ready-made answers, and the walk below is worth writing when you want
the same validator over responses that never pass through a web framework. The subset it handles is
smaller than Ajv's on purpose: an unsupported keyword throws when the validator is built rather than passing unnoticed at request time.

## Walk the document into a validator

The recursion is short because the schema shapes an API response uses are few.

```ts title="from-openapi.mjs"
export function shapeFromSchema(schema, schemas = {}) {
  if (schema.$ref) {
    const name = schema.$ref.replace('#/components/schemas/', '')
    return shapeFromSchema(schemas[name], schemas)
  }
  if (schema.type === 'array') return [shapeFromSchema(schema.items, schemas)]
  if (schema.type === 'object') {
    const required = new Set(schema.required ?? [])
    const props = Object.fromEntries(
      Object.entries(schema.properties ?? {}).map(([key, sub]) => {
        const built = shapeFromSchema(sub, schemas)
        return [key, required.has(key) ? built : Optional(built)]
      }),
    )
    // JSON Schema allows properties it does not describe unless the document
    // says otherwise, so the validator has to allow them too. Closing by
    // default would report every field the API adds as a breaking change.
    return schema.additionalProperties === false ? props : Open(props)
  }
  const leaf = LEAF[schema.type]
  if (!leaf) throw new Error(`unsupported schema type: ${JSON.stringify(schema.type)}`)
  return leaf
}
```

The comment marks the decision most implementations get backwards. A validator that rejects
undescribed properties reports every additive change as a failure, so the team turns it off within a
week. Follow the document: open by default, closed when it says `additionalProperties: false`.

The throw on an unknown type is the other deliberate choice. A validator that ignores keywords it
does not understand passes responses it never checked, and reports a green run that means nothing.

Build the validator once rather than per request. Reading the document, resolving its references and
constructing the checker are startup work, and doing them inside a handler turns a documentation
check into a per-response cost nobody budgeted for.

Reference resolution is the part that grows. The preceding walk handles a local `$ref` into
`components.schemas`, which covers most single-file documents. A document split across files, or one using
[dynamic references in JSON Schema](https://json-schema.org/understanding-json-schema/structuring),
needs a resolver. That is the point at which
[a maintained parser](https://github.com/APIDevTools/json-schema-ref-parser) costs less than
extending the walk yourself, and it is a well-defined job to hand off. Resolve first, validate second, and the validator stays as small as this one.

## Check it worked

Four responses against the document, then the same added field under a strict reading of it.

```bash
node demo.mjs
```

```text output
as documented              valid
amount sent as a string    invalid: amount_cents is not number
paid field removed         invalid: paid is missing
an added field             valid
an added field, strictly   invalid: carries a property the document does not describe
```

Line two is the drift from the opening paragraph, named by property. Lines four and five are the
same response read two ways, and the difference between them is one keyword in the document rather
than anything in the validator.

Run this over the responses your existing test suite already produces and the first pass is usually
noisy. Some of that noise is real drift. Some of it is a document written once, left behind, and never
compared with the service since. Both are findings, and separating them is the work the first run buys you. Fix the document where
it is wrong, and the service where it is, and the noise stops.

```bash
node --test validate.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 146.133239
```

## When it goes wrong

Validation runs in production and starts costing more than it finds. Every response pays for a walk
of its own body, and on a large list endpoint that is real time. Sample instead, validating one response in a hundred. Or run the check only in staging and in the
test suite, where the drift shows up just as clearly and nobody is waiting.

The second failure is a validator that throws. A response that fails validation is usually still
useful to the caller, and turning a documentation drift into a 500 makes the observability change
into an outage. Log the failure with its path and let the response through, and let the log volume
decide how urgent the fix is.

## When not to do this

Do not use shape, or any validator, as the way you learn what your API returns. The document is the
thing under test here, and a validator built from a document nobody maintains only proves that two
stale artifacts agree with each other.

Do not validate responses in the client and call it a contract test. A client checking its own copy
of the document tests the copy, and the interesting question is what the service actually sent.

Do not extend the walk until it covers all of JSON Schema. At that point you have written Ajv without its test suite. The reason to stop is that a homemade
validator fails by staying silent rather than by reporting.

Do not let the document drift because validation is passing. Open validation passes a response with a field the
document never mentions, which is how an undocumented field ends up holding somebody's integration
together.

## Related how-tos

- [Contract test an API you do not own](/howto/contract-test-an-api-you-do-not-own)

- [Choose consumer-driven or spec-driven contract tests](/howto/choose-consumer-driven-or-spec-driven-contract-tests)

## Last verified

Verified 2026-09-06 against Node 22.22.2 and shape 11.4.1. Both output blocks are what the preceding
command printed. The Ajv, Zod and middleware rows describe documented behavior and were not run.