Skip to main content
The Pages API takes a site from an archive to a reviewable preview, publishes one exact revision, points a domain at it, and puts a previous build back if you need to. Everything here is reachable with one API key — no repository, no Cloudflare account, no platform credential. Tool actions are POST https://app.goosybear.ai/api/v1/tools/{tool} with a JSON body. Archive, asset and preview-link operations use the Pages-specific REST endpoints shown below, because they carry bytes rather than JSON.

The lifecycle, in order

Two more reads sit alongside: page.read for the site’s current facts and page.assets for what is attached to it. If you build with a coding agent, the Goosy deploy skill teaches it all of this in one file.

Which workspace

Every call on this page takes the same optional workspace argument — a workspace slug or id — and it is resolved the same way everywhere, including on the multipart import endpoint:
  • A key pinned to one workspace runs there. Naming a different workspace is refused, never silently ignored — a pin is a ceiling.
  • An account-wide key may select any workspace its holder currently reaches. With nothing named it uses your default, or your only workspace.
  • Two or more reachable workspaces and nothing selected is refused as workspace_ambiguous, and the answer lists the slugs you may pass.
Where it goes depends on the door, and the name never changes. A JSON body carries it as a field — every tool on this page, and the preview-link endpoint. The two MULTIPART doors take it as a query parameter (?workspace=<slug>): POST /api/pages/ingest and POST /api/pages/{siteId}/assets. That is so the credential and your rate limit are resolved before anything decodes a large body. Our API host refuses a request body over about 4.5 MB before it reaches us, so a larger archive posted to POST /api/pages/ingest (or its validate endpoint) gets a bare 413 rather than our own typed upload_too_large. That endpoint only takes the bytes directly — it has no field for a URL to fetch. For a larger archive without posting the bytes yourself, upload it in the app (up to 40 MB), or have Goosy import it in chat from an https:// address — that is a chat capability, not something this key-authenticated endpoint accepts. A revoked membership closes a key’s reach on the very next call — the check is live, never frozen into the key. The holder also needs Use the API and MCP and the relevant live Pages permission.
These examples describe request contracts. Deployment and account exposure are separate: an environment may still refuse a tool while its rollout or exposure switch is closed. Hosted sites use a separate policy, grant, and credential flow for AI and retrieval operations; see Managed capabilities for Pages.

Read a site

Call page.read before replacing, previewing, or publishing a site. It returns the newest built revision separately from the currently published revision. It also returns the site’s canonical board_id; use that identifier for board and Data operations instead of matching a board by its display title.

Import or replace a site archive

POST https://app.goosybear.ai/api/pages/ingest accepts multipart/form-data. Send exactly one ZIP archive as file. The other three fields are optional text fields:
  • Omit siteId to create a site. name supplies its display name.
  • Send siteId to replace the managed source or staged bundle on that same site. name is ignored in this case. workspace is a QUERY parameter on this endpoint, not a form field — ?workspace=<slug>. It is the same argument every tool on this page takes, resolved the same way (see Which workspace above); it rides the query string here because this body is your archive, and reading a field out of it would mean decoding up to 40 MB before your key was checked. Send it whenever your key is account-wide. A signed-in browser session ignores it; that workspace comes from the session.
Before you upload, decide where pages.deploy.json goes: at the zip root for already-built output, at public/ for a source project we build. A root-level file in a source project classifies the upload as already-built and skips your build.
The service classifies the archive from its contents. A prebuilt static bundle is staged immediately and returns kind: "bundle" plus revision. A supported source project is stored and queued for the Pages build rail; it returns kind: "source" plus buildRunId:
The full report shape describes what the importer detected and accepted. Always read it. Server routes, unsupported dependencies, unsafe paths, or an invalid bundle can produce a typed refusal rather than a partial deployment. Two files a Cloudflare build always writes are ignored rather than refused — _headers and .assetsignore, matched by name at any depth. Neither could ever be served here, so neither is published and neither is a reason to reject your upload; report.notCarried names each one that was dropped. You do not need to delete them before you zip. See What we bind for you, and what we ignore. Every success also carries deploy — the deploy contract this upload actually resolved to — and deploySource, which says whether it was yours (declared), translated from a legacy pages.functions.json (legacy), or synthesized by us (synthesized). A synthesized manifest is worth reading and committing: it is what we decided on your behalf. What the database bindings in it mean, and what is wired today, is Site and project databases.

Setting a secret’s VALUE

