Publishing and verifying
A publisher that keeps its site in a repository — a script, a CI job, the forumulate CLI — needs three answers after a run, and none of them should take a crawl:
What does every published page say to a search engine, and why? — the SEO report.
Is the new version live yet? —
?wait=trueon a publish, or thepage.livewebhook.Will the config I am about to send be accepted? — the object's config schema.
All three work with any tooling: plain HTTPS and JSON, a draft-07 JSON Schema any validator reads (ajv, check-jsonschema, VS Code, a YAML language server), and a webhook signed like every other.
The SEO report
GET /orgs/{orgId}/sites/{siteId}/seo-report every member space + the site's mounted pages
GET /orgs/{orgId}/spaces/{spaceId}/seo-report one spaceOne row per published reader address: the space root, every page and its language twins, the pieces (posts, topics, events), the generated pages (tags, archives, authors, series, status components, incidents, hubs), legal documents and their editions, and a site's page mounts. Each row carries what the page's head says as rendered:
Field | What it is |
|---|---|
|
|
|
|
|
|
|
|
| every |
|
|
| the |
and each of them is { value, source }, where source says which setting produced it:
| Meaning |
|---|---|
| the piece's own SEO fields |
| derived from the piece's (or the surface's) own text |
| the piece's explicit canonical |
| the piece opted out of search |
| the piece's own |
| the space's SEO settings ( |
| the space's words in that language ( |
| the site's tagline (else its footer sentence) |
| the generated pattern for that kind of page, in its language ( |
| what the reader derives with nothing set: the page's own name, its address, index/follow |
| nothing is rendered |
So a title that ignores your seoTitle is not a mystery: the row names the setting that won.
Rows also carry kind (root, page, piece, hub, legal, legal-edition, mount), locale, spaceId, the HTTP status the reader answered, version (a page's published revision time, when the surface states one) and indexable: false marks an address the sitemap leaves out on purpose — a noindex page, a nav-hidden one, a closed role, an edition — so you can check its robots line rather than wonder why it is missing.
How it stays true
The values are never recomputed. Each address is rendered by the reader itself, on our internal network (past the edge, so you see what we serve now, not a cached copy), and its head is read back. The title, canonical and robots rules live in one place — the reader — so the report cannot disagree with the HTML. The list of addresses is the sitemap's own walk, plus what the sitemap leaves out on purpose.
Paging
Each row is a render, so a page holds at most 25 (default 20). Walk page.nextCursor as on every list; page.total is the number of addresses. Narrow with ?kind=hub or, on a site, ?spaceId=. A row the reader did not answer in time has status: 0 and an error; read that page again.
curl -H "Forumulate-Version: 2027-03-01" -s -H "Authorization: Bearer $FORUMULATE_TOKEN" \
"https://forumulate.com/api/orgs/acme/sites/main/seo-report?limit=25" |
jq -r '.rows[] | select(.title.source == "default") | .url'A private space publishes no address, so its report is empty. The platform copy of a page (forumulate.com/p/…, /b/… and so on, when the space has a live domain of its own) is not listed: it names your domain as canonical, and the report lists the canonical address.
Waiting for a publish
POST /orgs/{orgId}/spaces/{spaceId}/pages/{pageId}/publish?wait=true
POST /orgs/{orgId}/spaces/{spaceId}/publish-all?wait=true
POST /orgs/{orgId}/spaces/{spaceId}/blog/{topicId}/publish?wait=true
POST /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}/publish?wait=trueWith ?wait=true the answer holds — up to 60 seconds — until the reader serves the version this publish produced: we read the page through the reader (past the edge, which the publish already purged), and the reader states the version its head and body were drawn from. The answer gains
"live": {
"live": true,
"url": "https://acme.com/docs/getting-started",
"version": "2026-10-25T14:03:11.204Z",
"servedAt": "2026-10-25T14:03:12.010Z",
"waitedMs": 806,
"retryAfter": null,
"reason": "live"
}Not seen within the bound: 202 with Retry-After and live.live: false (reason: "timeout", or "unreachable" when the reader did not answer). The publish itself is done either way. reason: "no_address" (a piece with no address of its own, such as a status incident) and "not_public" (a private space) answer 200: there is nothing to wait for. publish-all waits for every page it published inside one bound and lists each under pages.
Without ?wait, every door answers exactly as before.
The page.live webhook
Subscribe a webhook to page.live and it fires once per published version, when the reader is seen serving it: {url, version, servedAt, spaceId} plus pageId or topicId. It covers every way a page goes live — the publish door, publish-all, a scheduled publish, an import, a git sync — and is watched for about eight minutes. A version the reader never served (a later publish replaced it first, or the space went private) fires nothing. Pages are only watched while a webhook subscribes to page.live.
Why <title> used to lag the body
A space's SEO write (PUT …/seo) and a space PATCH (name, settings, translations) refreshed only the API's own copy. Every page's head reads the space's SEO through the reader's metadata cache (five minutes) and the edge, so a new seoTitle, title template or site name reached <title> minutes after a page body published beside it. Both writes now evict the reader's cached space metadata and the edge, as a theme write does.
What will this object accept?
GET /orgs/{orgId}/sites/{siteId}/config/schema
GET /orgs/{orgId}/spaces/{spaceId}/config/schemaA draft-07 JSON Schema of exactly what that object's PATCH accepts now, for the Forumulate-Version you send (x-forumulate-api-version says which). It is generated from the schema the write door validates with, then narrowed to the object:
a site: its page mounts may name only its member spaces; the theme keys the strict theme check accepts by declaration are included; every key whose meaning moved is
deprecatedwhere it sits —config.searchas an object is the old spelling ofconfig.searchSettings— and refused keys (a site theme'schrome) are refused throughnot, withx-forumulate-refusedsaying why;a space: every
settingskey with its own door is listed, either validated here with that door's schema or refused (not) with the route to use inx-forumulate-route;x-forumulate-doorsnames each door, the theme by its published schema and the SEO object inline.
x-forumulate-moved lists every moved key with where it went and since when; x-forumulate-mistakes is the did-you-mean table the API's 422s use.
curl -H "Forumulate-Version: 2027-03-01" -s -H "Authorization: Bearer $FORUMULATE_TOKEN" \
https://forumulate.com/api/orgs/acme/sites/main/config/schema > site.schema.json
npx ajv-cli validate -s site.schema.json -d site.json --strict=falseThe anonymous /schemas/{version}/space-theme.json and site-config.json are the same generation without the per-object narrowing; they now include the round-5 theme keys the API already accepted.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.
Changelog
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the changelog in a space |
| |
| Create a changelog release |
| |
| Get the compile in a space |
| |
| List widget stats in a space |
| |
| Publish the changelog |
| |
| Get the changelog |
| |
| Update the changelog |
| |
| Delete the changelog |
| |
| Schedule the release |
| |
| Unschedule the release |
|
Object Schema
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| JSON Schema of what a site accepts |
| |
| JSON Schema of what a space accepts |
|
Publish All
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Publish all in a space |
|
Seo Report
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| SEO report of a space |
| |
| SEO report of a site |
|