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 optionalworkspace 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.
?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
Callpage.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
siteIdto create a site.namesupplies its display name. - Send
siteIdto replace the managed source or staged bundle on that same site.nameis ignored in this case.workspaceis 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.
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.
kind: "bundle" plus revision. A supported
source project is stored and queued for the Pages build rail; it returns
kind: "source" plus buildRunId:
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
undefinedrather than failing loudly at publish.
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.
refusals— every problem, not the first.wheretells you which stage would have found it (upload,read,build,publish), andcodeandmessageare 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/512is headroom you can see rather than a wall you have not hit yet.screensRun— which checks actually ran. Read it before you treat an emptyrefusalsas 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.
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 thebuildRunId 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.
pages.manage, and page.assets is the read that lists what is
already there.
Create a preview link
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.
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.
Revoke a preview link
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.
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:
confirmation_id:
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.
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:
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
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
- The deploy contract — every
pages.deploy.jsonfield. - Site and project databases —
env.DB,env.PROJECT_DB, and what a public page may read. - The Goosy deploy skill — hand this to Claude Code, Codex or Gemini and say “ship it”.
- Recipes — a complete run from a clean directory, transcript included.