This page describes the API path: sending a project with a key, the way
a developer or a coding agent would. See Pages API for the
full call reference and The deploy contract for
every field a project can declare.
What the archive should contain
Send a real project —package.json, your source files, and your lockfile —
zipped from the project root. A few things to leave out:
- Build output and dependency folders.
node_modules,.next,dist, and any local emulator state don’t belong in the archive — they’re rebuilt for you, and including them only makes the upload slower and harder to read. - Your own copies and version-control files. Leave out
.gitand any backup or “original” copies of the project sitting alongside it. - Content. Your database rows and media files are not part of the code archive — see Media and Migrations for a new site below for how those travel separately.
Dependencies: no install scripts, keep the lockfile
Two things make a project buildable here, and both are worth checking before you send it:- No install scripts. Dependencies are installed with lifecycle scripts
turned off, so a package that only finishes setting itself up through a
postinstallstep won’t finish setting itself up. Most everyday packages — UI libraries, routing, forms, styling — need nothing like this. If a dependency in your project does, it needs to come out before you send us the project. - Commit your
pnpm-lock.yaml. We build with pnpm, and we install exactly what that lockfile lists — we never resolve a version off the package registry on your behalf. Keep it committed and current: runpnpm installlocally after any dependency change, so the lockfile reflects whatpackage.jsonasks for. We read onlypnpm-lock.yaml; annpm,yarnorbunlockfile isn’t read at all, so a project that ships one of those instead — or no lockfile — is refused before anything is built, naming exactly what’s missing.
Analytics and consent
Until you switch something on, we add nothing to a site you send us. No tracking script, no cookie banner, nothing written to the visitor’s browser: your site is built and served exactly as it arrived. If your site runs its own analytics (PostHog, Google Analytics, anything else), keep it. Make sure the package is in your project’s own dependencies and lockfile like any other library; see Dependencies above. When you want our consent banner, your connected ad pixels or conversions reported to your ad platforms, switch them on in your site’s settings. See The consent banner, ad pixels and conversions below for what that adds and how your own code works with it.The deploy manifest
Every project we host is read through one file,pages.deploy.json — see
The deploy contract for the complete field
reference. It says which paths your backend answers, and what it needs to be
wired to: a database, your own storage, secrets by name.
You don’t have to write it. If your project doesn’t include one, we look
at what you sent and write one for you, and hand it back on import so you
can see what we decided and commit it if you agree.
One part of it is worth knowing before you write your own: a route can be
marked so that only someone signed in to your workspace can reach it — the
same way /admin is commonly protected — and we enforce that before your
code ever runs. See Members-only
routes.
Migrations for a new site
If your project needs its own database, declare a directory of numbered SQL files in the manifest —0000_create_table.sql, 0001_add_column.sql, and
so on — and they run in that order the first time your project is built.
Migrations are tracked one file at a time, not one statement at a time:
if a file fails partway through, the whole file is offered again on the next
build — so write every statement so it’s safe to run again from the top.
create table if not exists is the simple version of that. insert or ignore only gives you the same safety when the id column itself is
declared primary key or unique — a stable value alone isn’t enough,
because without that declaration there’s nothing for or ignore to conflict
on. Give every seeded row an explicit id of its own (never one your database
assigns automatically) and declare that column primary key or
unique, so a retried file skips a row it already inserted instead of
adding it twice.
Keep each file under 256 KB, aim for 64 files or fewer (past that you get a note, never a refusal), and
keep any single statement well under 100,000 bytes — batch a large seed
across rows rather than one giant statement. The full list of what we check,
and the number beside what you sent, is in the deploy contract’s
ceilings.
If you’re replacing a site you already run with us, an already-applied file
has to match, byte for byte, what’s already been applied there — add new
changes as new numbered files; don’t edit one that already ran. A new
file you add runs against your site’s real, existing database as soon as
your replacement is confirmed and built — before you ever publish (see
Replacing a site you already run with us
below), so review a schema change the same way you’d review any change to
a live database.
Media
There are two kinds of media, and they travel differently:- Files your pages serve directly — images and assets your code references by path — ship inside the archive’s static folder. They’re classified as ordinary site files and go live with your first publish.
- Files your backend reads from your own storage are different. Your storage is created the first time a build that declares it is staged — before you publish — and it starts out empty. There’s no bulk import for existing media today. For a brand-new site, the honest path is to add files through your site’s own upload flow once it’s live, or ask us about a one-time bulk load.
Check it, then ship it
Start with the same first step either way, then the path splits depending on whether this site has a code project already. Validate first, always. Send your archive to be checked without creating anything — every problem is named in one pass, rather than one at a time across repeated attempts. How big an archive can be depends on how you send it. Uploaded in the app, an archive may be up to 40 MB. Posted straight to the API (POST /api/pages/ingest/validate or POST /api/pages/ingest), it must stay
under about 4.5 MB — our API host refuses a larger request body with a bare
413 before it reaches us, and 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 call the
page.ingest_bundle tool with a public https:// link (see Import from an
address). See what you
upload.
Bringing a new site
With no existing code project for this site, sending your archive creates the site and starts the build immediately — there’s no diff to review and nothing to confirm, because nothing is being replaced.- Import. Creates the site and starts the build.
- Preview. See below — preview is live-bound, not read-only.
- Publish. Moves your live address to the build you just previewed — one exact version, reviewed before anything changes.
Replacing a site you already run with us
Sending an archive to a site that already has a code project never overwrites it outright — you see exactly what would change first, and nothing is written until you say so.-
Import — see the diff first. The response tells you nothing was
written, and carries a handle together with a diff of what would
change if you went ahead:
The diff names only code-project files — never your storage, your media, your domain, or anything else that makes the site yours. Your database is the one exception, and the rule is exact: a replacement whose migrations are byte-identical to what’s already applied changes no data; a replacement that carries new migration files applies them to your live database at that same build, before you ever publish — see Migrations for a new site above.
-
Confirm — resend the identical archive, with the handle on the URL.
Send the exact same bytes to the exact same endpoint, adding the handle
from step 1 as a query parameter:
Same
file, samesiteId— only the query string changes. That call is the one that actually writes: it answers with an"outcome": "ingested"body and starts the build. - New migrations run against your real database, at that same build — before you ever publish. If your replacement adds new migration files, this is where they run; see Migrations for a new site above for what makes a migration safe to send this way.
- Preview. See below.
- Publish. Moves your live address to the build you just previewed.
Preview is live-bound, not read-only
Your preview link is not a snapshot. The moment your project finishes its build, its database and storage are already the same ones your published site uses — there’s no separate copy — so a preview and a live site share one backend from the start. That makes preview a faithful check before you publish, but it also means a write you make while previewing is a real write: don’t submit a form, upload media, or run an admin action against a preview of a site you don’t want to change yet.Republish to roll back
Every build you’ve shipped stays available, and putting a previous one back live is the same act as publishing — you’re just choosing an earlier build. Rolling back reaches your 100 most recent builds; an older one isn’t offered as a rollback target.The consent banner, ad pixels and conversions
Three settings in your site’s settings, under Tracking, or have Goosy change them:- Consent banner. Our banner becomes the one consent source on your site, and your own code can read its decision.
- Ad pixels. The pixels of the ad accounts you’ve connected load on your site. They need the banner on.
- Ask before tracking. On (the default), nothing that needs consent loads until a visitor accepts. Off, analytics and your ad pixels load when a visitor arrives, and the banner is a notice that lets them opt out. Either way, a browser that sends Global Privacy Control is treated as opted out, and so is a visitor who opted out before.
What your HTML needs
- Every page has a real
<head>element. We add two script tags,<script src="/__goosy/collector.v1.js">then<script src="/__platform.js">, as its first children; a page without a<head>tag gets nothing. - No second cookie banner. With ours on, remove yours.
- If you send a Content Security Policy, it has to allow our script and the
hosts of the ad pixels you’ve switched on. Our scripts are served from your
own site, so
script-src 'self'covers them. CSPs that use'strict-dynamic'aren’t supported yet. window.goosyand thers-page-consentstorage key are ours. Don’t define the first, and don’t read or write the second.
Reading the visitor’s decision
window.goosy exists only while our banner is on for your site.
Don’t start anything that needs consent unless the state is
"granted".
Here’s PostHog gated on it:
Reporting a lead your server sends us as a conversion
When your server forwards a lead with your site key (see From your Worker, with your site key), it can carry the visitor’s consent, so the lead is reported to your connected ad platforms as a conversion. Three steps:- In the browser, before you call your server, take
window.goosy.leadFields(). It returns{ consent, eventId, fbp, fbc, fields }.eventIdis a fresh id when the visitor has granted consent, andnullotherwise.fieldsholds the same values under the names our intake reads (rs_meta_event_id,rs_meta_fbp,rs_meta_fbc,rs_page_path, andrs_attributionfor the visitor’s first ad click and campaign tags). - On your server, add
fieldsto the lead’s ownfieldsunchanged, and send your page’shostnamebeside them. - Back in the browser, once your server says the lead was accepted, call
window.goosy.trackLead(eventId)with theeventIdour intake answered. That fires the browserLeadon each connected platform with the same id as our server-side event, so each platform counts the conversion once. It returnstruewhen it fired.
- We answer an
eventIdonly when the lead becomes a conversion: the visitor granted consent, the form is one your site counts as a conversion (every form, until you name some in site settings), and the address isn’t a test address. Otherwise it isnull, andtrackLeadfires nothing. - A value in one of those fields that isn’t in the shape
leadFields()produces is refused with400 {"error":"invalid_field","fields":[…]}, and nothing is stored. Pass them through unchanged. - The consent is your visitor’s claim, as on any web form. We treat a lead
without an
eventIdas not consented and report nothing for it.
Where to go next
- Pages API — the full call reference for each step above.
- The deploy contract — every
pages.deploy.jsonfield, and the complete ceilings list. - Site and project databases — how your code reads its database once it’s live.
- Recipes — a complete run, from an empty folder to a live site.