API reference
Keep API documentation in sync with backend changes in CI
A GitHub Actions workflow that exports your OpenAPI spec from the backend, fails when it's stale, invalid, or breaking, and rebuilds the API reference from it.
By Hayden Bleasel12 min read

To keep API docs in sync with your backend, make the OpenAPI spec something CI produces from your code and something your docs build from. On every pull request, the workflow in this guide exports the spec from the backend, fails if the committed copy is out of date, validates it, checks it for breaking changes, and builds the docs from it. Blume regenerates every operation page from the spec on each build, so when the spec is right, the reference is right.
You end up with one GitHub Actions workflow, and pull requests that change an API field show the spec diff, a breaking-change verdict, and a docs build from the new spec. The example is the fictional Acme Messages API, written in Hono with Zod. Any framework that can write its spec to a file works the same way.
Blume covers the docs end: the build fails when the spec can't be loaded, and blume validate fails when a page links to an operation that no longer exists. Blume doesn't validate the spec against the OpenAPI schema or compare two versions of it, so this guide adds oasdiff, an open-source OpenAPI diff tool, for both. If your spec is a hand-written file in the docs repository, skip the export and freshness steps: Blume already rebuilds the reference from it on every build. Only the reference updates itself, though. For the pages you write about the API, see keeping docs updated from merged code changes.
Four checks, not one
"Are the API docs right?" is really four questions, and each one fails for a different reason:
| Check | What it answers | What runs it |
|---|---|---|
| Spec freshness | Does the committed spec match the code? | Your export script, then git status |
| Schema validation | Is the spec a valid OpenAPI document? | oasdiff validate |
| Breaking-change detection | Would this change break existing clients? | oasdiff breaking |
| Docs build | Does the reference build, and do links to it still resolve? | blume validate, blume build, blume audit |
Keep them as separate steps, so a red check says which question failed. Don't count on the docs build to catch a bad spec. Blume reads the spec with Scalar's parser and upgrades it to OpenAPI 3.1, but it only rejects a file that isn't a YAML or JSON object. A spec whose /messages/{id} operation never declares its id parameter still builds a reference, while oasdiff validate rejects it.
Lay out the repository
This guide keeps the backend and the docs in one repository, with the generated spec committed inside the docs project:
acme/
├── .github/workflows/api-docs.yml
├── api/
│ ├── package.json
│ ├── scripts/export-openapi.ts
│ └── src/app.ts
└── docs-site/
├── blume.config.ts
├── docs/index.mdx
├── openapi.json
└── package.jsondocs-site/ is a Blume project made with npx blume init docs-site --template docs, so its pages live in docs-site/docs/. Commit the lockfile in both folders, since CI installs from them.
Committing a generated file is deliberate. Reviewers see every API change as a diff in the pull request, the docs build without the backend's toolchain, and oasdiff can read the base branch's copy without running the old code. The alternative, pointing spec at a URL your running API serves, documents whatever is deployed rather than what the pull request changes. And a build that can't fetch it falls back to the last cached copy, if there is one, with only a BLUME_OPENAPI_STALE warning.
Export a deterministic spec
The backend defines its routes with @hono/zod-openapi, so the spec comes from the same Zod schemas that validate requests. The app lives in its own module and never starts a server, so exporting needs no port, database, or secrets:
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
const Channel = z
.enum(["email", "sms"])
.openapi({ description: "How to deliver the message." });
const Message = z
.object({
id: z.string().openapi({ example: "msg_8f2k" }),
status: z.enum(["queued", "sent", "delivered", "failed"]),
channel: Channel,
})
.openapi("Message");
const SendMessageRequest = z
.object({
channel: Channel,
to: z.string().openapi({
description: "An email address, or a phone number in E.164 format.",
}),
template: z
.string()
.openapi({ description: "The ID of the template to send." }),
})
.openapi("SendMessageRequest");
const sendMessage = createRoute({
method: "post",
path: "/messages",
operationId: "sendMessage",
tags: ["Messages"],
summary: "Send a message",
description:
"Queues an email or SMS for delivery and returns its ID right away.",
request: {
body: {
required: true,
content: { "application/json": { schema: SendMessageRequest } },
},
},
responses: {
202: {
description: "The message is queued.",
content: { "application/json": { schema: Message } },
},
},
});
const getMessage = createRoute({
method: "get",
path: "/messages/{id}",
operationId: "getMessage",
tags: ["Messages"],
summary: "Get a message",
description: "Returns a message and its delivery status.",
request: {
params: z.object({
id: z.string().openapi({
param: { name: "id", in: "path" },
description: "The message ID, from the response to Send a message.",
example: "msg_8f2k",
}),
}),
},
responses: {
200: {
description: "The message.",
content: { "application/json": { schema: Message } },
},
},
});
export const app = new OpenAPIHono();
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
description: "An API key, sent as a bearer token.",
});
app.openapi(sendMessage, (c) => {
const { channel } = c.req.valid("json");
return c.json({ id: "msg_8f2k", status: "queued" as const, channel }, 202);
});
app.openapi(getMessage, (c) => {
const { id } = c.req.valid("param");
return c.json(
{ id, status: "delivered" as const, channel: "email" as const },
200
);
});The export script imports the app, asks it for an OpenAPI 3.1 document, and writes it into the docs project:
import { writeFileSync } from "node:fs";
import { app } from "../src/app.ts";
const document = app.getOpenAPI31Document({
openapi: "3.1.0",
info: {
title: "Acme Messages API",
version: "1.0.0",
description: "Send transactional email and SMS.",
},
servers: [{ url: "https://api.acme.example/v1" }],
security: [{ bearerAuth: [] }],
tags: [
{
name: "Messages",
description: "Send a message and check whether it arrived.",
},
],
});
const target = new URL("../../docs-site/openapi.json", import.meta.url);
writeFileSync(target, JSON.stringify(document, null, 2) + "\n");Give it a script name in the backend's package.json. Node.js 22.18 and later run a TypeScript file directly, so there's no build step:
{
"name": "acme-api",
"private": true,
"type": "module",
"scripts": {
"openapi": "node scripts/export-openapi.ts"
},
"dependencies": {
"@hono/zod-openapi": "1.6.3",
"hono": "4.13.9",
"zod": "4.6.5"
}
}Run npm install and npm run openapi in api/, and commit the docs-site/openapi.json it writes. The freshness check compares bytes, so the export must write the same bytes whenever the code is the same:
- Keep dates, commit SHAs, and environment variables out of the document. A version string built from the date changes on every run.
- Write it with fixed formatting and a trailing newline, and keep your formatter away from the file. Otherwise the formatter and the export keep rewriting each other's output.
- Commit the lockfile. Another version of the generator can order or word the output differently.
Run the export twice. If the second run changes the file, something in it varies. Other frameworks export the same way; the FastAPI, NestJS, and Django REST Framework guides show their export step.
Point Blume at the spec
The docs project mounts the committed spec as an API reference at /api. The OpenAPI docs guide covers the reference in depth; this is the minimum:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
title: "Acme Docs",
deployment: { site: "https://docs.acme.example" },
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "API reference", path: "/api" },
],
},
reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});deployment.site gives the build an absolute URL for the sitemap and canonical links, since Blume can't detect one on a GitHub Actions runner. Next, link to operation pages from the pages you write. Those links are how CI notices when an API change moves a page:
---
title: Acme Messages
description: Send transactional email and SMS with the Acme Messages API, then check whether each message arrived.
---
Queue an email or SMS with [Send a message](/api/messages/send-message). The
response holds the message's ID: pass it to
[Get a message](/api/messages/get-message) to see whether it arrived.An operation's URL comes from its tag and its operation ID, so sendMessage under the Messages tag lives at /api/messages/send-message.
Add the workflow
One workflow with three jobs runs on every pull request and every push to main:
name: API docs
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
spec:
name: Spec
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Export the spec from the code
working-directory: api
run: |
npm ci
npm run openapi
- name: Check the committed spec is fresh
run: |
if [ -n "$(git status --porcelain -- docs-site/openapi.json)" ]; then
git diff -- docs-site/openapi.json
echo "::error file=docs-site/openapi.json::The spec is stale. Run 'npm run openapi' in api/ and commit docs-site/openapi.json."
exit 1
fi
- name: Validate the spec
uses: oasdiff/oasdiff-action/validate@v0.1.17
with:
spec: docs-site/openapi.json
breaking:
name: Breaking changes
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: git fetch --depth=1 origin ${{ github.base_ref }}
- uses: oasdiff/oasdiff-action/breaking@v0.1.17
with:
base: "origin/${{ github.base_ref }}:docs-site/openapi.json"
revision: "HEAD:docs-site/openapi.json"
fail-on: ERR
review: false
docs:
name: Docs
needs: spec
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs-site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npx blume validate --strict
- run: npx blume build
- run: npx blume auditThe oasdiff actions are pinned to a release here; oasdiff also publishes a moving v0 tag if you'd rather take updates as they ship. Once the workflow has run, make its checks required in your branch protection rules, so a failing check blocks the merge.
Spec freshness
The spec job exports the spec from the checked-out code, then asks Git whether that changed the committed file. git status --porcelain also catches a spec that was never committed at all, which git diff alone would miss. When the check fails, the log shows the diff and the annotation says what to run.
Schema validation
The same job then runs oasdiff's validate action on the fresh spec. It checks the document against the OpenAPI and JSON Schema rules and annotates each finding. By default it fails only on errors, and reports warnings and info-level findings, like an example that doesn't match its schema, without failing.
Breaking changes
The breaking job compares the spec on the base branch with the one in the pull request. oasdiff reads both straight from Git, which is why the job fetches the base branch first. fail-on: ERR fails on changes oasdiff rates as errors, like a new required request field. Set it to WARN to also fail on warnings, like a removed request field. review: false keeps both specs inside CI: by default, the action uploads them, encrypted, to oasdiff.com to link a side-by-side review.
The docs build
The docs job has needs: spec, so it only builds from a spec the first job has proven fresh and valid. It checks out the same commit, so the spec reaches it through the repository. If you'd rather not commit the spec, upload it as an artifact in the spec job and download it in the docs job, but you lose the reviewable diff and the base-branch copy that oasdiff compares against. Then three Blume commands run:
blume validate --strictchecks every link in your pages, including links to operation pages.--strictfails on warnings too, including the reference's own, likeBLUME_OPENAPI_EMPTYfor a spec with no operations.blume buildfails withBLUME_OPENAPI_UNAVAILABLEwhen it can't read or parse the spec. Inblume dev, that's only a warning, so a working dev server can hide it.blume auditchecks the built HTML and fails on errors, such as a link to a page that was never built (BLUME_AUDIT_LINK_TO_BROKEN). Advisory findings stay warnings.
Change a field in a pull request
Say messages can now be scheduled. On a new branch, add an optional sendAt field to the request schema:
template: z
.string()
.openapi({ description: "The ID of the template to send." }),
+ sendAt: z.iso.datetime().optional().openapi({
+ description: "When to send the message. Omit it to send right away.",
+ example: "2026-10-01T09:00:00Z",
+ }),
})
.openapi("SendMessageRequest");Push only that change, and the spec job fails: the export adds a property the committed spec doesn't have. Export again and commit the spec beside the code:
(cd api && npm run openapi)
git add docs-site/openapi.json
git commit -m "Regenerate the OpenAPI spec"
git pushThe pull request's diff now shows the API change the way clients will see it:
"template": {
"type": "string",
"description": "The ID of the template to send."
+ },
+ "sendAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When to send the message. Omit it to send right away.",
+ "example": "2026-10-01T09:00:00Z"
}
},Every check passes. The spec is fresh and valid, and oasdiff rates a new optional request field as a non-breaking change. The docs job builds the reference from the new spec, and the Send a message page's request body table gains a sendAt row, typed string<date-time>, with its description and no required badge. If your host builds a preview for each pull request, as Vercel and Netlify do for a connected repository, reviewers can open /api/messages/send-message there before anything merges.
When the change breaks clients
Now suppose the same pull request also renames to to recipient. Every client that sends to today breaks, and so does the breaking job. The action annotates each change on the pull request, and the oasdiff CLI (brew install oasdiff) prints the same result locally:
oasdiff breaking origin/main:docs-site/openapi.json HEAD:docs-site/openapi.json --fail-on ERR2 changes: 1 error, 1 warning, 0 info
error [new-required-request-property] at docs-site/openapi.json
in API POST /messages
added the new required request property `recipient`
warning [request-property-removed] at docs-site/openapi.json
in API POST /messages
removed the request property `to`The new required field is an error, so the job fails. The removed field is only a warning, which fail-on: ERR lets through on its own. What happens next is an API decision rather than a docs one: accept both names for a while, or ship the rename as a new API version.
When the change moves a page
Renaming an operation ID moves its page. Change sendMessage to createMessage, and blume validate fails the docs job, pointing at the link you wrote:
BLUME_BROKEN_LINK Broken link to /api/messages/send-message: no page resolves to /api/messages/send-message.
at docs/index.mdx:6:45
fix: Check the path, or create the target page.
docs: https://useblume.dev/docs/cli/validateUpdate the link to /api/messages/create-message, and add a redirect so the old URL keeps working for everyone who saved it:
export default defineConfig({
// ...the config from above
redirects: [
{ from: "/api/messages/send-message", to: "/api/messages/create-message" },
],
});blume validate counts a redirect as a page, so the redirect on its own clears the error. blume audit then warns about a link that goes through a redirect (BLUME_AUDIT_LINK_TO_REDIRECT), which is your reminder to update it. The redirects docs cover how each host serves them.
Troubleshooting
The freshness check fails, but you ran the export
The committed file differs from what CI writes. Run the export twice and check git status: if the second run changes the file, the document holds something that varies, like a date. If it only differs in CI, compare dependency versions (CI installs from the lockfile), and check whether a formatter or pre-commit hook rewrote the file after the export.
The breaking job can't load the base spec
On the first pull request, the base branch has no spec yet, and oasdiff stops with path 'docs-site/openapi.json' does not exist in 'main'. Merge the spec and the workflow first, then open API changes against them. An error that says invalid object name 'origin/main' means the base branch wasn't fetched, so keep the git fetch step before the action.
The docs build fails with BLUME_OPENAPI_UNAVAILABLE
Blume couldn't read or parse the spec, and the message says why. spec is resolved from the Blume project's root, not the repository's, so ./openapi.json means docs-site/openapi.json here. Check that the export script writes to that path, and that the file is committed.
oasdiff rejects a spec Blume renders
That's expected: Blume doesn't validate the spec, so a spec with errors can still render. Fix the finding in the code the spec comes from, such as a route's parameter schema, and export again. Don't hand-edit openapi.json: the next export overwrites it, and the freshness check fails until it does. To change what the docs show without changing the API, like hiding internal endpoints, use an overlay. Blume applies it at build time, and the committed spec stays exactly as exported.
A breaking change is intentional
Sometimes you mean to break clients, say in an API that's still in beta. oasdiff's breaking action takes an err-ignore file of regular expressions for error-level changes to ignore. Add the change there in the same pull request, so the exception gets reviewed along with it.
Next step
Trim the spec for public docs
If the exported spec includes internal endpoints, hide them with an overlay instead of editing the generated file, so the freshness check keeps passing.
Remove internal endpointsA step here not working for you? Report a broken step.