pages.deploy.json. It says
whether the site is static or has a backend, which paths that backend answers, and what it
asks to be wired to.
You never have to write it. If the file is absent we derive one from what the bundle carries
and hand it back in the ingest response, so you can see what we decided and commit it if you
agree.
What is live today: the contract is validated on every upload — a bundle is
accepted or refused against the shape below, and the manifest we resolved comes back on the
response — and the
DB, KV, BUCKET, IMAGES and submissions bindings are wired.
env.DB and env.KV are real in your Worker in your preview and staging sites, where
your migrations are applied when a revision is staged; both stores open in production with
the platform’s release. A declared object store is created and bound, the image-transform
binding is attached, and a post to /__lead is mirrored into the table you name.
PROJECT_DB is the exception: it is declared and recorded today, and its binding
arrives with the project-database stage.Where the file goes
Your Worker modules follow the same rule:
_functions/ in an already-built bundle,
public/_functions/ in a source project. An already-built bundle may also put its entry at
the root (_worker.js — what the Cloudflare adapters emit); we relocate it under
_functions/ when we stage it.
Four paths are reserved, and that list is closed. These are the only objects in a staged
bundle a visitor can never fetch:
Everything else in the bundle is servable — including any other
_-prefixed directory you
ship. We do not reserve a prefix pattern; we reserve those four paths and relocate into
them, which is why your migrations move and your pages never do.
Your declared migrations get the same treatment, on both routes in. Name the directory
whatever suits your project — migrations, db/sql — and we move it to _migrations/ as
we stage the bundle. That is what makes “staged, never served” true whether you uploaded a
built bundle or handed us a source project to build.
Your own field is never rewritten. bindings.database.migrations comes back normalized —
a trailing slash is removed, so "migrations/" reads back as "migrations" — and otherwise
untouched. Where the files ended up is recorded separately, in
bindings.database.stagedMigrationsPrefix (always _migrations/), which the platform writes
after staging and reads when it applies them. Do not set it yourself — a bundle that arrives
carrying it is refused, because it would tell us to read your schema from a directory we
never staged.
Where we look for it depends on which route you took, and it is the same place the
route puts everything else. In an already-built bundle the path is relative to the bundle
root. In a source project it is relative to public/ — "migrations": "migrations"
means public/migrations/, exactly as entry means public/_functions/…. A directory
outside public/ is not part of what the toolchain copies into your build output, so it is
neither built nor served, and we do not move it.
Directories you do not declare stay exactly where you put them, including an ordinary
migrations/ page you publish on purpose.
The shape
PagesDeployManifest in
the OpenAPI document. It is generated from
the same definition the platform validates against, so it is never out of date.
Two ceilings on the Worker itself
These are not fields — there is nothing to declare — but a bundle is refused on either, so they are worth knowing before you ship. They apply to the files that make up your Worker: the ones you put under_functions/ in an already-built bundle, or the ones we emit
when we build a source project.
In a source project you do not control either number — the modules are our compiler’s
output, not your files. If a build is refused on one of these, it is a bug on our side; send
us the build id.
Members-only routes
Put"access": "members" on a route and the platform gates it for you:
routes and leave entry out — the module does not exist until
we compile it — and the access you wrote travels with each pattern into the route table we
serve.
A static site needs no Worker for this. A manifest with no entry may declare routes
whose access is members and nothing else — the platform gates the static files under each
pattern itself. (A route with no access, or "public", still names something only a Worker
can answer, so it still needs an entry.) If you declare only members routes on a project
we compile into a Worker, we keep our own /* in front of them, so the rest of your site is
served exactly as before.
Every spelling of a path is one route. An exact pattern /team gates /team, /team/,
/team/index.html and /team.html alike, and /admin/* gates /admin.html as well as
/admin and everything under it.
Making one page private without editing this file. A member can switch a page to
members-only from its tile on the site’s Board, or by having Goosy do it, and your tooling can do the
same through the API: page.set_access (propose, then confirm) takes the page’s
path and members or public. Each writes this one rule into your pages.deploy.json —
touching nothing else in the file — and a live site rebuilds and publishes itself with it. A
route pattern such as /admin/* is still edited here, in the file.
What “member” means: somebody signed in to Goosy who belongs to the workspace this site
lives in. Nothing else. Roles inside your backend stay yours — we answer “is this a person
who may open this site’s private area”, and your code answers everything after that.
What happens on a request to a gated route. The platform decides before your Worker is
invoked, and before any static file under that pattern is served:
The identity your code can trust. On a
members route — and only there — we set three
request headers from the verified session:
The whole
x-goosy- namespace is stripped from every inbound request before any routing
decision, so a header that reaches your code is one we put there. You do not need to
re-check it, and a visitor cannot forge it.
A members-only image through next/image. On a Next site, /_next/image?url=/admin/avatar.png
reads a members source inside your Worker. A visitor with no session gets a 404 that no
cache keeps; a signed-in member gets the image, and your handler for /admin/avatar.png
receives the same three headers it receives when the member opens that path directly.
Signing out. GET /__unlock?clear=1 clears the site session; add &__next=/ to choose
where to land. The session lasts 12 hours.
There are no passwords, invites or session tables in your site. If your backend used to
have its own login, delete it — this replaces it.
The gate holds inside a preview too. A preview link
runs the revision’s own Worker, so a members route in a preview asks for a Goosy sign-in
exactly as it does on your live domain. The preview token proves your workspace issued the
link; it does not make its holder a member.
What we bind for you, and what we ignore
Two things happen to an already-built bundle that you do not declare and cannot switch off.env.ASSETS is attached — your Worker can read its own files
Every Worker we publish that ships static files gets the platform’s static-asset Fetcher
bound as env.ASSETS, beside your own bindings. You do not declare it and you cannot rename
it — ASSETS is reserved for exactly this, which is why a bindings.storage.name or
bindings.kv.name of ASSETS is refused.
env.ASSETS, and both find it. A revision that ships
no static files gets no binding, because there would be nothing behind it.
Two files from a Cloudflare build are ignored
A Cloudflare build always writes two files that this platform does not use. We drop them as we stage the bundle — they are never published, and they are never a reason to refuse your upload. The import report lists them so you can see what was left out.
Both are matched by name, at any depth —
assets/_headers, dist/client/_headers and a
top-level _headers are all ignored. You do not need to delete them before you zip.
What the platform decides, and will not let you set
Three things are ours, and a manifest that sets one is refused by name rather than ignored:- the script name — derived from your site id, so one site can never claim another’s name;
- the compatibility date — pinned by the platform, so the Workers runtime cannot change
your site’s behaviour without a rebuild. A top-level
compatibilityDateis refused; if your bundle was compiled against a newer date, declare it inruntime.compatibilityDate(or ship the Wrangler config that says so) and it moves the pin forward only; - the ceilings —
limitsis a request; asking for more than the ceiling is refused with the number, never quietly clamped to it.
mainModule is the old spelling of entry. A file that still uses it is accepted as the
legacy pages.functions.json; in pages.deploy.json, use entry.
A static site
The smallest honest manifest. You can also ship no manifest at all and we will synthesize exactly this.index.html at its top level. Zip the folder’s
contents, not the folder.
A Worker bundle
pages.deploy.json, _worker.js, assets/, migrations/ — and send it:
index.html at its root: your Worker answers /.
The manifest above is validated, recorded and staged, and the bindings it declares are
injected: env.DB is your database, a declared secret whose value is set on the site is
there, and form posts are mirrored into your submissions table. env.PROJECT_DB is the
one exception — declare it now if you will want it; it is bound by the project-database
stage.
Bring your own app
You do not have to build anything yourself. Send us the source project and we run its framework’s own Cloudflare build for you, then map the output onto the contract above.
In every case your static files land at the bundle root and your server code lands under
_functions/, which is never served to a visitor.
What we change, and how you see it
If your project needs a framework adapter and does not have one configured, we add it — deterministically, with no model involved — and the build result lists every file we touched:astro.config.base.mjs and the file at astro.config.mjs becomes a small wrapper that
imports it and sets exactly two things — output and adapter. Everything else you
configured — integrations, Vite plugins, site, base, image settings — passes through
untouched. Delete the wrapper and rename your file back to undo it.
For Next.js we add open-next.config.ts and wrangler.jsonc, and point your build script
at opennextjs-cloudflare build. Nothing else in your package.json changes. Your own
next build is not lost — the adapter runs it, because open-next.config.ts carries
buildCommand: "next build". That split is deliberate and load-bearing: the adapter builds
your app by running your package’s build script, so a build script that also named the
adapter would re-enter it forever.
If you had already wired the adapter yourself, we change nothing and changes comes back
empty. The same is true of a second build of a project we already conformed.
The one thing you have to do yourself
Declare the adapter as a dependency. We install from your lockfile alone and never resolve versions off the registry at build time, so we cannot add a package yourpnpm-lock.yaml does not corroborate. A project missing its adapter is refused before
anything is installed, naming exactly what to add:
A source project also needs a
pnpm-lock.yaml. A package-lock.json or a yarn.lock is
not one, and a project shipping only those is refused by name.
If both configs are present
A project carryingastro.config.* and next.config.* at its top level is refused
naming both files. We will not guess which one you meant and spend a build finding out.
A Vite app with an external backend
The Lovable and Bolt shape — a React app in the browser talking to Supabase or your own API. It ingests as an ordinary static site, your backend stays exactly where it is, and your secrets stay yours. We add one note to the build result:VITE_* variables to notice the pattern; we never read or store their values.
Calling the platform from your Worker — env.GOOSY_API_BASE
Your Worker reaches Goosy on your own site’s origin, at a reserved path we answer above
your code:
env.GOOSY_API_BASE is bound on every publish and is your site’s own origin plus /__api.
Read it; never hard-code a host. You do not need to know which Goosy this site is
published on, and you never have to change anything between a preview, a staging copy and
production — the address is the same one your visitors already use, so it works identically
everywhere.
/__api is ours, exactly as /__lead is: it is answered above your code, so you write
no handler for it, and a route pattern of /* does not swallow it. It is answered on a
password-protected site too — your Worker holds no visitor session, and your site key is
what authorizes the call.
What we add, and what we do not. We forward your request to the platform with your
Authorization header exactly as you sent it — your site key stays the only credential,
and we attach no identity of our own. Method and body are preserved. We add only the
infrastructure header that gets the request through our own front door, and it never appears
in the answer you receive.
It forwards two paths and no others. Anything else is refused
403 {"error":"api_path_not_allowed"} without leaving your origin:
The account-level deploy API — importing a bundle, uploading an asset, publishing — is not
reachable here. That is your account key’s surface and it is called from your own machine or
CI, never from a published site’s runtime.
The other answers.
401 {"error":"api_key_required"} — you sent no
Authorization: Bearer header. 405 with an allow header — the path is forwardable but
not with that method. 429 {"error":"rate_limited"} — this site is calling faster than it
may. 502 {"error":"api_unreachable"} — we could not reach the platform; retry.
413 {"error":"payload_too_large"} — the request body ceiling is 64 KB, and the answer
we will relay back is capped at 1 MB.
Submitting leads from your own Worker
There are two ways in, and which one you want depends on where the lead is when you have it:
Both end in the same place: the same lead row, the same
bindings.submissions mirror, the
same GoHighLevel sync. Pick the one that matches where your code is; nothing stops you from
using both on one site.
From the browser — POST /__lead
Your site posts its forms to POST /__lead on its own origin. That endpoint is ours —
it is answered above your code, so you write no handler, no CORS and no API key for it. It
is answered above your code on a preview link too, so a form you submit inside
/__preview/<token>/… reaches us rather than your /* route.
It is bot-gated with Cloudflare Turnstile,
and there is no ungated path. A submission that carries no challenge token is refused
400 {"error":"verification_failed","reason":"missing_token"}, whoever sent it. So your page
has to render the widget, and for that we give you the key.
The binding — env.PAGES_TURNSTILE_SITE_KEY
Every publish binds your site’s Turnstile site key into your Worker under that name. You
declare nothing; it is there. It is the public half of the pair — the value Cloudflare
intends to appear in page markup, safe to render — and it is the key for whichever widget
your site is currently on, which changes by itself when you connect a custom domain.
Verifying the token is ours, not yours. The matching secret is Goosy’s, minted and
rotated by us; /__lead checks the token with it before anything is stored. You never call
Cloudflare’s siteverify yourself, and neither half of the pair is a key you set — both
names are reserved.
The secret half is never bound into your Worker. env.PAGES_TURNSTILE_SECRET_KEY is
always undefined in your code: the value stays on our side, where /__lead reads it, and
nothing is uploaded with your bundle under that name. PAGES_TURNSTILE_SITE_KEY — the public
half, below — is the only one of the pair that crosses, and it is all a form needs.
A site published on a goosybear.ai platform address and a site on your own connected
domain both work; you do not have to do anything differently, and you never hard-code a key.
The markup
Load Cloudflare’s script, render the widget with your bound key, and let it write its token into the form:siteKey comes from the binding — in an Astro or Next route on Cloudflare:
cf-turnstile-response. A plain <form>
submits it for you. Posting application/json works too — send the same field as a property.
Render no form when the key is missing. The binding is absent only when the site has no
widget configured at all, and a form without a widget is a form every submission is refused
by. Check for it and leave the form out rather than shipping one that cannot succeed.
What we do with the lead
A successful submission answers
202 {"ok":true,"eventId":…}. eventId is a string only when
the visitor accepted tracking and the platform script added its dedup field; it is the id the
server-side conversion carries, so a browser conversion fired with it is counted once. It is
null otherwise. The platform script fires its own browser conversion from this answer, so your
code never needs to.
Fields we read by name
Every field you post is stored under the name you gave it. Five names carry a meaning, and we read them by exactly these spellings and no other:
A phone posted as
mobile_phone, tel or any other name is stored with the lead but never
reaches the contact. Name the control phone.
If the workspace has no GoHighLevel connection, nothing is lost and nothing is notified.
The lead is still captured and still mirrored into your table; it simply never reaches a CRM,
so no CRM automation fires and no email goes out. Notifying anyone is a GoHighLevel workflow,
not something this endpoint does — a site that had its own email notice and removed it should
connect GoHighLevel before it goes live, or it will capture applications in silence.
Keep a submission small: the body ceiling is 16 KB, and a larger one is refused 413.
From your Worker, with your site key
Plenty of sites do not post a form to us at all: the visitor posts to your route, your handler writes your own row first, and only then do you want the lead in Goosy. That code runs in your Worker, where there is no browser and therefore no Turnstile token to carry — so/__lead is closed to it, and this is the path that is open.
Your site key is the authentication, so there is no bot gate on this path. You already
hold the two bindings it needs — the same pair your managed AI calls use, injected on every
publish, never declared by you:
siteId, tenant_id or
workspace_id in your body is not compared against anything; it is never read.
A successful submission answers
202 {"ok":true,"eventId":null} — the same answer, and the
same meaning, as the browser path: the lead is durable, and the mirror and the CRM sync run behind it.
eventId is an id instead of null when the lead carries the visitor’s granted consent and
becomes a conversion; see Reporting a lead your server sends us as a
conversion.
400 {"error":"invalid_field"} names a consent field you passed that is not in the shape our
script produced. 401
means the key was not accepted; 429 means this site is submitting faster than its key
allows and carries how long to wait; 413 is the same 32 KB envelope ceiling.
This is a server-to-server call, so keep it server-side.
env.GOOSY_API_KEY is a
credential — never render it into a page, ship it to a browser, or put it in a client
bundle. If the lead is in a browser, the browser path above is the one that is safe there,
and it needs no key at all.Your form entries in your own database — bindings.submissions
Name a table in bindings.submissions and every post to /__lead lands twice: once in
Goosy, where the leads list and the CRM sync read it, and once as a row in your table,
which nothing but your own code touches. You write no code to make that happen — and you do
create the table, because your site’s migrations own your schema, not us.
The platform writes exactly six columns, always these and only these:
Put this in your own
migrations/ directory, changing only the table name:
Telling your forms apart — form (or form_type)
One /__lead serves every form on your site, so a hidden field is what tells a newsletter
signup from an application. Post it as form; form_type is accepted as an alias, so
a site already sending that spelling needs no change. If a page sends both, form wins.
If form is present but its value is refused, we do not fall back to form_type — we
know which field your page meant, so the lead is stored with form empty rather than under a
name from the other field. An empty form is treated as absent, so the alias behind it still
answers.
_ and -, up to 64
characters. Anything else is refused to null rather than tidied into something
acceptable, because a value we rewrote would be a form name you never declared. Case and
surrounding spaces are the one exception: Contact and contact are the same form. A refused
value never costs you the lead — the submission is stored either way, with form empty.
id text primary key is required. The insert is on conflict(id) do nothing, so a
redelivered submission adds nothing and overwrites nothing — and SQLite refuses the statement
outright if id is not a primary key or unique.
A column we do not write may exist, but it has to accept a row that does not set it: a not null column with no default makes every insert fail. Add columns of your own as nullable, or
give them a default.
The mirror is best-effort and never costs you a lead. If the table is missing, has the
wrong shape, or the site has no database yet, the lead is still stored by Goosy and the form
still answers the visitor normally; the attempt is recorded against the lead with the reason.
Nothing is retried, so a fix to your schema applies to the next submission, not to past ones —
the leads API is where past leads live either way.
What the response tells you
Every successful ingest answers with the contract it resolved to:kindisprebuilt(a bundle with a manifest),bundle(a plain static folder) orsource(a project we build).deploySourceisdeclared(your file),legacy(yourpages.functions.json, translated) orsynthesized(ours).deployis the manifest as we resolved it — defaults filled in,entryrewritten to where it was actually staged (under_functions/), andbindings.database.stagedMigrationsPrefixadded when you declared migrations. Your ownbindings.database.migrationscomes back normalized (trailing slash removed) and otherwise unchanged. Read the response rather than assuming your file was taken verbatim.
Replacing a site you already deployed
Send the same call withsiteId and the platform shows you the diff before it replaces
anything. When the site already holds a code project, the import answers 202 instead of
200:
200 with the ordinary outcome: "ingested" body. The handle commits to
those bytes against that revision: a different zip, or a site whose current project moved in
between (someone else pushed, or an edit was checkpointed in the app), is refused
confirmation_stale — push again without confirm to see the new diff. A zip that is
byte-identical to the current project answers 200 with outcome: "unchanged" and writes
nothing.
What a push replaces, and what it never touches. A confirmed push replaces the code
project — your files, your Worker, your manifest, and it applies any new migrations you
declared. The site’s database, its storage bucket, the media you placed on its board, its data
tables, its domains and its keys are project assets: a push never reads or writes them, and a
push cannot drop a table or an object.
Pull the code back down. GET /api/pages/{siteId}/export with the same key streams the
site’s code project as the zip it would build from — for a bundle you brought, the newest
staged revision with the platform’s own bookkeeping left out and its migration relocation
undone, so the zip re-imports as it is; for a site built here, its current source tree. The
X-Goosy-Export-Ref header names the revision the zip is byte-identical to. Nothing is stored
and nothing is metered; the download is recorded on the audit ledger.
When a bundle is refused
Every refusal names the field, and a ceiling refusal names the number. Nothing is written when a bundle is refused — fix the file and send it again.Your own key-value store — bindings.kv
Declare bindings.kv and your first publish creates a key-value namespace that belongs to
this site alone, bound into your Worker as env.KV (or whatever name you chose):
- The store is yours and nobody else’s. One namespace per site — never shared with another site, another brand, or the platform. Your code is its only reader and its only writer; we create it, bind it, and never open it.
- The NAME is yours; the store is ours to name.
nameis the identifier your own module reads (env.<name>), and it may not beDB,ASSETS,GOOSY_API_KEYorGOOSY_API_BASE, nor any name you also declared inbindings.secrets— those already arrive on the sameenv, and your Worker would get whichever one landed last. - The store lives as long as the site, and nothing survives an erasure. There is no delete-the-store and no delete-the-site flow to trip over: the namespace stays put for the life of the site. When your account’s data is erased, the store and your site’s database go in the same pass — so neither is the place to keep something you need to outlive that. There is no separate copy to recover from, by design. Export anything you need to keep.
- Renaming or dropping the binding never touches your keys. Change
nameand your next publish binds the same store under the new name; removebindings.kvand the binding stops arriving — the store and its keys stay, and declaring it again picks them up. A store you stopped declaring is still yours, and it still goes in the erasure above.
env.DB.
Ceilings
Every number the platform will refuse you on, in one place. Nothing here is a surprise at publish time: each one is checked at the stage named, and every refusal says the ceiling and the number you sent. You do not have to discover these one upload at a time.POST /api/pages/ingest/validate
takes the archive you are about to import, runs every check on this page against
it, and answers the whole list at once — each ceiling with the number you
actually sent beside it, and no build spent. Where a ceiling below has no row in
that reply, it is one we cannot measure until your project is compiled.
“Yours?” is the column worth reading first. Where it says yes, the number counts
something you wrote and you can change it. Where it says no, the number counts something
we produced from your project — a compiled Worker, the objects our build emitted — and
hitting it is a bug on our side, not a file for you to trim. Send us the build id and we will
either fix the build or raise the ceiling.
What you declare
A manifest sitting at every one of these at once still fits inside the 128 KiB — that is
how the 128 KiB was chosen, and it is pinned by a test so it stays true if any of the others
moves.
What you upload
The path ceiling is not ours: a file’s path becomes part of a storage key, and the storage
layer’s own key limit is 1,024 bytes. A longer path would unpack and then fail mid-publish,
so it is refused at the door instead.
The API door’s ceiling is not ours either. Our API host refuses a request body over about
4.5 MB before it reaches us, so a larger archive posted to the validate or ingest endpoint
gets a bare
413 rather than our own typed upload_too_large. POST /api/pages/ingest 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.
What we build and publish
For an already-built bundle the first two rows are yours — the objects are the files you
zipped. For a source project they are not: we compile them, so a build refused on either is
something for us to fix.
The Worker’s own two ceilings — its uncompressed size and its module count — have their own
section above, and in a source project neither is yours either.
Limits that are Cloudflare’s, not ours
These we do not screen, because they land on the contents of a file rather than on its shape. If you hit one, the error you get back is Cloudflare’s own, passed through with the file named:
A migration file of many ordinary statements is nowhere near the first row; one enormous
generated
insert can exceed it inside a file well under our 256 KiB. We do not split your
SQL to find out — a splitter that guesses wrong inside a trigger body is wrong
irrecoverably — so that one is D1’s to enforce and ours to report faithfully.
Related
- Your own object store —
env.BUCKET,env.IMAGES, and an Astro route that uses both. - Pages API — creating sites, building, publishing, domains.
- Recipes — a full run from an empty directory to a live site.
- Errors and refusals — the shared error envelope.