A code generator fetches your OpenAPI document and gets a login page. A browser-based client
fetches it and the console says the request was blocked by CORS. The URL in your docs is
/api/v3/spec?format=json, it has moved once already, and the document it serves is two
releases behind the one in the repository.
What you get
You will end up with your OpenAPI document at four URLs that do not move, served with the headers a browser needs, and a check that fails when the published copy drifts. This is for you if anyone fetches your description by URL.
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 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 | 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 | 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 | 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 | 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.
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, 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.
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
registered for YAML. The JSON copy is application/json.
Access-Control-Allow-Origin: * is what
the 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 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.
node demo.mjs
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.
node --test check.test.mjs
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 *.
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.
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:
node pitfall.mjs
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
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 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
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.
- Twenty-two years is a long time for a format to travel as
text/plain.
Footnotes
-
There is no registered media type for an OpenAPI document. An Internet-Draft,
draft-polli-rest-api-mediatypes, proposedapplication/openapi+jsonandapplication/openapi+yaml, and the Datatracker files its first revision as expired. YAML itself waited longer: RFC 9512 registeredapplication/yamlin February 2024, and the YAML specification history begins in May ↩︎ Back to text -
APIs.guru keeps a directory of public OpenAPI documents, and its machine-readable listing 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. ↩︎ Back to text