# deploy.withzero.xyz — deploy a full Node app (backend + Postgres + frontend), paid in USDC You (an agent) built a Node app that runs on localhost with a real database. This capability HOSTS it: upload the source, pay a small hosting term in USDC, and get back a live HTTPS URL with a dedicated Postgres. The app can then call OTHER Zero capabilities (image gen, news, scraping, LLMs, on-chain, …) and pay for them out of its own prepaid credits — no wallet ever lives inside the app. This is a FULL Node runtime on Cloud Run: any npm package including native modules, app.listen(process.env.PORT), a writable filesystem (ephemeral — see RUNTIME CONTRACT), a normal DATABASE_URL Postgres connection, backend + static frontend in one service. (For edge-only apps — a bare fetch handler, no TCP, SQLite/D1 — use app.withzero.ai instead. Use THIS when you have a normal Node app with a real database, native deps, heavy compute, or you just don't want to rewrite it.) If you discovered only one of the endpoints below, read this whole file: the capability is the whole lifecycle, not a single call. ==================================================================== IDENTITY & PAYMENT MODEL (read first) ==================================================================== There is no signup. You arrive on one of two PURCHASING CHANNELS, and either one identifies you and owns the apps you create: • DIRECT RAIL — your USDC wallet (MPP or x402; both prove the same wallet). • ZEROCLICK RAIL — buying through the ZeroClick marketplace, identified by your marketplace agent id. Apps and credits are keyed to your buyer ACCOUNT, which either channel resolves to. If you have used BOTH channels, link them (BUYER CHANNEL LINKING below) so they reach the same apps. Responses to create/renew include billing_rail ("direct" | "zeroclick" | "key") telling you which rail the request rode, and a replayed flag on idempotent replays. There IS an optional API KEY, and it is neither a signup nor a third identity. A buyer who has proved a WALLET can mint one (dpl_…), then send it as an ordinary Authorization: Bearer instead of signing every request. The key acts as the same buyer account that minted it, draws a prepaid ACCOUNT balance instead of answering a 402 per call, and is revocable. Full lifecycle in API KEYS below. Nothing else in this file needs one — if you already sign, you can keep signing and ignore that section entirely. Two kinds of money move, from two different mechanisms: 1) HOSTING TERM — on the DIRECT rail: a charge-intent (MPP/x402) on create + renew. A fixed fee buys one term (a fixed number of days). Dynamic-price route: you DECLARE the amount and it must equal the current rate. • Send header X-Pay-Amount-Micros: (1 USDC = 1_000_000 micros) • Send the SAME value in the JSON body field named for the route (term_fee_micros for create/renew, credit_micros for credits). • The 402 challenge is issued for exactly that amount; pay it with your wallet and retry. The server ENFORCES header == body == rate, so a wrong amount is a 4xx, never a silent over/under-charge. On the ZEROCLICK rail: for create/renew, OMIT term_fee_micros and the header — ZeroClick's app-terms usage ledger is authoritative and bills you there. Credits top-ups always use the direct charge mechanics above. With an API KEY: OMIT both as well. No 402 is issued; the current rate is drawn from your prepaid ACCOUNT balance and the response reports billing_rail: "key". Keyed create/renew instead require an Idempotency-Key header — see API KEYS. 2) PREPAID CREDITS — a balance you top up that funds your app's OUTBOUND calls to other Zero capabilities. Each outbound call reserves up to a max price, pays the sibling's real 402, and settles the actual cost against your credits (the remainder is released). This is how a hosted app pays for image/news/LLM/etc. without holding a key. This balance comes in TWO SCOPES and each app is permanently on exactly one: PER-APP credits (POST /api/v1/apps/:id/credits) for an app created by a signing buyer, or one shared ACCOUNT purse (POST /api/v1/credits) for an app created with an API key. GET /api/v1/apps/:id reports which, in "purse". See TWO BALANCES under API KEYS — do not guess, and do not assume they pool. Owner-only routes (read/manage an app) take EITHER a signed PROOF of your channel identity (wallet proof on the direct rail; marketplace identity on ZeroClick) OR an API-key bearer — no funds move on either, but you must establish that you are the buyer on record. Authoritative pricing is always GET /api/v1/rates (below). Do not hardcode. ==================================================================== DISCOVERY (free, no auth) ==================================================================== - GET /api/v1/rates → { termFeeMicros, buildFeeMicros, termDays, perProxyCallMicros, creditsTopUpCapMicros } all micro-USDC strings. Read this BEFORE any paid call to size X-Pay-Amount-Micros. - GET /openapi.json → full request/response schemas for every route. - GET /health → liveness. ==================================================================== PACKAGING YOUR APP (the "source" field) ==================================================================== "source" is a base64-encoded ZIP of your app directory. Rules the upload validator enforces AT ACCEPT (violations are an immediate 400 VALIDATION_ERROR, before any build starts): - It must be a real ZIP archive (not tar/gz). - package.json MUST be at the ARCHIVE ROOT — not nested in a subdirectory. A manifest at app/package.json is rejected (no_package_json). Zip the CONTENTS of your app dir, not the dir itself. - Limits: <= 2000 files, <= 50 MiB total uncompressed. No absolute paths or ".." entries (path_traversal → rejected). - SIZE OF THE UPLOAD ITSELF: keep the base64 "source" under ~10 MiB of zip. The hard ceiling is ~24 MiB (a 32 MiB request body), above which the request dies at the edge with a bare 413 that carries no error code from us. Between ~10 and ~24 MiB it will usually work but can fail under concurrent load. DO NOT put video, audio, model weights, datasets or large images in the zip — see LARGE FILES below, which has no practical size limit. - package.json depending on sqlite3 / better-sqlite3 is rejected with 400 { error: { code: "SQLITE_NOT_DURABLE" } }. The filesystem is EPHEMERAL (see RUNTIME CONTRACT) — a SQLite file WILL be lost on scale-to-zero, so use the injected DATABASE_URL Postgres for durable state. To deploy anyway (throwaway/demo data ONLY), pass ackEphemeral: true in the create/redeploy body. On create this fires BEFORE the 402 — you are never charged for it. Rules that only fail LATER, at build time (the create is accepted and charged; the app then lands in status "failed" — read /logs for why): - package.json must have a "start" script, and a committed package-lock.json is how the build installs your exact deps. - Do NOT include node_modules — it bloats the upload past the ~10 MiB practical limit above and the build ignores it anyway. The build runs Google Cloud Native Buildpacks (no Dockerfile needed): it auto-detects the Node app and installs deps from your lockfile. Native modules are supported. - YOUR APP MUST ANSWER HTTP ON `/`. After deploying we fetch it, and the deploy only becomes "live" once it responds with a status below 500. Any status under 500 counts — 200, a redirect, even 404 — so an app that only serves /api/... is fine. What fails is an app that never answers: the classic cause is binding PORT and then crashing (e.g. throwing on a database connection at startup), which looks healthy to the platform but returns 500 to every caller. Connect lazily, or retry, rather than throwing at boot. A create whose app never answers is marked "failed" and REFUNDED rather than billed for a dead app; a REDEPLOY that never answers leaves your existing live version untouched and running. ==================================================================== LARGE FILES — video, audio, model weights, datasets ==================================================================== Anything big goes into the app's durable /data volume, NOT into the source zip. The bytes never pass through this API, so there is no meaningful size limit (GCS accepts up to 5 TiB per object; your per-app storage cap applies). Three steps: 1. Ask for an upload URL (free, owner-authenticated): POST /api/v1/apps//assets { "path": "media/intro.mp4", "contentType": "video/mp4" } → { uploadUrl, method: "PUT", headers: {...}, dataPath, maxBytes, expiresAt } 2. PUT the raw bytes to uploadUrl, sending the returned `headers` EXACTLY as given. They are signed into the URL — change or drop one and GCS rejects the upload. Send the file raw: not multipart, not base64, no form encoding. curl -X PUT "$uploadUrl" \ -H "content-type: video/mp4" \ -H "x-goog-content-length-range: 0,10737418240" \ --data-binary @intro.mp4 3. Read it from the running app at the returned `dataPath`, building the path from process.env.ZERO_DATA_DIR rather than hardcoding /data: const file = process.env.ZERO_DATA_DIR + "/media/intro.mp4"; Notes that will otherwise cost you an hour: - The /data mount caches metadata for about a minute. A file you just PUT can take up to ~60s to appear to the running app. This is normal; retry rather than re-uploading. - Re-PUT the same path to replace a file. There is no separate update call. - The URL expires (default 15 min, max 60). Mint a new one rather than reusing. - You can upload BEFORE the app's first successful deploy — staging assets ahead of the code is fine, as long as the app exists. - /data storage is metered nightly and is subject to a per-app cap. When an app goes over cap (or its balance goes negative) /data flips read-only and this route returns 409 until you free space or top up. ==================================================================== CREATE & FUND (paid — charge-intent; see PAYMENT MODEL #1) ==================================================================== - POST /api/v1/apps Body: { source: "", term_fee_micros: "", env?: { KEY: "val", ... }, slug?: "", ackEphemeral?: true /* required iff package.json has a sqlite dep */ } Header: X-Pay-Amount-Micros: (== term_fee_micros == termFeeMicros) (fee body field + header are DIRECT rail only — omit both via ZeroClick) → 202 { appId, slug, url: null, status: "building", billing_rail, replayed } Pays one hosting term and queues the build. url is null on accept — poll GET /api/v1/apps/:id for the real serving URL once it goes live. DATABASE_URL / PORT / ZERO_* are injected at deploy — do NOT set them in env. slug: omit it for a unique generated id (recommended); a custom slug must be globally unique. A taken slug is a 409 { error: { code: "SLUG_TAKEN" } } issued BEFORE the 402 — you are never charged for a slug conflict. - POST /api/v1/apps/:id/renew Body: { term_fee_micros: "" } + X-Pay-Amount-Micros header. Extends the term. If the app had lapsed to "paused", this also re-queues a deploy from its last source (response includes resumed:true). - POST /api/v1/apps/:id/credits Body: { credit_micros: "" } + X-Pay-Amount-Micros header. Buys prepaid credits for THIS APP's outbound Zero calls. Amount must be within [1, creditsTopUpCapMicros]. → { appId, creditsMicros } (new balance). An app that draws the shared ACCOUNT purse (created with an API key) is refused here — 400 { error: { code: "PURSE_IS_ACCOUNT", details: { top_up: "POST /api/v1/credits" } } }, before any 402 — rather than minting a second balance it could never spend. Fund the named route. ==================================================================== MANAGE (owner-proof or API key — no charge) ==================================================================== These routes require either a signed PROOF that you are the buyer who owns the app (MPP proof intent on the direct rail; marketplace identity via ZeroClick — no funds move either way) or an API-key bearer (API KEYS below). You do NOT construct the proof by hand: standard Zero buyer tooling (the `zero` CLI / SDK) answers the proof challenge automatically with your wallet, the same way it answers a 402. Just call the route as yourself. - GET /api/v1/apps list your apps. - GET /api/v1/apps/:id one app's full state (poll this until live): { appId, slug, status, url|null, termExpiresAt|null (ISO-8601), creditsMicros (micro-USDC string, AVAILABLE balance net of in-flight outbound-call holds), purse ("app" | "account" — WHICH balance that number is, and therefore which route tops it up; see TWO BALANCES), keepWarm, logsUrl, createdAt }. - POST /api/v1/apps/:id/deployments redeploy new source within the term. Body: { source: "", env?: {...}, ackEphemeral?: true (sqlite deps; see PACKAGING) }. → 202. Dedupes against an in-flight build; an idempotent replay returns the original deployment. - GET /api/v1/apps/:id/deployments LIST this app's deployment history (NOT the redeploy route above — same path, different verb): newest first, paginated (?limit=&cursor=), each row { id, status, logsUrl, createdAt }. - POST /api/v1/apps/:id/rollback roll back to a PRIOR deployment from that history. Body: { deploymentId: "" }. → 202, same shape as redeploy. Redeploys that deployment's ALREADY-STORED source — no upload, no new charge. 404 if deploymentId isn't this app's; 409 { code: "NO_ROLLBACK_SOURCE" } if it has none (e.g. failed before any source was stored). - PATCH /api/v1/apps/:id update env / scale (applied on next deploy; see SCALE). - GET /api/v1/apps/:id/logs tail build + runtime logs (build failures show here). - DELETE /api/v1/apps/:id tear down (app + Postgres + service). → 202; GET keeps answering with the pre-delete status until the worker finishes, then 404s. Poll until the 404. SCALE (PATCH /api/v1/apps/:id { scale: {...} }): real Cloud Run knobs — cpu <= "2", memory <= "1Gi", maxInstances 1..10 (all applied, clamped, on the NEXT deploy) — plus keepWarm: true|false, applied IMMEDIATELY when live: - keepWarm keeps ONE instance always warm (minInstances 1) instead of scale-to-zero, killing cold starts. Idle time bills from your PREPAID CREDITS at the idle rates in GET /api/v1/rates (~$0.23/day for the default cpu=1/512Mi shape). Enabling requires a minimum credit balance (else 402 INSUFFICIENT_CREDITS with the required amount in details); it AUTO-DISABLES if your credits run out. Keep the balance funded. METERED RUNTIME: beyond the flat term fee, your app's ACTUAL compute (vCPU-seconds, GiB-seconds, requests — measured hourly from Cloud Run) bills from prepaid credits at the at-cost rates in GET /api/v1/rates, net of a monthly per-app free allowance that covers light/demo traffic entirely. Quiet apps pay ~nothing; busy apps pay what they use. If credits go NEGATIVE the app keeps serving through a grace period (~72h) — top up to clear the debt, or the app is paused (renew/top-up resumes it). Polling: status transitions provisioning → building → deploying → live. Poll GET /api/v1/apps/:id every ~3-5s; TERMINAL states are "live" (url is now set) and "failed" (read /logs for why). "paused" = the term lapsed (renew to resume). A typical build takes ~1-3 minutes. ==================================================================== API KEYS (optional — hold one bearer instead of signing every call) ==================================================================== Signing every request is fully supported and none of this is required. Mint a key when you would rather hold a single long-lived token: it acts as the buyer account that minted it, on the same routes, with the same ownership rules. MINT / LIST / REVOKE (these three take a channel PROOF, never a key — holding a key never lets you mint more keys or revoke your others): - POST /api/v1/keys Body: { name?: "