# How to publish an OpenAPI document at a stable URL for agents

> Serve the OpenAPI document as JSON and YAML at URLs that do not move, with the headers a browser needs, and a check that fails when the copies drift.

Source: https://voxgig.com/howto/publish-openapi-at-a-stable-url

- Audience: api-producer
- Level: beginner
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Build /openapi.json and /openapi.yaml from the one document in your repository, and publish the same files again under /v1/. Serve them with Content-Type, Access-Control-Allow-Origin: *, and Cache-Control: max-age=300, and keep them out of any bot challenge. Then run a check on a schedule that fetches the URL, validates the document, and compares it with the repository copy, failing on the first difference.

---
## You will need

Node 22 or later, and an OpenAPI 3 document. Verified 2026-09-24 against Node 22.22.2, `yaml`
2.9.1, and `@seriousme/openapi-schema-validator` 2.10.0. The
[OpenAPI Specification](https://spec.openapis.org/oas/v3.1.0.html) allows the document in JSON
or YAML and recommends `openapi.json` and `openapi.yaml` as the file names, which is where the
URL shape comes from. It registers no media type for either.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A runtime route from the framework](https://fastapi.tiangolo.com/tutorial/metadata/) | The framework derives the document from the code, as FastAPI does at `/openapi.json` | The document changes when a dependency changes, and a request-time render is a request-time failure | The description is written by hand, or the served file must equal a committed one |
| [A static file in the build output](https://docs.astro.build/en/basics/project-structure/) | Any site with a build step, which is where the docs already live | A build on every change, and headers set in the host's configuration rather than in the code | The document is generated from code the docs build cannot see |
| [Redocly hosting](https://redocly.com/docs/realm/config/openapi) | Reference docs rendered from the same upload, with no server of your own | A vendor between you and the URL, and the raw file lives wherever the vendor puts it | Your own domain has to serve the raw document |
| [Scalar hosting](https://scalar.com/products/api-references/getting-started) | The same, with the Scalar reference and a registry of your versions | The same vendor dependency, and a canonical URL that is theirs unless you proxy it | The generator or agent needs a URL under your domain |

The static file and the runtime route disagree about what the document is. A static file is
an artifact you committed, so it cannot drift from the repository, and it cannot know anything
the build did not. A runtime route is derived from the running code, so it is always current
and never checked in, and a bug in the derivation ships to every client at once. The hosted
platforms remove the server from your hands and put the URL in theirs.

Whichever you pick, the check at the end of this page is the same: fetch what is published,
validate it, and compare it with what you meant to publish.

## Decide the URL shape first

Two URLs per format. `/openapi.json` tracks the current release, and `/v1/openapi.json` is the
same document pinned under its major version, so a client that generated code against v1 can
keep fetching v1 after v2 ships.

```ts title="build.mjs"
export function build({ source = 'openapi.json', out = 'dist' } = {}) {
  const doc = JSON.parse(readFileSync(source, 'utf8'))
  const major = `v${doc.info.version.split('.')[0]}`
  const json = JSON.stringify(doc, null, 2) + '\n'
  const yaml = stringify(doc)
  const written = []
  for (const dir of [out, join(out, major)]) {
    mkdirSync(dir, { recursive: true })
    for (const [name, text] of [['openapi.json', json], ['openapi.yaml', yaml]]) {
      writeFileSync(join(dir, name), text)
      written.push(join(dir, name))
    }
  }
  return { version: doc.info.version, major, written }
}
```

Both formats come from one parse, so they cannot disagree. The YAML is written by
[the `yaml` package](https://eemeli.org/yaml/), because a hand-rolled emitter is the kind of code
that works until a description contains a colon.

The major comes from `info.version`, which means the per-major URL is derived from the document
and not from a second place that has to be kept in step. When `info.version` becomes `2.0.0`,
`/v2/openapi.json` appears and `/v1/openapi.json` keeps serving the last v1 build, as long as
the deploy does not delete it.

## Set the three headers

A URL that answers `curl` and fails in a browser is missing one header. A URL that gets fetched
by every agent on every call is missing another.

```ts title="serve.mjs"
export function headersFor(path, body) {
  const ext = path.slice(path.lastIndexOf('.'))
  return {
    'content-type': `${TYPES[ext]}; charset=utf-8`,
    // The document is public, and a browser-based client cannot read it without this.
    'access-control-allow-origin': '*',
    'access-control-allow-methods': 'GET, HEAD, OPTIONS',
    'access-control-allow-headers': '*',
    // Five minutes in any cache, then a conditional request against the ETag.
    'cache-control': 'public, max-age=300',
    etag: `"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`,
  }
}
```

`application/yaml` is the type [RFC 9512](https://www.rfc-editor.org/rfc/rfc9512.html)
registered for YAML. The JSON copy is `application/json`.

`Access-Control-Allow-Origin: *` is what
[the CORS protocol](https://fetch.spec.whatwg.org/#http-cors-protocol) requires before a
browser hands a cross-origin response to the page that asked for it. A wildcard is right here
because the document is public and the request carries no credentials. The `OPTIONS` handling
exists for the preflight a browser sends when the client adds a header of its own.

`max-age=300` with an `ETag` means a generator that fetches on every run costs you one
request per five minutes per cache, and a conditional request after that.
[RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.1) defines the directive.
Five minutes is also the longest a fixed document will lag a deploy, which is a trade you can
explain.

## Check it worked

Build, serve, fetch all four URLs plus one that should not exist, then run the check twice:
once against a faithful copy and once after the served copy has fallen behind.

```bash
node demo.mjs
```

```text output
built 4 files for version 1.4.0, major v1
/openapi.json      200  application/json; charset=utf-8  public, max-age=300  cors *
/openapi.yaml      200  application/yaml; charset=utf-8  public, max-age=300  cors *
/v1/openapi.json   200  application/json; charset=utf-8  public, max-age=300  cors *
/v1/openapi.yaml   200  application/yaml; charset=utf-8  public, max-age=300  cors *
/v2/openapi.json   404  application/json                 -                    cors -
check: ok, version 1.4.0 matches openapi.json
check: FAIL
  published document differs from openapi.json at $.info.version
```

The last two lines are the reason to have the check. The served copy was edited to say `1.3.0`
and to drop a response, and the check names the first path at which the two documents differ.
Nothing about the URL, the headers, or the schema changed, so nothing else would have noticed.

The test suite covers the round trip from JSON to YAML and back, the headers, the `304` on a
matching `ETag`, the check's four refusals, and the echoed `Origin`.

```bash
node --test check.test.mjs
```

```text output
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 933.893296
```

## Fail the build when the copies disagree

The check fetches the published URL the way a browser would, with an `Origin` header, and
refuses on any of four counts: the headers, the JSON, the schema, and the diff. A server that
echoes the `Origin` back, with `Vary: Origin`, passes the header check, because a browser reads
that as readily as `*`.

```ts title="check.mjs"
  const type = res.headers.get('content-type') ?? ''
  if (!type.startsWith('application/json')) problems.push(`content-type is ${type || 'missing'}, expected application/json`)
  const allow = res.headers.get('access-control-allow-origin')
  if (allow !== '*' && allow !== ORIGIN) problems.push(`no Access-Control-Allow-Origin header for ${ORIGIN}, so a browser cannot read it`)
  if (!res.headers.get('cache-control')) problems.push('no Cache-Control header, so every fetch reaches the origin')

  let published
  try {
    published = JSON.parse(await res.text())
  } catch (err) {
    return { ok: false, problems: [...problems, `body is not JSON: ${err.message}`] }
  }
  if (published === null || typeof published !== 'object' || Array.isArray(published)) {
    return { ok: false, problems: [...problems, 'body is not a JSON object'] }
  }

  const result = await new Validator().validate(published)
  if (!result.valid) {
    // A document with no usable `openapi` version comes back as one string, not a list.
    const [first] = Array.isArray(result.errors) ? result.errors : []
    problems.push(`not a valid OpenAPI document: ${first ? `${first.instancePath || '$'} ${first.message}` : result.errors}`)
  }

  const local = JSON.parse(readFileSync(source, 'utf8'))
  const diff = firstDifference(local, published)
  if (diff) problems.push(`published document differs from ${source} at ${diff}`)
```

The validator checks the document against the OpenAPI JSON Schema for its declared version,
which catches a truncated upload or a wrong file at the URL. The diff catches the case the
schema cannot: a valid document that is not the one in the repository.

Run it on a schedule rather than only on deploy, because the failure it catches is the
deploy that did not happen.

```yaml title="check-openapi.yml"
on:
  schedule:
    - cron: "17 6 * * *"
  workflow_dispatch:
```

## When it goes wrong

The document works from `curl` and every browser-based client fails. Two causes, and they look
alike from the terminal. Three servers, one file, two kinds of client:

```bash
node pitfall.mjs
```

```text output
server               curl   browser
no CORS header       200    blocked, no Access-Control-Allow-Origin
bot challenge        403    200
CORS, no challenge   200    200
```

The first row is the missing header. The server answered 200, the bytes arrived, and the
browser refused to hand them to the page, which is what
[`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin)
governs. The second row is the opposite failure: a bot challenge in front of the site answered
the browser and blocked everything else, which is every generator and every agent. Exempt the
document's path from the challenge; [Cloudflare's bot
products](https://developers.cloudflare.com/bots/) and their equivalents all take a path rule.

The YAML and the JSON disagree. Two build steps, or a hand-edited copy. Write both from one
parse, as `build.mjs` does, and let the test round-trip the YAML back to the source object.

The check fails the morning after a release. The deploy shipped the site and not the document,
or a CDN is still serving the old copy past its `max-age`. Purge the path, and put the check
after the deploy as well as on the schedule.

`/v1/openapi.json` vanished when v2 shipped. The build writes only the current major, and the
deploy cleared the directory. Keep the previous major's file as a committed artifact, or serve
it from the last v1 build's output.

## When not to do this

Do not publish a document that describes endpoints you have not shipped. A generator will
build a client for them, an agent will call them, and the 404 will be filed as your bug. Publish
what is live, and keep the draft on a branch.

Do not put the document behind the same login as the API. A description is not a credential,
and the client that needs it most is the one that has not authenticated yet. If the API is
private, put the document behind the same network boundary and nothing more.

Do not serve the runtime-generated route as the stable URL when the description is hand
written. The two will differ, and the route wins because it is the one at the URL. Pick one
source and publish that.

Do not skip the check because the file is static. Static is how the copy that was uploaded
once in March is still there in September, valid and wrong.[^2]

## Related how-tos

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

- [Validate responses against the OpenAPI document at runtime](/howto/validate-responses-against-the-openapi-document)

- [Serve your OpenAPI examples as mock responses](/howto/serve-openapi-examples-as-mock-responses)

## Last verified

Verified 2026-09-24 against Node 22.22.2, `yaml` 2.9.1, and `@seriousme/openapi-schema-validator`
2.10.0. Every output block is what the command preceding it printed. The servers are local
stand-ins started by the demo; the bot challenge is a user agent check, not a vendor's product.

[^1]: There is no registered media type for an OpenAPI document. An Internet-Draft,
[`draft-polli-rest-api-mediatypes`](https://datatracker.ietf.org/doc/draft-polli-rest-api-mediatypes/),
proposed `application/openapi+json` and `application/openapi+yaml`, and the Datatracker files
its first revision as expired. YAML itself waited longer: [RFC 9512](https://www.rfc-editor.org/rfc/rfc9512.html)
registered `application/yaml` in February 2024, and the
[YAML specification history](https://yaml.org/spec/history/2001-05-26.html) begins in May
2001. Twenty-two years is a long time for a format to travel as `text/plain`.

[^2]: [APIs.guru](https://apis.guru/) keeps a directory of public OpenAPI documents, and its
[machine-readable listing](https://api.apis.guru/v2/list.json) records, for every entry, the
URL the document was harvested from. On the day this page was written it held 2529 APIs. A
directory that size is an argument for stable URLs made by other people, several thousand
times, without meaning to.