# How to publish an OpenAPI 3.0 copy beside your 3.1 document

> Keep OpenAPI 3.1 as the source of truth and build a 3.0 copy for older generators, with a guard that fails when the conversion loses or mangles anything.

Source: https://voxgig.com/howto/publish-openapi-3-0-alongside-3-1

- Audience: platform-team
- Level: advanced
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Keep the 3.1 document canonical and generate the 3.0 copy in CI with @apiture/openapi-down-convert, wrapped in a guard. The guard rewrites numeric exclusiveMinimum, refuses keywords 3.0 cannot express unless you name them, fails on 3.1 constructs the converter leaves behind, and stamps the canonical URL into the copy. Then run your oldest generator against the copy and openapi-typescript against the source.

---
## You will need

Node 22 or later and an OpenAPI 3.1 document. Verified against Node 22.22.0. The source here uses
type arrays for null, the shape the page on nullable and optional fields in OpenAPI 3.1 sets up.
The [3.0.3 Schema Object](https://spec.openapis.org/oas/v3.0.3.html#schema-object) is a subset of
an older JSON Schema draft,[^1] and anything it does not list is, in its words, "strictly
unsupported."

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`@apiture/openapi-down-convert`](https://github.com/apiture/openapi-down-convert) | A hand-written or generated 3.1 file, and a build step you control | A guard you write and own, because it exits 0 on output a 3.0 reader rejects | Your framework already emits both versions from code |
| [Huma dual output](https://huma.rocks/features/openapi-generation/) | Your API is written in Go with Huma, which serves 3.1 and 3.0.3 side by side | A Go framework, and its rules for type arrays, which keep the last non-null type | The spec is written by hand, or the service is not Go |
| [A hand-kept 3.0-compatible subset](https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0) | Few schemas, and no 3.1 feature you need yet | You give up webhooks, `const` and null in type arrays, the features that justified 3.1 | You adopted 3.1 for one of those features |

The converter is the only option that keeps one hand-written source and a mechanical copy, but
it fails silently, so it needs the guard below. Huma writes `exclusiveMinimum` in the 3.0 form,
but only for specs it generated itself. A hand-kept subset needs no build step, at the price of writing
3.0 forever with a 3.1 version number on it.

## Wrap the converter in a guard

The converter does most of the work but reports it only when `--verbose` is on. Run against
this page's sample, version 0.14.2 dropped `webhooks` and `propertyNames`, rewrote the type array
and `const`, and left `exclusiveMinimum: 0` untouched. It exited 0 each time.

The guard below checks the source before conversion and the copy after it. It fails on any
constraint, type or data value it cannot translate exactly. Labels go through: a `$ref`'s sibling
`description`, every `examples` entry after the first, `$comment`, and the license `identifier`.

```js title="downconvert.mjs"
export function downConvert(doc31, { canonicalUrl, allowLoss = [] } = {}) {
  if (!/^3\.1\.\d+$/.test(String(doc31.openapi))) {
    throw new DownConvertError(`expected an OpenAPI 3.1 document, got ${doc31.openapi}`, [])
  }
  if (!canonicalUrl) {
    throw new DownConvertError('no canonical URL: the 3.0 copy must say where the source of truth is', [])
  }

  const lossy = lossyKeywords(doc31)
  const refused = lossy.filter((l) => !allowLoss.includes(l.keyword))
  if (refused.length) {
    throw new DownConvertError('3.0 cannot express these, and allowLoss does not name them', refused)
  }

  const webhooks = Object.keys(doc31.webhooks ?? {})
  const doc = new Converter(structuredClone(doc31), { verbose: false }).convert()
  const rewrites = fixExclusiveBounds(doc)

  const left = [...changedProperties(doc31, doc), ...changedData(doc31, doc), ...leftovers(doc)]
  if (left.length) {
    throw new DownConvertError('the 3.0 copy would not say what the source says', left)
  }

  const note = `Generated from the canonical OpenAPI 3.1 document at ${canonicalUrl}. ` +
    'Report problems against that document, not this copy.'
  doc.info.description = doc.info.description ? `${note}\n\n${doc.info.description}` : note
  doc.info['x-derived-from'] = canonicalUrl

  return { doc, report: { lost: lossy, webhooks, rewrites } }
}
```

`fixExclusiveBounds` turns `exclusiveMinimum: 0` into `minimum: 0` with `exclusiveMinimum: true`,
which is how 3.0 writes the same bound.[^2] When both bounds are set it keeps the stricter one.
`leftovers` walks every schema for type arrays, `type: 'null'`, `const`, `prefixItems`, a `$ref`
with siblings and the other 2020-12 keywords. It skips property names, so a field called `if` is
not a keyword. `changedData` compares every `example`, `default` and `enum` value before and after,
because the converter walks those values as though they were schemas and rewrites an `examples`
array or a `const` key it finds inside one.

## Build the copy in CI

Run the build on every change to the source. The first run stops, because the `labels` map
constrains its keys with `propertyNames` and 3.0 has nothing that says the same thing.

```bash
node build-30.mjs spec/openapi.yaml public/openapi-3.0.yaml --canonical https://api.example.com/v2/openapi.json
```

```text output
refused: 3.0 cannot express these, and allowLoss does not name them
  propertyNames at /components/schemas/Order/properties/labels
```

Decide, then say so in the command. Here the key pattern is a server-side check that a 3.0
client can live without.

```bash
node build-30.mjs spec/openapi.yaml public/openapi-3.0.yaml --canonical https://api.example.com/v2/openapi.json --allow-loss propertyNames
```

```text output
spec/openapi.yaml (3.1.0) -> public/openapi-3.0.yaml (3.0.3)
  canonical   https://api.example.com/v2/openapi.json
  dropped     webhook orderShipped: 3.0 has no webhooks
  lost        propertyNames at /components/schemas/Order/properties/labels (allowed)
  rewrote     exclusiveMinimum: 0 at /components/schemas/Order/properties/total
```

Publish the copy at a URL that names its version, and keep the canonical one at the plain
address. The copy's `info.description` opens with the canonical URL, and `x-derived-from` carries
it for tools. A consumer who files a bug quotes a document that tells them where to file it.

## Check it worked

Run the slowest-moving generator you support against the copy, and a 3.1-native one against the
source. [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
is unmaintained and points users at Hey API, which makes it a fair stand-in for the pinned tool.
[openapi-typescript](https://openapi-ts.dev/) reads 3.1 directly.

```bash
node generate.mjs
```

```text output
generator                   input       couponCode      schemaVersion         file
openapi-typescript-codegen  3.1 source  string | null   any                   string
openapi-typescript-codegen  3.0 copy    string | null   Order.schemaVersion   Blob
openapi-typescript          3.1 source  string | null   2                     string
```

Read the first row before trusting a generator's silence. Fed the 3.1 file, the old generator did
not reject it. It typed `const` as `any` and the upload as `string`, so the generated method
will not take a `Blob` without a cast. From the copy it produced `Blob` and an enum. openapi-typescript
types binary as `string` by default, and its [Node API](https://openapi-ts.dev/node) documents a
transform to `Blob`.

Then lint the copy against the 3.0 schema. This configuration turns on only
[Redocly's `struct` rule](https://redocly.com/docs/cli/rules/common/struct) and reference
resolution, so style warnings stay out of the result.

```yaml title="redocly.yaml"
rules:
  struct: error
  no-unresolved-refs: error
```

```bash
npx redocly lint public/openapi-3.0.yaml --format=stylish 2>&1
```

```text output
Woohoo! Your API description is valid. 🎉
```

```bash
node --test downconvert.test.mjs
```

```text output
1..15
# tests 15
# suites 0
# pass 15
# fail 0
```

Each test feeds the guard shapes the converter mishandles and asserts the exact pointers it
reports.

## When it goes wrong

The converter exits 0 on a copy that a 3.0 validator rejects. Run the converter without the guard
and lint what it wrote.

```bash
npx openapi-down-convert -i spec/openapi.yaml -o naive-3.0.yaml && npx redocly lint naive-3.0.yaml --format=stylish
```

```text output
naive-3.0.yaml:
  74:29  error    struct  Expected type `boolean` but got `integer`.
```

That is the `total` bound from the opening paragraph. Lint every copy before publishing it.

A field disappears from the copy. The converter walks a `properties` map as though it were a
schema, so a property named after a keyword is treated as one.

```bash
npx openapi-down-convert -i spec/keyword-names.yaml
```

```text output
components:
  schemas:
    Rule:
      type: object
      properties:
        kind:
          type: string
        enum:
          - type: string
```

The source had `$schema`, `const` and `kind`. The copy deleted `$schema`, a property Huma's
[schema link transformer](https://github.com/danielgtaylor/huma/blob/main/transforms.go) adds to
its response models, and renamed `const` to `enum`. The guard compares property names before and
after, so the same input fails with `property $schema lost`, `property const lost` and
`property enum added`.

The nullable `$ref` written as `oneOf: [{ $ref }, { type: 'null' }]` comes through unchanged and
fails 3.0. There is no exact rewrite, because a 3.0
[Reference Object](https://spec.openapis.org/oas/v3.0.3.html#reference-object) ignores anything
written beside it, `nullable` included. Written instead as a `$ref` with `type: [object, 'null']`
beside it, the reference converts to `nullable: true` beside the `$ref`, which the same rule
hides from a 3.0 reader. The guard refuses both forms, and any other `$ref` sibling that is more
than a label, so a person decides what the 3.0 client should assume.

## When not to do this

Do not publish both documents without saying which one is canonical. Consumers report bugs
against whichever one their tool fetched. A description that names the source turns those reports
into pointers rather than arguments.

Do not let the copy become a second source. Never edit `public/openapi-3.0.yaml` by hand, because
the build overwrites it on every run.

Do not add `--allow-loss` to make a red build green. Each allowed keyword is a rule the 3.0
client will not enforce, so name it only after deciding the server-side check is enough.

Do not keep the copy forever. Ask the consumer which generator version would read 3.1, and give
the copy a removal date. If your service is written with Huma, use its own `/openapi-3.0.json`
rather than this pipeline, because the framework has the types the converter has to guess.

## Related how-tos

- [Publish an OpenAPI document at a stable URL for agents](/howto/publish-openapi-at-a-stable-url)

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Detect breaking changes between two OpenAPI versions in CI](/howto/detect-breaking-changes-between-openapi-versions-in-ci)

## Last verified

Verified 2026-10-15 against Node 22.22.0, `@apiture/openapi-down-convert` 0.14.2, `@redocly/cli`
2.54.3, `openapi-typescript` 7.13.0 and `openapi-typescript-codegen` 0.31.0. Every output block is
what the preceding command printed in the page's code directory after `npm ci`. The Huma behavior was
read from its source and observed with v2.39.1 under Go locally, outside the captured runs.

[^1]: The [3.0.3 specification](https://spec.openapis.org/oas/v3.0.3.html#schema-object) calls it
"JSON Schema Specification Wright Draft 00." That names the draft after its editor, Austin Wright,
as the [Internet-Draft's header](https://datatracker.ietf.org/doc/html/draft-wright-json-schema-00) shows. The
JSON Schema project's own [specification links](https://json-schema.org/specification-links) file
that draft as Draft 5, a cleanup of Draft 4 that kept Draft 4's meta-schemas. Its
[draft-06 release notes](https://json-schema.org/draft-06/json-schema-release-notes) then ask
implementers not to implement or advertise support for draft-05. OpenAPI 3.0 builds on it all
the same, by the name that leaves the number out.

[^2]: The boolean form is the older one. JSON Schema draft-06 [changed both keywords from a boolean
to a number](https://json-schema.org/draft-06/json-schema-release-notes). Its release notes give
the reason as "the principle of keyword independence." Since then `exclusiveMinimum` has not
needed `minimum` beside it to mean anything, so a down-converter has to put the neighbor back. The
[Huma source](https://github.com/danielgtaylor/huma/blob/main/openapi.go) does, and on
type arrays it settles the matter with a comment: "Last non-null wins."