Skip to main content
Every bundle you send to Goosy is read through one file: 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

The machine-readable schema is 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:
This works whether you sent an already-built bundle or a source project we build. In a source project you declare 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.
It is what makes a framework’s own asset handler work unchanged: Next’s image optimizer and Astro’s asset routes both reach for 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 compatibilityDate is refused; if your bundle was compiled against a newer date, declare it in runtime.compatibilityDate (or ship the Wrangler config that says so) and it moves the pin forward only;
  • the ceilings — limits is 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.
Your bundle is a folder of files with index.html at its top level. Zip the folder’s contents, not the folder.

A Worker bundle

Zip that tree — pages.deploy.json, _worker.js, assets/, migrations/ — and send it:
A bundle shaped this way needs no 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:
Your own configuration is never rewritten. For Astro it is copied verbatim to 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 your pnpm-lock.yaml does not corroborate. A project missing its adapter is refused before anything is installed, naming exactly what to add:
The adapters, and the versions we build against: 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 carrying astro.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:
It is a note, not a refusal — the site publishes and works. We read the names of your 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:
The widget writes the token into a field named 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:
Which site the lead belongs to is not something you send. We resolve it from the key — account, workspace and site, all three, server-side. A 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.
The value must be a short identifier — lowercase letters, digits, _ 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:
  • kind is prebuilt (a bundle with a manifest), bundle (a plain static folder) or source (a project we build).
  • deploySource is declared (your file), legacy (your pages.functions.json, translated) or synthesized (ours).
  • deploy is the manifest as we resolved it — defaults filled in, entry rewritten to where it was actually staged (under _functions/), and bindings.database.stagedMigrationsPrefix added when you declared migrations. Your own bindings.database.migrations comes 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 with siteId 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:
Nothing has been written. Read the diff, then send the same zip again with the handle on the query string to apply it:
That call answers 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):
Four things to know:
  • 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. name is the identifier your own module reads (env.<name>), and it may not be DB, ASSETS, GOOSY_API_KEY or GOOSY_API_BASE, nor any name you also declared in bindings.secrets — those already arrive on the same env, 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 name and your next publish binds the same store under the new name; remove bindings.kv and 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.
Reach for it when a value is small, read hot, and fine to lose — a session, a counter, a rate limit, a cache. Anything you want to query, join or report on belongs in 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.