Your bundle names the secrets it reads — bindings.secrets in pages.deploy.json — and never carries a value. A secret is a key you add once, in Goosy: open the site, open its settings drawer, and in Secrets choose Add a key (a name and the key), or Bind an existing key to give the site one of the brand’s keys from the Connections page. Adding or binding one takes the pages.manage capability and an account admin role. There is no customer-API endpoint for this, today. A value is typed only in Goosy — the site’s Secrets band or the Connections page. The Goosy agent’s own page.set_site_secret verb gives a site a key the brand already holds and never takes a value; this API has no door at all, and stating otherwise would be a promise nothing keeps. Three properties are worth knowing before you plan around it:
  • It is write-only, by design. Nothing reads a value back — not this API, not the app, not us. A value you cannot re-read is a value that cannot leak through a support session or a screenshot; keep your own copy.
  • The key lives on the Connections page. Replacing it there sends the new key to every site that uses it at its next publish; removing it there takes it off those sites too. Removing a secret from a site leaves the key itself.
  • The name in the manifest and the name in the drawer must match exactly. A name declared but never set arrives as no binding at all, so your Worker reads undefined rather than failing loudly at publish.
A name you set is bound into your Worker at the next publish as env.<NAME>.

Check an archive before you import it

POST https://app.goosybear.ai/api/pages/ingest/validate takes the same archive the import takes, runs the same checks, and answers all of them at once — without creating a site, storing a byte, or spending a build. Send it the zip you are about to import and you learn every problem in one reply instead of one per attempt. It is the same credential and the same body as the import: one ZIP as file, the optional siteId text field, ?workspace=<slug> on the query string. It is metered as an ordinary read.
Three fields carry the answer:
  • refusals — every problem, not the first. where tells you which stage would have found it (upload, read, build, publish), and code and message are byte-for-byte what the import itself would have replied. An empty list means these checks all passed.
  • measurements — every ceiling your archive could be measured against, beside the number you sent. functions.modules 56/512 is headroom you can see rather than a wall you have not hit yet.
  • screensRun — which checks actually ran. Read it before you treat an empty refusals as a promise: anything we cannot decide without compiling your project — a type error, an integration that throws, an install that fails — is not in this list and is still a build result.
A refused archive comes back as HTTP 200 with data.ok: false. A 400 means the request itself was malformed — not multipart, no file, or a siteId that is not the shape of a site id.

Follow a source build

Use the buildRunId from a source ingest as build_run_id. The site and run must belong together inside the key’s resolved workspace.
status is pending, running, retrying, succeeded, or failed. Immediately after enqueue, pending can mean the durable build record has not appeared yet. Poll with normal backoff until the call returns a terminal state. A successful build returns its revision. A failed build returns why, in failure:
failure is null on every other status. failure.message is the rail’s own customer-facing sentence — read it and fix what it names; it is never a stack trace or an internal run id, and there is nothing further to look up.

Upload a reference image

POST https://app.goosybear.ai/api/pages/{siteId}/assets stores one image and attaches it to the site’s own board. The multipart body accepts exactly one field, file; a caller cannot supply a different board id. workspace is a QUERY parameter here for the same reason it is on the import endpoint — the body is the upload.
Accepted types are PNG, JPEG, WEBP, and GIF. The file must be nonempty and no larger than 40 MB. SVG is not accepted. This endpoint requires an operator role in addition to pages.manage, and page.assets is the read that lists what is already there. POST https://app.goosybear.ai/api/pages/{siteId}/preview-links creates a link for the site. ttlMinutes is optional; the default is 10,080 minutes (seven days), and accepted values are whole minutes from 5 through 43,200. A preview link follows the site, not a frozen revision. While the link is alive it always shows the site’s newest built revision — build again and the same URL shows the new build on the next load. Nothing about the link changes: the URL is the same, the expiry is the same, and revoking it still works. Only a publish moves the live site. revision in the response below is the revision the link was minted at, kept so you can see what it showed at the time.
The token is the preview capability. Store it only where you can protect a credential, keep it out of logs and analytics, and share its URL only with the intended recipient. The response is no-store and no-referrer. Customers do not need Cloudflare credentials; the service owns the preview route. Creating a preview does not publish the site or attach a custom domain. A draft remains a draft. A preview runs your server routes. The link is not a static snapshot: the revision it is showing has its own Worker, so a route your deploy contract declares answers from your handler inside the preview exactly as it does on your live domain — same code, same caps, same bindings. That is what makes “preview, check it, then publish” possible for a site that renders on the server, and it is why you can exercise a form or an API route in a preview before anything is live. Your preview’s backend is your site’s real backend. Its env.DB is the same site database your published site reads and writes, and its secrets are the same secrets — there is one copy of your data, not a preview copy. So a write you make while previewing is a real write, and a migration your revision declares runs against that database when the revision is built. Use a test row, not a customer’s. A preview token stands in for the site password, and for nothing else. Anyone holding the link sees the bytes a visitor with the password would see — that is what the link is for, and why its lifetime is short and it can be revoked. A members-only route is different: the token proves your workspace issued the link, not that the person holding it is a member, so a members route inside a preview asks for a Goosy sign-in exactly as it does on your live site. Share the link the way you would share a credential. DELETE https://app.goosybear.ai/api/pages/{siteId}/preview-links takes the 43-character preview token in a strict JSON body. Keep it out of a query string.
Revocation is idempotent: a missing or foreign token still returns the site id after the key’s workspace boundary has been applied.

