workspace as shown. See
Quickstart if you have not made a key yet.
workspace argument off every call. An account-wide key works
everywhere too — including the Pages import, asset and preview-link endpoints —
as long as you pass workspace when your holder reaches more than one.
1 · How many credits are left
The one call that takes no workspace — credits belong to the account, not to a single workspace.2 · What is going out this week
meta_line is the same sentence the schedule board shows you, so it is the
right thing to put in a status message. To see where a specific piece has got
to, content.status gives you counts by state plus the pieces behind them.
3 · Find something in the library, then open it
Two calls, because searching and fetching are different jobs. Search first:kind and the id from the search
result — an id alone is not enough, because different kinds of thing can share
one:
query returns the most recent items, which is a cheap way to see what
a workspace has.
4 · Ask your knowledge base a question
relevance lets you drop weak
hits before you show them to anyone.
5 · Create a project and move a board into it
Read the workspace tree before using any board id.kind and root_board_id
are optional; use kind: "site" to narrow the result to Pages boards and the
projects that contain them. Creating or moving a project requires an operator
role.
idempotency_key. Reuse that key only
when retrying this same create. parent_board_id is optional and creates a
nested project when present.
board.tree into that project. Omit
from_parent_board_id when the board has one current location; the service
resolves it. Send to_parent_board_id: null to move a board back to the root.
board.move refuses with
boards_tree.parent_ambiguous. Read the tree again and retry with the exact
from_parent_board_id it should leave.
6 · Create and maintain a Data-backed CMS
Create a typed table on the project board.name, columns, and
idempotency_key are required. board_id is optional; omitting it creates a
board for the table. Column keys use lower snake case, start with a letter, and
are at most 64 characters. Supported types are text, number, boolean,
date, email, phone, url, and json. All six Data actions require an
operator role.
workspace handle when the key is not pinned.
data.describe returns table definitions, counts, row limits, sources, and the
current published_columns. data.query returns count_line, total,
returned, rows shaped as { "row_id", "values", "source" }, and
next_cursor when another page exists. Its limit is 1 through 100. Equality
filters take string values and can name only real columns.
data.append validates and coerces the whole row against the table schema;
unknown keys are refused. Reuse its idempotency_key only for the same append.
data.update_cell accepts a JSON string, number, boolean, null, array, or
object, then revalidates the complete row. data.set_published_columns
replaces the entire public list; an empty array publishes no columns. It
decides what a public Page may read, so it answers in two calls: without
confirmation_id it proposes, returning state: "confirmation_required", a
confirmation_id and a request_summary, and changes nothing; send the same
payload again with that confirmation_id, on a member’s own API key, to apply
it. A service account’s key can propose but never confirm, and a table whose
columns or public list changed in between answers proposal_changed — propose
again.
After the Page itself is live, its server or Worker can read only those public
columns. This read has no bearer credential and never permits writes:
limit from 1 through 1,000, optional cursor, and
one equality filter expressed by the pair eqColumn and eqValue. Supplying
only half of that pair is refused.
7 · Finish a Page setup without opening the app
Use the same workspace-pinned key for the whole sequence:- Create the project with
board.create_folder. - Create and populate the CMS table with
data.create_tableanddata.append, then set its explicit public projection withdata.set_published_columns(propose, then confirm on a member’s own key). - Send the site ZIP to
POST /api/pages/ingest. Supplying an existingsiteIdreplaces that same site’s bundle or managed source; a source upload returnsbuildRunId. - Follow a source build with
page.build_status, passing thatbuildRunIdasbuild_run_id, until it returnssucceededand a revision. - Upload PNG, JPEG, WEBP, or GIF references with
POST /api/pages/{siteId}/assets. Each upload is attached to that site’s board automatically. - Read
board.treewithkind: "site", then useboard.moveto place the site’s board in the project. - Create a review link with
POST /api/pages/{siteId}/preview-links. - Call
page.publishonce to obtain the exact-revision confirmation. Show the returned site and revision to the approver, then call it again with theconfirmation_idafter approval.
The whole Pages lifecycle, from a clean directory
This is a real run, recorded verbatim on 2026-09-12 against the Dev environment, from an empty directory with only an API key and these pages — no repository, no Cloudflare account, no platform credential. The key used was account-wide (not narrowed to a workspace), which is whyworkspace appears
on every call. The two multipart steps were re-run after workspace moved to
the query string, so the commands below are the ones that were executed.
Responses are trimmed to the fields the next step needs; identifiers are the
real ones from the run.
0 · What was in the directory
Two files, written by hand:1 · Which workspaces can this key reach?
The fastest way to find out is to name one that does not exist. The refusal lists the real ones.workspaces array, the slugs are appended to
error.message instead.
2 · Import
workspace is a query parameter on this endpoint, not a form field — the
body is your archive, and reading a field out of it would mean decoding up to
40 MB before your key was checked.
deploySource: "declared" means our own pages.deploy.json was used. Omitting
the file entirely returns "synthesized" and the manifest we wrote.
3 · Attach an image, and list what is attached
4 · Look at it
5 · Publish, in two calls
confirmation_id:
6 · Where does it answer?
active with no steps and no records — it
is ours end to end. A domain you own is the one with a steps list to watch;
connecting one returns the records to add:
page.domain_connect answers if you try to claim one of ours.)
A real connect returns the records to add, routing first — that CNAME is the
one that makes the hostname resolve, and the certificate records alone are not
enough. Shown here as an illustration, not a recording: this run drove only
the refusal paths above, because a success would claim a real hostname.
7 · Ship a second revision, then put the first one back
Re-import onto the samesiteId, publish it the same two-call way, and the
site now has two builds:
page.revisions shows the flag has moved:
The same surface over MCP
Every tool above answers identically over the MCP endpoint — same names, same arguments, same envelope. Atools/list on POST /api/mcp/mcp with the same
bearer key returned 41 tools, ending:
tools/call on page.revisions returned the same body the REST facade did.
That is not a coincidence to re-check per tool: both transports run one dispatch,
and a test walks the whole advertised list over both and deep-compares.
Two refusals worth wiring for
Also useful
connections.status answers which providers a workspace has connected and
which need attention, split into connected, needs_attention and available.
It is the call to make before an automation tries to publish somewhere, so a
failure becomes a warning you saw coming.
Two habits worth keeping
- Branch on
ok, never on the status code. An action that declines still answers200. Errors and refusals has the full list. - Log
working_inalongside the result. It is the fastest way to catch an integration quietly working in the wrong workspace.