Draft a theme, and preview it on your own domain
A theme change is visible to every reader the moment it saves. A draft lets you save without publishing, look at the result on your own domain behind a token, and then publish in one call.
This page assumes you have read Theme your site, which covers the keys themselves.
The endpoints
Call | What it does |
|---|---|
| Read the draft, and whether it is stale. |
| Save a draft, replacing it. |
| Save a draft, merging into it (deep). |
| Make the draft the live theme. |
| Throw the draft away. The live theme is untouched. |
| Mint a preview token and get the URLs to open. |
PUT and PATCH take the same shape as the published theme and are strict: an unrecognised key is a 422 naming the path, for example chrome.footerColumns.0.title. This is the one theme surface that refuses unknown keys rather than dropping them, because a misspelled theme key is invisible — the page renders, just not the way you asked.
Save a draft
PATCH /orgs/{orgId}/spaces/{spaceId}/theme/draft
{
"accentColor": "#2d6cdf",
"chrome": { "footerColumns": [{ "heading": "Product", "links": [] }] }
}PATCH on a draft merges the same way PATCH on the live theme does, null included: {"logoUrl": null} clears the key in the draft.
Saving a draft never touches the live theme and does not invalidate any reader cache. The caches hold the published theme; a preview swaps the draft in per request.
Script origins and ad settings are validated at save, not at publish. A draft that cannot be published is configuration accepted and then not honoured, which is worse than a refusal.
Read it back
GET /orgs/{orgId}/spaces/{spaceId}/theme/draft{
"exists": true,
"theme": { … },
"updatedAt": "2026-10-03T09:12:44.102Z",
"updatedBy": "…",
"stale": false
}exists: false means there is no draft. An envelope with no theme in it counts as no draft, so "I discarded the draft" and "I drafted the default look" stay distinguishable.
Preview it
POST /orgs/{orgId}/spaces/{spaceId}/preview{
"token": "…",
"expiresAt": "2026-10-03T10:12:44.000Z",
"windowSeconds": 1800,
"urls": [
"https://docs.yourcompany.com/?preview=…",
"https://forumulate.com/p/{orgId}/{spaceId}?preview=…",
],
}Open any of the urls. Your custom domains come first, because that is the address the look has to be right on.
Why a token and not just a cookie
Your staff session cookie is first-party to our origin. It cannot travel to docs.yourcompany.com, so ?draft=1 on your own domain used to silently show the published page — a preview that looked like a bug in the theme.
The token is in the query string, and only in the query string. Our edge reads ?preview= and forwards it internally as a header, because a Next layout (which is where the theme is compiled) is never handed the query parameters. A header sent by a client is stripped first, so the query string stays the only source of truth. No cookie is involved, which is also why a preview URL can be pasted to a colleague.
The window
windowSeconds is 1800. The previous window is accepted too, so a token is good for between 30 and 60 minutes depending on when in the window you minted it. A token with a timestamp in the future is refused outright.
Mint a new one rather than trying to extend one. It is a single call.
When a token is not accepted
Every failure — missing, malformed, wrong space, wrong organization, expired, forged — falls through to the published theme, indistinguishably. There is no error. So if a preview URL shows you the live look, the token has expired or it belongs to a different space; re-mint it.
The token value must match ^[A-Za-z0-9._~-]+$ and be at most 256 characters, so URL-encode it when you build the link by hand (the urls in the response already are).
Previewing one draft page, not the theme
That is a different parameter:
GET /public/orgs/{orgId}/spaces/{spaceId}/preview/pages/{pageId}?token=…?token= for a page, ?preview= for the chrome and the theme. The same minted value works for both.
Publish it
POST /orgs/{orgId}/spaces/{spaceId}/theme/draft/publishOne update sets the live theme and deletes the draft together, and writes an audit entry. There is no window in which both are half-applied.
The 409 when the live theme moved
{
"error": {
"code": "theme_draft.stale",
"message": "The live theme changed after this draft was saved, so publishing it would revert that change. Re-save the draft, or publish with force to overwrite.",
},
}A draft records a fingerprint of the published theme as it was when you saved. At publish we compare that fingerprint with the published theme's fingerprint now. If somebody else changed the live theme in between, publishing your draft would silently revert their change — so we refuse.
The fingerprint is a hash of the theme's content with keys sorted at every depth, not an updatedAt and not a version number. That matters because a jsonb round trip does not preserve key order, so comparing raw JSON would report a conflict that is not one.
Two ways out:
# 1. Re-save the draft. That re-reads the live theme and re-bases your draft on it.
PATCH …/theme/draft { … }
POST …/theme/draft/publish
# 2. Publish anyway, discarding their change on purpose.
POST …/theme/draft/publish { "force": true }GET …/theme/draft tells you in advance: stale: true means a publish will 409. A draft saved before this field existed has nothing to compare and is treated as current, so it will never block.
A publish with no draft at all is a 404, not a 409.
A publish evicts the reader's caches for the whole space — our data cache, the rendered pages, the CDN, and the security policy's list of allowed origins — so the new look is live within a couple of seconds, like publishing a page. That includes a theme that adds a NEW font, stylesheet or image origin: the reader's Content-Security-Policy allows it on the same request that first serves the CSS loading it, on forumulate.com/p/… and on your own domain alike. Saving the draft did not, which is why a preview is instant while the published theme is still the old one.
Two honest limits. If our eviction call fails (it is best-effort and never fails your publish), the origin list refreshes on its own within one minute. And a CDN of YOUR own in front of your domain keeps whatever it cached for its own TTL unless you have given us a Cloudflare token to purge it.
A site's draft, and previewing it
A site has a theme draft of its own, with the same calls as a space's. Importing a theme bundle or applying a library theme lands there, front page included, and so does your own edit:
Call | What it does |
|---|---|
| The draft, if any: |
| Store a whole theme as the draft. |
| Deep-merge onto the draft, or onto the live site theme when there is none yet. |
| Make it live across every member space and clear it. 409 |
| Throw it away. The live theme is untouched. |
The body of PUT/PATCH is the theme object itself (what sits under config.theme), validated as PATCH /sites/{siteId} validates config.theme: an unknown key is a 422 naming it, and a 404 page, forum category or motion target the draft could never publish is refused when you save it, not when you publish. PATCH /sites/{siteId} with config.theme still writes the LIVE theme immediately; use the draft when readers should not see the change yet.
A site draft is previewed with a site preview link, which is a different thing from a space's token:
Call | What it does |
|---|---|
| Mint a link. The token is in the response, and only there. |
| List the links you minted, with |
| Revoke one and mint its replacement with the same settings. |
| Revoke one. It stops working on the next request. |
All four need space.update, the capability every site theme write uses.
POST /orgs/{orgId}/sites/{siteId}/preview-tokens
{ "label": "For the design review", "includeSpaceDrafts": false, "expiresInHours": 24 }{
"link": {
"id": "…",
"token": "cfsitepv_…",
"expiresAt": "2026-10-11T09:12:44.000Z",
"includeSpaceDrafts": false,
"active": true,
"urls": [
"https://docs.yourcompany.com?preview=cfsitepv_…",
"https://forumulate.com/s/{orgId}/{siteSlug}?preview=cfsitepv_…",
],
},
"data": { … } // the same object, as on every create
}Every field of the body is optional. expiresInHours is 1 to 720 (thirty days) and defaults to 24. In the console, Sites → Preview links → Preview draft mints one and opens it; Preview draft after importing a theme into a site does the same with the defaults.
What the link shows, and where
Put ?preview=<token> on any address of that site — its front door, or any page of a space the site publishes, on your own domain or ours:
The site's front door renders the draft theme and the draft's front page. A draft with no front page of its own keeps the published one; a site with no draft at all shows the published site (with the ribbon, so you know the link worked).
Every space under the site wears the site's draft layer under its own published theme. A space's own draft stays hidden unless you minted the link with
"includeSpaceDrafts": true; then a space that has a draft is previewed exactly as its own preview token would show it — its draft theme, its draft front page and its unpublished pages.A staged site opens to a valid link, like it does to its staging token.
Anywhere else the token is ignored and the published page is served: another site of the organization, a space this site does not publish, another organization. As with a space token, a revoked, expired or foreign link shows the published site rather than an error, so a probe cannot tell a link that never existed from one you revoked.
What a preview can never do
Be cached. A response to a request carrying
?preview=isCache-Control: private, no-storefrom the API and from our edge, and the reader's data cache refuses any fetch with apreviewparameter. The next reader without the token cannot be served what you saw.Be indexed. Every preview response carries
X-Robots-Tag: noindex, nofollow, and the site's front door adds anoindexrobots tag.Look like the live site. A valid link puts a Preview ribbon in the corner of every page it renders. An invalid one does not, because what you are then looking at IS the live site.
Space tokens and site links, side by side
Space preview token | Site preview link | |
|---|---|---|
Mint |
|
|
Lifetime | 30–60 minutes, rotating window | 1 hour to 30 days, you choose |
Revocable | No (it expires) | Yes, one by one, and listed |
Shows | That space's draft | The site's draft, plus space drafts if asked |
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 clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Site Preview
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List site preview links |
| |
| Mint a site preview link |
| |
| Rotate a site preview link |
| |
| Revoke a site preview link |
|