Publish one exact revision

page.publish always takes two calls. The first call reads the site’s latest built revision and returns a short-lived confirmation without publishing:
Present the site and revision to the person approving the launch. After they approve, repeat the same call with the returned confirmation_id:
The confirmation is bound to the key holder, tenant, workspace, site, tool, and revision. It expires after 30 minutes and is single-use after a successful publish. If a newer build becomes ready first, the call returns pages.publish_revision_stale, consumes the stale confirmation, and requires a new first call so the newer revision can be reviewed.

Point a domain at it

domain.connect reserves a hostname the customer already owns and starts its certificate. It does not make the domain live — the connection finishes when the records that come back are added at whoever manages that domain’s DNS.
Relay every record. pages.domain_taken means the hostname is already connected somewhere; disconnect it there first or use a different name.

Read where a site answers

page.domains returns every hostname the site holds, each with its state, the three progress steps, and the DNS records still required. It reads the recorded state — it does not poll the certificate authority on your behalf, so call it on a sensible cadence rather than in a loop.
state is waiting_dns, validating, active or failed. The free platform address carries no steps and no records and is always active — it is ours end to end, so there is nothing to wait for and nothing for anyone to add. A custom hostname is the one with a steps list to watch.

List what has been built

page.revisions returns a site’s builds, newest first, with the one currently published flagged. limit defaults to 20 and tops out at 100.
has_functions says whether that build carries a Worker rather than only static bytes. Exactly one row can have published: true.

Put a previous build back

page.rollback publishes one exact prior revision through the same path a launch takes — the same quota, the same address, the same pointer flip — so it also takes two calls. The first publishes nothing:
previous_revision is what the site is serving right now, so the person approving sees both ends of the swap. Repeat the same call with confirmation_id to perform it. The confirmation is bound to both the site and the revision: an approval for one build can never be spent on another. pages.revision_not_found means that revision is not one of this site’s recent builds; pages.revision_already_published means it is the one already live.

Take a page off the board

page.archive retires a page from your Pages board when you are done with it. Nothing is deleted: the build, its revisions and its assets all stay exactly where they are, and one call puts the page back. (The word archive does double duty on this page — the zip you import is also called an archive. This call is about the board, not about bytes.) Like publishing, it takes two calls. The first changes nothing:
The page is NAMED back to you, so whoever approves is deciding about a page they recognise rather than a UUID. Repeat the same call with confirmation_id to perform it:
archived is the direction flag and it goes both ways. Send false — through the same two calls — to put the page back on the board; the second call answers "state": "restored" with "archived_at": null. The confirmation is bound to both the site and the direction: an approval to put a page back can never be spent on taking one off.

A live page is refused

Retiring a page from the board is bookkeeping — it does not stop the hostname serving bytes. A live page whose row said it had been put away would still be answering for anyone holding the link, so the call refuses rather than telling you something that is not true. Take the page off the internet first. Putting a page back (archived: false) is never refused for this reason.

List a site’s assets

page.assets lists the images and files attached to the site’s own board — identity, name, type, size and when it arrived. No download URL is issued: a signed URL is a capability with a lifetime, and a list does not mint one per row. limit defaults to 50 and tops out at 200.

Handle responses

Tool actions return a flattened body such as { "ok": true, ... } and answer HTTP 200 once the tool ran, including typed tool refusals with "ok": false. The ingest, asset, and preview-link endpoints return { "ok": true, "data": ... }; their failures use { "ok": false, "error": { "code", "message" } } and an appropriate HTTP status. A missing or invalid bearer key is 401; access, workspace, role, or Pages permission denial is 403; invalid input is 400; and rate limiting is 429. On 429, wait for Retry-After before trying again. Asset and preview-link responses are no-store and no-referrer. The workspace refusals are the two worth branching on by name: workspace_ambiguous carries the slugs you may pass, and workspace_unreachable means the name is not one your key reaches — or that your key is pinned elsewhere.

Where to go next