Publishing and verifying

Embed
Preview of the card
<a href="https://forumulate.com/guides/publish-and-verify"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/9f366d3c-0185-403d-8128-5d2c0c45b837/social-card.svg?v=3e7ae33eec8b" alt="Publishing and verifying" width="600" height="315"></a>

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:

  1. What does every published page say to a search engine, and why? — the SEO report.

  2. Is the new version live yet? — ?wait=true on a publish, or the page.live webhook.

  3. 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 space

One 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

title

<title>

description

<meta name="description">

canonical

<link rel="canonical">

robots

<meta name="robots">; null = none (index, follow)

hreflang

every <link rel="alternate" hreflang>

ogTitle

<meta property="og:title">

jsonLdTypes

the @type of every JSON-LD block

and each of them is { value, source }, where source says which setting produced it:

source

Meaning

page.seoTitle, page.seoDescription

the piece's own SEO fields

page.content

derived from the piece's (or the surface's) own text

page.canonicalUrl

the piece's explicit canonical

page.noindex

the piece opted out of search

page.customization

the piece's own ogTitle

space.seo

the space's SEO settings (PUT …/spaces/{spaceId}/seo)

translations.<code>

the space's words in that language (PATCH …/spaces/{spaceId} {translations})

site.tagline

the site's tagline (else its footer sentence)

pattern:<type>.<kind>

the generated pattern for that kind of page, in its language (pattern:blog.tag)

default

what the reader derives with nothing set: the page's own name, its address, index/follow

none

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=true

With ?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/schema

A 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 deprecated where it sits — config.search as an object is the old spelling of config.searchSettings — and refused keys (a site theme's chrome) are refused through not, with x-forumulate-refused saying why;

  • a space: every settings key with its own door is listed, either validated here with that door's schema or refused (not) with the route to use in x-forumulate-route; x-forumulate-doors names 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=false

The 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

Changelog_list

GET /orgs/{orgId}/spaces/{spaceId}/changelog

Get the changelog in a space

forumulate changelog list<br>client.changelog.list()

Changelog_create

POST /orgs/{orgId}/spaces/{spaceId}/changelog

Create a changelog release

forumulate changelog create<br>client.changelog.create()

Changelog_compile

GET /orgs/{orgId}/spaces/{spaceId}/changelog/compile

Get the compile in a space

forumulate changelog compile<br>client.changelog.compile()

Changelog_widgetStats

GET /orgs/{orgId}/spaces/{spaceId}/changelog/widget-stats

List widget stats in a space

forumulate changelog stats<br>client.changelog.widgetStats()

Changelog_publish

POST /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}/publish

Publish the changelog

forumulate changelog publish<br>client.changelog.publish()

Changelog_get

GET /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}

Get the changelog

forumulate changelog get<br>client.changelog.get()

Changelog_update

PATCH /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}

Update the changelog

forumulate changelog update<br>client.changelog.update()

Changelog_remove

DELETE /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}

Delete the changelog

forumulate changelog delete<br>client.changelog.remove()

Changelog_schedule

POST /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}/schedule

Schedule the release

forumulate changelog schedule<br>client.changelog.schedule()

Changelog_unschedule

DELETE /orgs/{orgId}/spaces/{spaceId}/changelog/{releaseId}/schedule

Unschedule the release

forumulate changelog unschedule<br>client.changelog.unschedule()

Object Schema

Operation

Request

What it does

Shortcuts

ObjectSchema_site

GET /orgs/{orgId}/sites/{siteId}/config/schema

JSON Schema of what a site accepts

client.objectSchema.site()

ObjectSchema_space

GET /orgs/{orgId}/spaces/{spaceId}/config/schema

JSON Schema of what a space accepts

client.objectSchema.space()

Publish All

Operation

Request

What it does

Shortcuts

PublishAll_publishAll

POST /orgs/{orgId}/spaces/{spaceId}/publish-all

Publish all in a space

client.pages.publishAll.publishAll()

Seo Report

Operation

Request

What it does

Shortcuts

SeoReport_forSpace

GET /orgs/{orgId}/spaces/{spaceId}/seo-report

SEO report of a space

client.seoReport.forSpace()

SeoReport_forSite

GET /orgs/{orgId}/sites/{siteId}/seo-report

SEO report of a site

client.seoReport.forSite()

The Forumulate teamUpdated