# Webly API > The website API for AI agents. Build, preview, publish, roll back, and manage websites, and share files and folders by link, through one REST API or its MCP server. ## Base URL ``` https://api.pr-216.playbase.ai/v1 ``` Machine-readable spec: `GET /openapi.json` (every operation carries `x-capability` and `x-rate-limit-class`). Remote MCP: `POST /v1/mcp`. Human docs rendered from this file: `GET /docs` (guide) and `GET /docs/api` (reference); `GET /docs` without `text/html` in `Accept` returns this file as Markdown. Dashboard for people: `GET /app` (paste an API key). `GET /api` lists the npm packages framework sites may import and their pinned versions. ## Get started with an agent Paste this into Claude Code, Codex, Cursor or any agent that can run shell commands: ``` Set up Webly so you can publish websites and share files and folders for me. If I have npm: npx skills add Webly-AI/webly-plugin --skill webly -g -y -a (on Windows: npx.cmd … --copy) If not: curl -fsSL https://pr-216.playbase.ai/install.sh | bash (on Windows: irm https://pr-216.playbase.ai/install.ps1 | iex) Then read https://api.pr-216.playbase.ai/llms.txt and ask me what I'd like to publish or share. ``` That installs the `webly` skill (instructions plus a zero-dependency Node helper, `webly.mjs`) into `~/.claude/skills/webly` and `~/.agents/skills/webly`. No account is needed to publish. Claude Code users can install the plugin instead, which adds the MCP server and a session-start hook (which also reports skill updates): `/plugin marketplace add Webly-AI/webly-plugin` then `/plugin install webly@webly`. **How it works.** The first publish needs no account: the site is live for 24 hours and claimable for 7 days. When the person wants to keep it, the agent connects Webly's MCP server, the person signs in once (OAuth), and the site moves into their workspace. From then on the agent works over MCP. | | Without an account | Signed in (MCP + OAuth) | |---|---|---| | Start | `webly.mjs deploy ./site` | `whoami`, `list_websites` | | Lifetime | Live 24 h, claimable 7 days, then deleted | Permanent | | Sites | One website per machine (per saved token), plus shared files | Per plan (unlimited with billing off) | | Size | 500 MiB per token, website and shared files together; website 25 files, 1 MiB each, 5 MiB total | Full API limits | | File links | As many as fit in the 500 MiB; each link lasts 24 h | Free 1 GB, up to 7 days; paid keeps them | | Editing | Whole-tree update or swap, first 24 h | Drafts, quality gate, publish on approval, rollback | | Extras | none | forms, custom domains, analytics | **What the skill does, depending on what is on the machine.** State lives on disk (`~/.webly/state.json`, `~/.webly/pending`) and at the API, so a new session, `/clear` or a restart picks up where the last one stopped. `webly.mjs doctor` reports it without changing anything. `webly.mjs deploy ` builds a project that has a `package.json` `build` script and deploys its output folder; the site is named with `--name`, else the package or folder name. A meaningless name like `dist` stops the first deploy so the agent can ask the person (it is a label, not the web address). The first deploy links the project to its site in `.webly/project.json` (commit it; no secrets), and later deploys update that site. The helper refuses rather than publish a second site: when the linked site was claimed into an account (update it over MCP), when an unlinked folder is on a machine connected to an account (`list_websites`, then `webly.mjs link `; `--anonymous` overrides, but never over this machine's existing anonymous site: `replace --anonymous` discards it). Edits made to the site elsewhere since this machine's last deploy don't block it; the result reports them as `replaced`. | Token saved | MCP loaded | Meaning | Next | |---|---|---|---| | no | no | New to Webly | Publish anonymously; offer to claim | | yes | no | Published before; MCP never set up or not loaded | Keep publishing while the site's `next.actions` includes `update`; otherwise claim | | no | yes | Signed in | Work over MCP | | yes | yes | Signed in, site not yet claimed | `list_websites` first: if the folder is linked elsewhere or the account already has this project, work on that site instead. Otherwise `create_claim_code`, then `webly.mjs claim ` | **Claiming.** `webly.mjs connect claude|codex` registers the MCP server at user scope and records `pending: claim`. If the Webly tools are already callable, the agent calls `authenticate` (Claude Code) or `whoami` (other hosts), opens the sign-in URL, calls `create_claim_code` and runs `webly.mjs claim `: the helper sends the code and the saved secret to the API itself, so the agent never reads the secret. If they aren't (the usual case right after `connect`), the agent doesn't wait for a reload: it runs `webly.mjs claim-link --open`, which opens the browser claim page (the token travels only in the URL fragment and is never printed). MCP loads next session. ## Anonymous deployments (no sign-in required) Anonymous endpoints use the API origin (not the `/v1` base above). One saved credential holds **one live website at a time, plus shared files** (a storage site beside it, see below), and **500 MiB in total** for both. Its website is live and editable for 24 hours, claimable until seven days after the credential's first site, then permanently purged if unclaimed. Neither source updates nor swaps reset those deadlines, and no site's 24 hours run past the seven days (a site created late gets a shorter day). While the server runs invite-only, these endpoints return `503` with `details.reason: "invite_only"`. 1. `POST /public/v1/anonymous/credentials` → `{token, claimPage}`. The local client/agent must save the returned `wa_…` secret atomically in a private file (for example `~/.webly/state.json`, mode `0600`) before deploying. The server cannot write files on your computer. Reuse that file; do not issue a fresh credential for every request. 2. `POST /public/v1/anonymous/sites` with `Authorization: Bearer wa_…` and `{name, kind: "static" | "framework", files}`. Static files are `{path, content, encoding?: "utf8" | "base64", contentType?}`; framework files are `{file: {type, name?}, content}` using the typed file identities below. To start over within the first 24 hours, send the same request with `replace: true`. The new site (new id and URL, any kind) is uploaded and built first; only then does it replace the current one, which goes offline and is purged. It keeps the credential's original deadlines. If the new site fails to build you get `400` with `details.reason: "build_failed"` and the current site is untouched. Without `replace` a second create is `409 site_already_created`. 3. `GET /public/v1/anonymous/sites/{id}` returns versions, deployment status, deadlines and URLs, plus `next: {message, actions}`: what the credential may do now (`update`, `replace`, `claim`, or nothing once claimed), and `live`: a one-line "Live now: version N at URL" (null when offline). Anonymous deploys go live on their own; there is no separate publish step. Use `current` instead of the id to recover a lost creation response. Framework builds may return `202`; poll until `ready` or `failed`. Ready versions are published automatically. Failed builds stay on the same site. 4. `PUT /public/v1/anonymous/sites/{id}` with `{expectedHeadVersion, files}` replaces the complete source tree; omitted files are removed. It requires the same credential and stops at the 24-hour cutoff. No anonymous leases are needed. 5. Open `https://app.pr-216.playbase.ai/claim#token=wa_…` (or paste the secret at `/claim`). Never put the secret in a query string or share/log this link. Sign in and confirm ownership (the page shows the signed-in account, with **Not you? Use a different account** to switch Google accounts). The browser cannot read your local credential file. `POST /api/anonymous/claim` takes `{token}` plus the Better Auth browser session and trusted Origin, or a user OAuth access token, or `{token, code}` where `code` comes from `POST /v1/websites/claim-codes` (MCP `create_claim_code`; a `wc_…` code tied to the signed-in person, valid 10 minutes, `401 claim_code_invalid` otherwise). The code form is how the helper claims (`webly.mjs claim `) without the agent ever holding the secret. An agent connected to Webly's MCP over OAuth should run `webly.mjs doctor` right after connecting and before any deploy, and claim a saved site that way. Without the helper it may read `~/.webly/state.json` and call `claim_anonymous_site` (`POST /v1/websites/claims`). 6. Errors carry `details.reason` when the credential's state matters. Only `credential_consumed` (already claimed) and `credential_expired` (claim deadline passed or site purged) mean the saved secret is permanently useless; delete it and issue a new one for a new site. Keep it on `edit_window_closed` (410, the site can still be claimed; no more swaps), `site_already_created` (409, send `replace: true` to swap) and `network_limit` (409, too many live anonymous sites on this network; `details.retryAfterSeconds`). A credential that has not created a site yet gets `404` from status and claim. API keys alone cannot claim. The account is activated and the website moves to its personal workspace. Same-user retries return `alreadyClaimed`. After claim, the secret no longer grants anonymous access and cannot create or swap to another site. Continue with normal authenticated APIs/OAuth. Free claims retain only the original 24-hour preview; afterwards the saved website is held offline. When the server runs without billing (`GET /v1/billing` → `enabled: false`) there are no plans: a claim is an ordinary active site and nothing is held. Hobby/Pro claims activate at the same URL right away (Hobby and Pro have no site limit unless one is set for the workspace). Held sites do not use an active slot. The owner can edit and publish them through the normal API until the original 24-hour preview ends; after that they are read-only (`402 claim_held`) until activated. An upgrade, a comped plan or `POST /v1/websites/{id}/activate` releases held sites. **Sharing files without an account.** Sharing files doesn't use up the website: create the credential's storage site with `{name, kind: "storage"}` (no `files`) whether or not it already has a website (`409 site_already_created` if it already has a live one; `GET /public/v1/anonymous/sites/storage` finds it, and `current` stays the website when there is one). Then `POST /public/v1/anonymous/sites/{id}/objects/uploads` and `POST /public/v1/anonymous/sites/{id}/objects/finalize` with the same bodies as the signed-in storage endpoints and the `wa_` secret as the bearer, and `DELETE /public/v1/anonymous/sites/{id}/objects/{objectId}` to cancel an upload that failed. Each upload is its own folder, so links are unlimited: one per file and one per folder. The credential's 500 MiB covers the website's uploads (every version) and finished files together (`413 anonymous_storage_limit`; status shows `storage: {usedBytes, limitBytes}`), 100 MiB per file (uploads in progress don't spend it, and have their own 1 GB allowance; deletes don't refund it). The storage site's files are live **and claimable for 24 hours** from its creation, then deleted; after that, creating it again starts a new 24 hours (the budget isn't renewed). Claiming the credential moves the website and its live shared files together into the person's workspace (it must have room in its storage quota, else `402 storage_limit`), the files with the plan's default expiry, counted from the claim; the claim response has the website in `website` and the files in `storage`. Helper: `webly.mjs upload … [--folder name]` does all of it and prints each file's link, the folder link and the zip link. A `--folder` name gets a random suffix here (`demobowling-3f9a2c1b5e7d90a1c2b4`), so the link can't be guessed. Limits: 25 files, 1 MiB/file, 5 MiB decoded tree, 8 MiB JSON body; uploads spend the credential's 500 MiB (updates and swaps included). A network (one IPv4 address, or one IPv6 /64) holds at most 50 live anonymous sites, so an office or event can share one connection. Rate limits (burst, then refill): per credential 5 creates/swaps then 10/day, 20 edits then 20/hour; per network 60 credentials then 120/day, 100 creates then 200/day, 200 edits then 10/minute, 60 claims then 60/hour. Signed-in requests use the normal API limits instead. Observe `Retry-After` on 429. Terminal/expired credentials are never reusable. A different credential is a different anonymous identity; these limits cannot identify a human who changes credentials/devices/IPs. See `docs/anonymous-deployments.md` for rollout, security and testing details. Helper: `node scripts/webly.mjs` (shipped in the `webly` skill). ## Authentication and access Bearer token in the Authorization header: ``` Authorization: Bearer wb_your_api_key ``` `GET /v1/me` tells you who you are: organization, access scope, and the exact capabilities the key holds. It also returns `workspace: { id, slug, name } | null` (the workspace this request acted in) and `workspaces: "all" | "one"`. **Workspaces (OAuth).** An API key belongs to one workspace. A person's OAuth connection covers either the one workspace picked at sign-in or, if they chose **All my workspaces** (the default), every workspace they belong to, including ones they join later. In each workspace their role there still applies: a Full access connection used in a workspace where the person is a member gets `full_editor`. - `GET /v1/workspaces` — signed-in people (OAuth or session; `403 user_required` for API keys) `→ { items: [{ id, slug, name, kind: "personal"|"team"|"shared", role: "owner"|"admin"|"member"|"collaborator", current, canUse }], mode: "all"|"one", hint? }`. `canUse: false` means this connection is bound to another workspace; the person reconnects (in Claude Code: `/mcp`) and picks All my workspaces. MCP: `list_workspaces`. - Name the workspace with the header `X-Webly-Workspace: {slug or org_ id}` (MCP: the `workspace` argument on tools that act on a whole workspace). Requests under `/v1/websites/{id}` need none: website ids are global, so the site's own workspace is used. A named workspace is never overridden, so a site in another workspace is then `404`. - A workspace-level request (create or list websites, billing, keys, namespace, audit log, uploads) from an all-workspaces connection that names none, when the person has several, is `400` with `details: { reason: "workspace_required", workspaces: [{ slug, name, role }] }`. Ask your person which one; never guess. - An unknown workspace and one you can't use answer the same: `404` with `details.reason: "workspace_not_found"`. - REST for a person, without an API key: the Webly skill's helper runs `webly login` (opens the browser to the same sign-in and workspace choice) and then `webly api /path [--workspace slug] [--data JSON]`. It keeps the token itself; `webly logout` revokes it. Any OAuth client works the same way: the bearer token is accepted on every `/v1` route. **Keys carry a scope and a role.** - Scope: `{ kind: "organization", role }` (every website in the org) or `{ kind: "websites", websiteIds: [...], role }` (only those websites). - Roles are strictly additive: - `content_editor` — read websites; read schema; create/update/publish CMS items; upload and manage assets; read pages. - `full_editor` — everything above plus source files, deploys, publish/rollback/unpublish, CMS schema, pages, domains, and reading the workspace's plan. - `admin` — everything plus create/rename/delete websites, manage API keys, read the audit log. Organization scope only. (Upgrading or changing the plan needs a signed-in admin in the dashboard.) - `owner` is a human account role and is never grantable to a key. A team workspace has exactly one owner (`GET /v1/me` → `owner: { userId, name }`). Only the owner can hand the workspace to another member, in the dashboard's People page (`POST /v1/members/{userId}/transfer-ownership`, `{ billingEmail? }`: signed-in people only, `403` for keys and agents; the old owner becomes an admin, and unless `billingEmail` is `false` receipts and invoices go to the new owner; the card and subscription stay with the workspace). The owner can't leave without naming a successor, and deleting a team workspace cancels its subscription at once. This is ownership of the workspace itself, not moving a website between workspaces. You can't change it for anyone: when they ask, tell them to use the People page. **Two different failures, deliberately:** - `404 not_found` — the website (or anything under it) is outside the key's scope. Ids never confirm their existence across tenants or to keys without a grant. - `403 forbidden` — the key can see the website but its role lacks the capability. `details.capability` names what is missing. **Taken-down sites.** Webly can take a site down for breaking the acceptable-use policy. The website then has `suspendedAt` and `suspendedReason` set, nothing serves on any hostname (live, draft or custom domain), and publishing fails with `403` and `details.reason: "suspended"`. Nothing you do can lift it: tell the owner the reason and that they can write to abuse@webly.ai. Anonymous sites report `status: "suspended"`. ### API keys (admin) - POST /v1/api-keys — `{ name?, access: { kind, role, websiteIds? }, expiresAt? }` → the `secret` is returned exactly once - GET /v1/api-keys (`status=all` to include revoked, `websiteId=` to filter) · GET /v1/api-keys/{keyId} - PATCH /v1/api-keys/{keyId} — change `name`, `expiresAt`, or `access` (takes effect immediately) - POST /v1/api-keys/{keyId}/rotate — new `secret`; the old one stops working instantly - DELETE /v1/api-keys/{keyId} — revoke (permanent) Give each agent session the narrowest key that works: a website-scoped `full_editor` for building a specific site; a `content_editor` for content-only tasks. ### Audit log (admin) - GET /v1/audit-log — `eventType=`, `resourceType=`, `websiteId=`, `actorId=`, `actorKind=` (`api_key`, `oauth`, `user`; comma-separated for several), cursor-paginated. Records website create/update/delete/restore, publish, rollback, unpublish, subdomain renames, domain add/activate/primary/remove, API key create/update/rotate/revoke, and shared files (`storage.upload`, `storage.move`, `storage.delete`, `storage.folder_delete`), each with the acting key or person. Events by an app signed in over OAuth carry its name in `metadata.agent`. ## Conventions - **Errors** always have the shape `{ kind, message, details? }`. `kind` is one of `validation` (400), `unauthorized` (401), `payment_required` (402), `forbidden` (403), `not_found` (404), `conflict` (409), `payload_too_large` (413), `rate_limited` (429), `upstream` (502), `internal` (500). - **Lists** always return `{ items, nextCursor }`. Pass `nextCursor` back as `?cursor=` until it is `null`. `limit` is 1-100 (default 20). Cursors are opaque. - **Rate limits** are per key, token-bucket. Standard: 120 burst, ~600/min. Expensive (deploys, publishes, rollbacks, builds, head/check): 20 burst, ~30/min. `429` responses carry `Retry-After`; every response carries `X-RateLimit-Limit` / `X-RateLimit-Remaining`. ## Mental model - An **organization** owns **websites**. - A website is either **framework** (default: React source in typed files, built through a quality gate, edited with the `/source` tools below) or **static** (raw files sent with `/deploy`). Pick `kind` at creation; it cannot change. - Every website has a **draft head version** (`headVersion`) and a **published version** (`publishedVersion`, `null` until the first publish). - Deploying files creates a **new head version** (append-only, versions are never rewritten). Nothing goes live until you **publish**. - The draft head is always viewable at `https://draft--{subdomain}--pr-216.playbase.ai` (noindex, no-store). - The published version is served at `https://{subdomain}--pr-216.playbase.ai` and on every active custom domain. The subdomain is generated (`zen-wreath-y76r`), not taken from the name. - On **Hobby and Pro** the workspace also has a **namespace**, and every published site answers at `https://{vanity}.{namespace}--pr-216.playbase.ai` too (`urls.vanity`, e.g. `https://fall-out-boy.kev--pr-216.playbase.ai`). For the few minutes until a new or renamed namespace's certificate is live, `urls.vanity` is `null` and the vanity address is left out of `urls.all`, so `urls.primary` is the flat address (or the primary custom domain, which always leads). See Vanity namespace below. - `urls.all` lists every address the published site answers at, primary first: active custom domains (the primary one leading), then the vanity address, then the flat one; or, when the owner made a playbase.ai address primary, that one first. `urls.primary` is `urls.all[0]`, the address to share; with a primary set (a custom domain, or a playbase.ai address the owner chose), every other address 308-redirects to it. Both are empty/null while the site isn't served (not published, deleted, or taken down). Deploy responses, deploy tickets and the source summary carry the same `urls` object as the website. - Publishing fails safe: if the build is not ready or the quality gate fails, the live site does not change. **Rollback** re-publishes any earlier version; **unpublish** takes the site offline without touching history. - Framework sites: every commit starts a **background preview build**; the draft host moves to the new version once it is ready. Publishing waits for the build (`202` `status: "building"` if it is still running after about a minute: publish again shortly) and returns `400` with `details.diagnostics` / `details.renderFailures` only when it really failed. - **Where content lives.** Everything is **source files**: pages, layout, styles, and repeating content such as blog posts, products, team members, events and FAQs. The CMS and managed blog (`/collections`, `/blog/posts`, MCP `create_collection`, `add_blog_post`, …) are **deprecated**: the owner's dashboard no longer shows that content, so don't create new collections or posts. Existing sites that already read collections or a managed blog keep working: check with `GET /collections` / `GET /blog` before changing a site, edit that content through those endpoints, and move it into source files only when the owner asks. - **Forms** (contact, quote request, signup, booking) post to Webly with `formAction('name')`; replies land in the owner's dashboard (Forms tab) where they can read, reply and export. Never use a `mailto:` link or a third-party form service — that sends the owner's leads somewhere they don't control. Include a hidden `_honey` input (spam trap) and a hidden `_redirect` to a thank-you page. Never collect card numbers, passwords, government ID numbers or health details in a form: submissions are stored as plain text, readable by everyone in the workspace. Take payments through a payment provider's hosted checkout link instead. ## Quick Start (framework website) 1. Create a website (starts as an empty draft): ```bash curl -X POST /v1/websites -d '{"name": "My Blog"}' # { id: "ws_...", kind: "framework", subdomain: "zen-wreath-y76r", headVersion: 1, publishedVersion: null, # urls: { draft: "https://draft--zen-wreath-y76r--pr-216.playbase.ai", published: null } } ``` 2. Write a page (each write is one new head version; send `baseVersion`, the version you read, when you rewrite an existing file): ```bash curl -X PUT /v1/websites/{id}/source/file -d '{ "file": { "type": "page", "name": "home" }, "content": "import { definePage } from \"@webly\"\nexport default definePage({\n route: \"/\",\n metadata: { title: \"Home\" },\n render: () =>

Hello

,\n})\n" }' # { version: 2, changed: true, file: { type: "page", name: "home" } } ``` 3. Run the quality gate, then preview at `urls.draft` and publish: ```bash curl -X POST /v1/websites/{id}/head/check # { version: 2, ok: true, diagnostics: [], renderFailures: [], pages: [{ name: "home", route: "/", renders: 1 }] } curl -X POST /v1/websites/{id}/publish ``` ## Quick Start (static website) 1. Create with `kind: "static"`: ```bash curl -X POST /v1/websites -d '{"name": "My Blog", "kind": "static"}' ``` 2. Deploy files into a new draft version: ```bash curl -X POST /v1/websites/{id}/deploy/json -d '{ "files": [ {"path": "index.html", "content": "

Hello

"}, {"path": "style.css", "content": "body { font-family: sans-serif; }"} ], "message": "First draft" }' # { version: 2, changed: true, urls: { draft: "https://draft--zen-wreath-y76r--pr-216.playbase.ai", published: null }, next: "Preview ... then POST /publish" } ``` 3. Preview the draft at `urls.draft`, show it to the user, then publish: ```bash curl -X POST /v1/websites/{id}/publish # { website: { publishedVersion: 2, urls: { published: "https://zen-wreath-y76r--pr-216.playbase.ai" } }, deployment: { status: "ready" } } ``` 4. Something wrong? Roll back instantly: ```bash curl -X POST /v1/websites/{id}/rollback -d '{"version": 2}' ``` ## Endpoints ### Websites - POST /v1/websites — Create website `{ name, kind?: "framework"|"static"|"storage", slug?, subdomain?, vanity?, description?, folder?, copyFrom? }`. `folder` files the site in a dashboard folder ("Clients"; up to 60 characters). `copyFrom: websiteId` duplicates that website (Hobby and Pro; `402 plan_required` on Free): same kind, copied as with `POST /v1/websites/{id}/duplicate` below. `storage` is file sharing, not a website (see Storage websites below): live at once, no source, build or publish, and it takes no plan website slot. Without `slug` it comes from `name`, and a taken one gets a random suffix (`portfolio-x7k2p9`). Without `subdomain` a short one is generated (`zen-wreath-y76r`); leave it out, since on Hobby and Pro the name goes in the vanity address instead: `vanity` (or the name, suffixed if another site in the namespace has it) becomes `{vanity}.{namespace}.playbase.ai`. A `slug`, `subdomain` or `vanity` you pass is used exactly or fails `409`; `vanity` on Free is `402 plan_required`. - GET /v1/websites — List websites the key can see (`limit`, `cursor`; `scope=trash` for soft-deleted; `folder=Clients` for one folder, `folder=` for sites in none; `archived=true|false` to filter archived sites, which are included by default) - GET /v1/websites/{id} — Get website (includes `headVersion`, `publishedVersion`, `hasUnpublishedChanges`, `urls`: `draft`, `published`, `vanity`, `primary`, `all`, and for websites that build, `build: { status: "none"|"building"|"ready"|"failed", version, previewReady }`: the head version's build, and whether the draft has any built version to show yet) - PATCH /v1/websites/{id} — Update name / slug / description / folder (`null` or `""` takes it out of its folder) / `archived` (`true` hides it from the dashboard's main views and sets `archivedAt`; the site stays live). Does not affect hostnames - PATCH /v1/websites/{id}/subdomain — Rename the platform subdomain `{ label }` (1-56 chars, lowercase, digits, inner hyphens; not a workspace's namespace) - PATCH /v1/websites/{id}/vanity — Hobby and Pro: rename the site's label in the namespace `{ label }` → `{label}.{namespace}.playbase.ai`. Unique within the namespace only (`409` if another site there has it); the old vanity address stops working at once - DELETE /v1/websites/{id} — Soft-delete and take offline on every hostname - POST /v1/websites/{id}/restore — Restore a soft-deleted website - GET /v1/websites/{id}/analytics/query?startAt=&endAt=[&dimension=&limit=&filters] — Engagement totals for a range (Unix ms) `→ { pageviews, visitors, visits, bounces, totaltime }`. Add `dimension` (one of `path, entry, exit, title, query, referrer, channel, domain, country, region, city, browser, os, device, language, screen, event, hostname, tag, distinctId`) to get `rows: [{ name, pageviews, visitors, visits, bounces, totaltime }]` ranked by visitors (`limit` default 25, max 1000). Optional filters as query params: `path, referrer, title, query, browser, os, device, country, region, city, language, hostname, tag, event, distinctId, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, segment, cohort`. - GET /v1/websites/{id}/analytics/timeseries?startAt=&endAt=&series=pageviews|events&unit=year|month|day|hour|minute[&timezone=&maxPoints=&compare=prev|yoy] — `series=pageviews` `→ { pageviews: [{ x, y }], sessions: [{ x, y }], compare? }`; `series=events` `→ { events: [{ x, t, y }] }` (custom events tracked with `umami.track('name')` in page code). `maxPoints` (default 30, max 500) keeps the most recent buckets and sets `truncated: { shown, total, hint }` when it cut. - GET /v1/websites/{id}/analytics/realtime — `→ { views, visitors, events, countries, timestamp }` for the trailing 30 minutes. - GET /v1/websites/{id}/analytics — Server-side beacon overview (`period=24h|7d|30d|90d` or `from`/`to`): totals, series, top pages/referrers/countries/devices. Counts every published HTML response the edge served, including non-JS clients, but leaves out known bots (crawlers, link previews) and visits from the owner's own dashboard or a local dev server (`app.webly.ai`/`localhost` referrers, for that visitor's whole day). The beacon fallbacks of the endpoints above apply the same rules; `device=bot` brings bots back. Analytics comes from two sources: a first-party tracker (`/_webly/a.js`, proxied on the site's own hostname; sessions, duration, devices, custom events — powers `query`/`timeseries`/`realtime`) and an edge beacon (every published HTML response). Draft previews are never tracked. `website.analytics` reports `{ provider: "umami"|"beacon", tracking, dashboardUrl }`; `dashboardUrl` is a read-only shareable dashboard. Disable the tracker for a site with `defineWebsiteConfig({ analytics: { enabled: false } })`. Static websites get the tracker added to their published pages at the edge (top-level page loads only, not iframes); a page that already loads `/_webly/a.js` is left as is. When the tracker backend is not configured the three endpoints answer from the beacon with `source: "beacon"` and a reduced dimension set (`path, referrer, country, device, hostname`). - POST /v1/websites/{id}/duplicate — Hobby and Pro, in both workspaces: copy the website 1:1 `{ toWorkspace?, name? }` `→ 201 { website, publish?: { status: "published"|"building"|"failed", version, message? }, next }`. The copy has every version (same numbers and messages), uploaded media (new asset ids, rewritten in source and CMS), CMS collections and items, pages, form submissions and notification settings, the site password and storage folders, and is published at the same version as the original (`building`: publish that version again shortly; `failed`: that version no longer builds, fix it on the copy). It gets new addresses (`name` defaults to "{name} (copy)"); custom domains, analytics history and collaborators stay with the original. `toWorkspace` (slug or `org_…` id) puts it in another workspace you admin: that needs a signed-in person (OAuth or the dashboard; API keys get `403 user_required`), `404` if you aren't a member there, `403` if you aren't an admin. `402` with `details.reason`: `plan_required` (either workspace on Free), `plan_limit` or `storage_limit` (the receiving workspace has no room). Nothing is left behind when a copy fails. - POST /v1/websites/{id}/move — Hobby and Pro, in both workspaces: move the website into another workspace you admin, at once `{ toWorkspace }` `→ { website, transfer }`. Everything comes along (history, media, CMS, forms, custom domains, published build); nothing stays in the old workspace. It gets a **new flat address** (`{subdomain}.playbase.ai`) and vanity address in the new workspace, and the old ones stop serving; custom domains keep working. Signed-in people only (`403 user_required` for API keys); `400` for the same workspace; `409 transfer_pending` while a code transfer is pending; `402` as for duplicate. Form notification recipients are cleared. - GET /v1/websites/{id}/destinations — Signed-in people: the workspaces you admin `→ { workspaces: [{ id, slug, name, plan, current, eligible, reason?: "plan_required"|"locked"|"site_limit"|"storage_limit", message? }] }`, i.e. where this website could be duplicated or moved now. - POST /v1/websites/{id}/transfer — Hobby and Pro: start moving the website to someone else's workspace `{ toOrganizationId? }` `→ { id, status: "pending", expiresAt, code }`. The `code` is shown once and expires in 7 days; give it to an admin of the receiving organization. `409` while another transfer is pending. For a workspace you admin, `move` does it at once. - GET /v1/websites/{id}/transfer · DELETE /v1/websites/{id}/transfer — Inspect or cancel the pending transfer - POST /v1/websites/claim-codes — returns `{ code, expiresAt }`, a `wc_…` code valid for 10 minutes that claims into the signed-in person's personal workspace when sent with the secret to `POST /api/anonymous/claim` as `{token, code}`. Lets the local helper (`webly.mjs claim `) claim without the agent reading the secret. Needs an OAuth token or signed-in session. MCP: `create_claim_code`. - POST /v1/websites/claims — `{ token }` claims a website created without an account (`wa_` secret, usually in `~/.webly/state.json`) into the signed-in person's **personal** workspace, whichever workspace the caller is connected to. Needs an OAuth token or signed-in session; API keys get `403` with `details.reason: "user_required"`. MCP: `claim_anonymous_site`. Returns `{ website, workspace, dashboardUrl }`; `dashboardUrl` is `https://app.pr-216.playbase.ai/sites/{id}?workspace={slug}`. - POST /v1/websites/transfers/accept —`{ code }` (admin of the receiving org; Hobby and Pro on both ends, else `402 plan_required`) moves the website, its history, CMS, assets, domains and published build. It gets a new flat address and the old one stops serving. Website-scoped keys of the previous organization lose access; any edit lease is dropped. The old organization then sees `404`. ### Deploy (static websites only; creates a new draft head version; nothing goes live) - POST /v1/websites/{id}/deploy — Multipart: repeat field `files`, filename = path. Optional fields `uploaded` (JSON, see below), `replace`, `expectedHeadVersion`, `force`, `message`. Up to 2000 files, 1 MiB each, 100 MiB per request (`413` beyond, the same cap as `/deploy/json`) - POST /v1/websites/{id}/deploy/json — `{ files: [{ path, content, contentType?, encoding?: "utf8"|"base64" }], uploaded?: [{ path, size, sha256 }], deletes?: [path], replace?: bool, expectedHeadVersion?: n, force?: bool, message? }` - POST /v1/websites/{id}/deploy/uploads — Large files (images, video, 3D models; anything over 1 MiB) go straight to storage, never through the API: `{ files: [{ path, size, sha256 (lowercase hex), partSha256?, contentType? }] }` → `{ files: [{ path, hash, size, contentType, upload: { method: "PUT", url, headers, expiresAt, command } | null }], expiresAt }`. They count toward the workspace's storage first: `402 storage_limit` when they don't fit and `413 file_too_large` over the plan's per-file limit, before any byte moves. Storage checks every byte against the SHA-256: the checksum is signed into each upload URL and sent as the `x-amz-checksum-sha256` header, so send `headers` exactly. A file over 100 MiB goes up in 100 MiB parts and needs `partSha256`, each part's SHA-256 in order, and its `sha256` is then the SHA-256 of those raw 32-byte digests (`400 part_hashes_required` / `part_hashes_mismatch` otherwise). PUT each file that has an `upload` to its `url` with its `headers` (or run `upload.command` with `FILE` set to the local path). Such a file comes as `parts: [{ partNumber, url, size, headers, command }]` with `partSize` instead: PUT each part's bytes with its `headers` (`partSize` from offset `(partNumber - 1) * partSize`), and re-run any part that fails on its own. Asking again with the same files resumes: only the parts storage doesn't have yet come back (`uploadedParts` lists the rest). Neither `upload` nor `parts` means storage already holds those bytes (same file at another path, or an earlier deploy). URLs last an hour. Then deploy with `uploaded` set to the returned `files` (small files inline as usual); a file not uploaded yet (or missing parts, or released after a day unused) is `400 upload_missing`. A file has one size: listing a stored file's SHA-256 with another size is `409 size_mismatch`, and the deploy must name the size its upload was started with. Uploads land in private staging and are copied into the site only by the deploy, so an upload URL can't change a file after it's deployed. MCP: `begin_site_upload`, then `deploy_files` with `uploaded`. An upload no deploy uses within a day is deleted and stops counting. - POST /v1/websites/{id}/deploy/tickets — A deploy ticket (MCP `begin_deploy`) for uploading a folder on disk without file bytes in a tool call or an API key in a shell command. Returns `{ ticket, expiresAt, headVersion, urls, upload: { method, url, headers, command } }`. Valid 5 minutes and for one deploy (it is bound to the current head version). Run `upload.command` with `DIR` set to the folder (`DIR=./dist; `); it sends files up to 1 MiB only, while the Webly helper (`webly deploy --ticket --message ""`) also sends larger ones through `/public/v1/deploys/uploads`. - POST /public/v1/deploys — Redeem a ticket: `Authorization: Bearer wd_…`, multipart `files` (filename = path; dotfiles are the caller's to skip), up to 100 MiB per request (`413` beyond). Optional fields `replace` (default **true**: the draft holds exactly these files), `baseVersion` (the version these files were last deployed from: if the site changed since then, the deploy goes ahead unless it would undo one of those changes, which is `409 would_revert` with `details.paths`; read the live files with `read_source_files`, merge them into the local project and deploy again, or ask the person and resend with `force=true`), `force`, `message` (one line saying what changed; the site's History shows it, and without one the version reads "Saved changes") and `uploaded`. Without a `baseVersion` (at or before the ticket's head), a replace that would remove files the site has is `409 would_remove` with `details.paths`: add them to the folder, send `replace=false`, or, with the person's OK, resend with `force=true` (`force` skips both checks). `uploaded` may also name files the site already stores (`{ path, size, sha256 }`, e.g. each `.webly/media.json` entry of a git-mirror clone), which keeps them without sending their bytes. Same response as `/deploy`, plus `replaced: { fromVersion }` when it replaced a head other than `baseVersion`. A ticket replayed after a deploy that changed the site is `409` (one whose deploy changed nothing or failed stays usable until it expires); an expired or forged one is `401 deploy_ticket_invalid` - POST /public/v1/deploys/uploads — The uploads step with a ticket (`Authorization: Bearer wd_…`), same body and response as `/v1/websites/{id}/deploy/uploads`, plus `ticket` and `ticketExpiresAt`: a ticket for the same site and head that lasts until an hour after `begin_deploy` issued the original (renewing again doesn't extend it), so the deploy still works after a long upload. Use it for `POST /public/v1/deploys`. Both ticket endpoints check that whoever asked for the ticket can still change the site: a revoked key or a removed member is `403 deploy_ticket_revoked`. Give every static HTML page share-preview tags in `` (`og:title`, `og:description`, `og:image` as an absolute URL, `twitter:card`, and a favicon): Webly adds `og:` tags only for framework sites, so a static page's link previews show exactly what you uploaded. Files are merged into the current head by default; `replace: true` makes the new version contain only the files you send. Identical content is a no-op (`changed: false`, version does not move). Changing, deleting or `replace`-ing away a file that already exists needs `expectedHeadVersion`, the version you read the files at: without it the write is refused with `400 base_required` (`details.paths` lists the files it would have overwritten), with it, if another session moved head since, your change is **merged** onto theirs: files they didn't touch are written as sent, files you send back unchanged from your base keep their version (edit, type change or delete), and text files (HTML, CSS, JS, JSON, SVG, Markdown…) you both changed are merged line by line (three-way, like git); the response has `merged: { baseVersion, headVersion, files }`. If you both changed the same (or adjacent) lines, or both changed a binary file, or one side deleted what the other edited, nothing is saved: `409 merge_conflict` with `details.conflicts: [{ path, kind: "both_edited"|"deleted_by_them"|"edited_by_them", hunks: [{ startLine, base, theirs, ours }], theirsBy: [{ version, name, client }] }]` and `details.next` — read the files at `headVersion`, re-apply your change keeping theirs, resend with that version. `baseVersion` is accepted as the same field. With `replace: true` and an older base, files others added since are kept. `force: true` overwrites whatever is there now; only when the person asks. Adding new files needs neither. Limits: 1 MiB per file sent inline (larger files go through `/deploy/uploads`, up to the plan's per-file limit), 2,000 files per website. Every file a site stores counts toward the workspace's storage, each distinct file once across all its versions. Paths may not contain `..`. ### Source code and versions - GET /v1/websites/{id}/source/summary — `headVersion`, `publishedVersion`, `hasUnpublishedChanges`, file count, urls - GET /v1/websites/{id}/source/files — List files (`target=head|published` or `version=n`, optional `prefix`) - GET /v1/websites/{id}/source/files/read?path= — Read one file (text as utf8, binary as base64) - POST /v1/websites/{id}/source/files/read — `{ paths: [...≤50], target?|version? }` - DELETE /v1/websites/{id}/source/files — `{ paths: [...], expectedHeadVersion?, force?, message? }` → new version. Static sites: `expectedHeadVersion` (or `force`) is required, as for deploys; deletes don't merge, so a stale one is `409` with `details.changedBy` (who wrote since) - GET /v1/websites/{id}/source/versions — History, newest first (`limit`, `cursor`); entries flag `isHead` / `isPublished` - GET /v1/websites/{id}/source/feed — `?since=N&wait=S` versions written after N (default: head, so only newer ones), oldest first, up to 20: `{ since, headVersion, publishedVersion, versions: [{ version, message, authorName, client, baseVersion, createdAt, changes: [{ path, status, file? }] }], next, truncated }`. With `wait` (seconds, ≤30) it holds the request until someone writes. Pass `next` as `since` next time. At most 5 waiting requests per key (`429 too_many_waiters`). MCP: `wait_for_changes`. - GET /v1/websites/{id}/git — Read-only git clone of the source and its whole history (Cloudflare Artifacts): one commit per version on `main`, oldest first, with the version's time, author and a `Webly-Version: N` trailer; images, video and files over 5 MB are listed in `.webly/media.json` (path → sha256, size, contentType) instead of committed. Syncs first (waits up to 10 s) → `{ remote, branch, token, tokenExpiresAt, clone, pull, syncedVersion, headVersion, inSync, lastError, nextSyncAt, readOnly: true, next }`. The token is a read token valid for **1 minute**: run `clone` (or `pull` inside the clone) right away as `WEBLY_GIT_TOKEN= ` (or `export WEBLY_GIT_TOKEN=` first; the commands are wrapped in `sh -c '…'` so either works) (git sends it on every request, so a 401/403 means it expired: ask `/git` again and rerun); never put it in the remote URL. To deploy from a clone, use the Webly helper (`webly deploy . --ticket …`): it keeps the files in `.webly/media.json` and takes the base version from the last `Webly-Version` trailer, so a stale clone can't silently undo newer versions (`git pull` first when it says the clone is behind). Before each later pull check `headVersion` with `GET /v1/websites/{id}` and ask `/git` again only when it moved. Writes still go through the API/MCP; the repository rejects pushes. After a failed push the mirror retries with backoff (`inSync: false`, `lastError`, `nextSyncAt`). `503 git_unavailable` (not configured on this server), `400 git_not_website` (shared files), `402 claim_held` (activate the claimed site first). Anonymous sites get git once claimed and activated, with every earlier version replayed. - GET /v1/websites/{id}/source/changes — Added/removed/modified files between `from` and `to` (default `published` → `head`) ### Framework source (framework websites only) Source is a set of **typed files**, not a file tree. Named types: `page`, `component`, `module` (name is path-like: 1–255 chars, no leading `/`, no `.`/`..` segments, none of `< > : " | ? * \ #`; not an asset extension). Singletons (no name): `global_css`, `not_found`, `website_config`, `custom_head_start`, `custom_head_end`, `custom_body_start`, `custom_body_end`. Assets (images, files) live in the asset library, never in source. - POST /v1/websites/{id}/source/lease — **Deprecated.** Writes no longer need a lease; several people and agents can edit one site at once. Still answers `{ leaseToken, headVersion, deprecated: true }`, and writes accept and ignore `leaseToken`, so older clients keep working. - GET /v1/websites/{id}/source/tree — List typed files (`type=`, `nameGlob=` e.g. `blog/*`, `lines=true`, `target=head|published` or `version=n`) - GET /v1/websites/{id}/source/file?type=&name= — Read one file `→ { content, bytes, lines, hash }` - POST /v1/websites/{id}/source/file/read — `{ files: [{ type, name? }...≤50], target?|version? }` - PUT /v1/websites/{id}/source/file — `{ file, content, message?, baseVersion? }` create/replace one file → new head version (`201`), or `200 changed:false` if identical. `baseVersion` is the `version` your read returned. If others changed that file since, your change is **merged** onto theirs line by line (three-way, like git) and the response has `merged: { baseVersion, headVersion, files }`. If you both changed the same (or adjacent) lines nothing is saved: `409 merge_conflict` with `details.conflicts: [{ file, kind: "both_edited"|"deleted_by_them"|"edited_by_them", hunks: [{ startLine, base, theirs, ours }], theirsBy: [{ version, name, client }] }]` and `details.next` — read the file at `headVersion`, re-apply your change keeping theirs, resend with that version. Without `baseVersion` the last writer wins. - DELETE /v1/websites/{id}/source/file — `{ file, baseVersion? }` (`409 merge_conflict`, kind `edited_by_them`, when someone edited the file after `baseVersion`: deleting would discard their work) - POST /v1/websites/{id}/source/str-replace — `{ files: [{ file, edits: [{ oldStr, newStr, replaceAll? }] }], dryRun? }` (static sites: `{ path, edits }` instead of `{ file, edits }`; text files only, written as sent with no formatting, and `oldStr` must match exactly) — atomic: ≤50 files / ≤200 edits, each `oldStr` must match exactly once unless `replaceAll`; an `oldStr` with no exact match still applies if it matches exactly once when whitespace is ignored (so text that Prettier re-indented still works); any failing edit fails the whole call (`400` with `details.problems[]`) and nothing is written. `dryRun: true` validates without writing. Prefer this over rewriting whole files. - PATCH /v1/websites/{id}/source/pages/route — `{ page, route }` change the `route:` literal (normalized; `/_webly/*` reserved) - PATCH /v1/websites/{id}/source/metadata — `{ page?, metadata }` replace a page's `metadata` object, or the site defaults in `website_config` when `page` is omitted - POST /v1/websites/{id}/source/search — `{ pattern, isRegex?, caseSensitive?, nameGlob?, type?, contextLines?≤3, maxMatchesPerFile? (10, max 50), filesOnly?, target?|version? }` `→ { matches: [{ file, line, column, text, before?, after? }], files, truncated }`. At most 500 matches in all (`truncated: true` past that); lines over 400 characters come back cut around the match, marked with `…` (`column` is still the position in the full line). Static sites: searches text files (binary and files over 1 MiB are skipped), matches and `files` give `path` instead of `file`, `nameGlob` matches the path (`**/*.html`), and `type` is refused. The other `source/file` endpoints are framework-only: on a static site they return `400` with `details.next` naming the static-site tools. - GET /v1/websites/{id}/source/file/diff?type=&name=&from=&to= — Unified diff of one file (defaults `published` → `head`) - POST /v1/websites/{id}/head/check — **Quality gate, read-only**: lint → typecheck against the generated `@webly` types → bundle → server-render every page with placeholder CMS content `→ { version, ok, message?, diagnostics: [{ step, severity, file?, line?, column?, message }], renderFailures: [{ pageId, route, message }], pages, timings }`. Publishing runs the same gate (with live content) and fails safe. - POST /v1/websites/{id}/rebuild — `{ target?: "published"|"head", version? }` re-render a built version with current CMS content. Usually unnecessary: item changes (create, update, delete, publish, scheduled `publishAt`) automatically re-render the published and head builds within a few seconds. - GET /v1/websites/{id}/deployments/status — includes `diagnostics` (pages, timings, or the failure details) and the served `routes` Every `PUT`/`str-replace` write is formatted with Prettier before it is stored (single quotes, no semicolons, 100 columns), so head is always canonical: read a file back before composing exact-match edits against it. Responses carry `formatted: true` when formatting changed your text; unparseable code is stored as sent (`formatError`) and the gate reports the syntax error. Raw HTML slots (`custom_*`) are stored verbatim. #### Writing pages ```tsx import { definePage, cms } from '@webly' import Hero from '@components/Hero' export default definePage({ route: '/blog/:slug', // static segments and :params (one dynamic segment per route) metadata: { title: '{{ data.post.title }}', description: '{{ data.post.excerpt }}' }, queries: ({ params }) => ({ // declare CMS data; the platform resolves it before render post: cms.blogPosts.bySlug(params.slug), // binding a route param enumerates one page per published item recent: cms.blogPosts.list({ limit: 5, sort: { field: 'createdAt', direction: 'desc' } }), }), render: ({ data, params }) => data.post ?
:

Not found

, }) ``` - `@webly` is generated from the website's CMS schema: `cms..list(opts) | bySlug(slug) | byId(id)`; items are `{ id, slug, publishedAt, createdAt, updatedAt, position, ...fields }`, with `image`/`file`/`gallery` fields resolved to `{ id, url, filename, contentType, altText, kind }`. Field keys and option values are typed — typos fail the typecheck. - `reference` / `multi_reference` fields are resolved **one level deep**: `post.author` is the published author item (`AuthorItemRef`, its own reference fields stay ids) or `null` if unpublished/missing; `multi_reference` is always an array. Going deeper (`post.author.company.name`) is a type error — declare a second query instead. - `list` options: `limit` (≤100), `offset`, `sort: { field, direction }`, `filter: { [field]: { eq | neq | contains | startsWith | endsWith | gt | gte | lt | lte | in | nin | exists } }`, `filterLogic: 'and'|'or'`, `search`. Only published items are returned. - Imports allowed: `@webly`, `@components/Name`, `@modules/name`, `@pages/name`, `@modules` (registry), `react`, and allowlisted npm packages pinned to an exact version in the specifier (`clsx@2.1.1`). Not allowed: relative imports, assets, Node built-ins, unpinned packages, `react-dom/*`, two versions of one package. #### Packages available to framework sites `GET /api` → `packages` is the source of truth (name → installed version). Currently: `clsx@2.1.1` (class names), `date-fns@4.4.0` (dates), `marked@18.0.12` (Markdown → HTML string; render with `dangerouslySetInnerHTML`), `slugify@1.6.9`, `lucide-react@1.44.0` (icons; server-rendered SVG), `zod@4.6.2`. The pin must equal the installed version exactly — `import clsx from 'clsx@2.1.1'`; a different pin fails lint with the message `"clsx" is provided at version X; pin "clsx@X"`. Anything else is a lint error: implement it inline or ask for the package to be allowlisted. #### Site helpers from `@webly` - `imageUrl(asset | assetId, { width?, height?, fit?: 'cover'|'contain'|'inside', format?: 'webp'|'avif'|'jpeg'|'png', quality? })` → same-origin `/_webly/img/{assetId}?w=…` URL that the edge resizes on demand and caches forever. `srcSet(asset, [480, 960, 1440], opts)` builds a `srcSet` string. Image assets carry `width`/`height` for aspect-ratio boxes. - `formAction('contact')` → `/_webly/forms/contact`. Use it as `
`; the edge stores submissions (see Forms below). Add `` as a honeypot and optionally ``. - `website_config`: `export default defineWebsiteConfig({ metadata: { title, titleTemplate: '%s | Site', description, language, openGraph, twitter }, redirects: [{ from, to, status? }], headers: { ... }, forms: { contact: { redirect?: '/thanks', webhook?: 'https://…', fields?: ['email', 'message'] } } })` — a literal object (no spreads/computed keys); metadata strings accept `{{ data.x.y }}` and `{{ params.name }}` templates. `headers` are added to HTML responses on top of the platform defaults (`X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS on published hosts). - `not_found` exports a React component (or a `definePage`) rendered as `404.html`. `global_css` is served at `/global.css`. `custom_head_*` / `custom_body_*` are raw HTML injected into the document shell. - **Tailwind CSS v4** is built in: start `global_css` with `@import "tailwindcss";` and use utility classes in any file; the build compiles exactly the classes it finds in your source. Configure with CSS (`@theme { --color-brand: #ff4400; }`, `@layer`, `@utility`); `@plugin`/`@config` JavaScript plugins are not supported and fail the gate with a `css` diagnostic. Plain CSS without the import passes through untouched. - Render runtime: pages are server-rendered in a dedicated V8 isolate with ECMAScript + `TextEncoder` only — no DOM, no `fetch`, no timers that fire, no Node APIs — then hydrated in the browser. Each render has a 5 s CPU budget and the isolate a 256 MB heap; failures land in `renderFailures` with the page and route and never block other pages. - Loop: `str-replace`/`PUT` → `head/check` → fix diagnostics → preview `urls.draft` → `publish`. Never publish without a green check. ### Publishing - POST /v1/websites/{id}/publish — `{ version?, wait? }` (`wait`: seconds to wait for a running build, 0–60, default 60) Publish head, or the version you previewed (when others edit the site too, head may already hold someone's unfinished work). If the build is still running, publish waits for it (up to about a minute); if it is still running after that you get `202` `{ status: "building", published: false, version, next }`: nothing went live and nothing is wrong, so publish that `version` again in about 15 seconds. A build in progress is never an error; only `400` (build failed) is. `400` if it failed, is empty or doesn't exist. History is linear, so the versions between the previous publish and this one go live with it (newer versions stay unpublished): the response's `alsoPublishes` lists, oldest first, the ones written by someone other than you (`{ version, authorName, client, message, createdAt }`; a person counts as one writer across their apps, each API key is its own writer, unattributed versions are left out, empty when publishing an older version), and `next` sums them up. Tell your person what of others' work went live - POST /v1/websites/{id}/unpublish — Take the site offline; head and history untouched - PUT /v1/websites/{id}/password — `{ password: string | null }` (8–128 characters; `null` removes it) puts a password on every host of the site: live, custom domains and the draft preview `→ website` with `protected: true|false`. Visitors get a small password page; the right password unlocks the site in that browser for 7 days. Setting it again (even the same one) signs everyone out. Webly keeps only a hash: the password is never returned, so tell the person what it is, and never write it into site source. Applies at the edge within about 30 seconds. **Hobby and Pro only**: on Free, `402` with `details: { reason: "plan_required", upgradeUrl }`; removing always works, and a password set before a downgrade stays in force. Storage websites take passwords per folder (below), not here (`400`). - POST /v1/websites/{id}/rollback — `{ version }` Re-publish an earlier version (commits it as a new head, then publishes; `202` `status: "building"` like publish when that head's build is still running) - POST /v1/websites/{id}/head/restore — `{ to: "published" | n }` Copy a state into a new head without publishing (discard a draft) - GET /v1/websites/{id}/deployments/status — `status: building|ready|failed|superseded` (`superseded`: a newer version was written while this one waited, so it was skipped; check the newer version's build instead), `canPublish`, `routes`, `createdBy`/`apiKeyId` (who triggered the build; null for anonymous or background builds) (`target=head|published` or `version=n`) - POST /v1/websites/{id}/deployments/ensure — Build a version ahead of time `{ target?|version? }` ### Pages (legacy DB-backed content records, readable via the public API) - POST /v1/websites/{id}/pages · GET /v1/websites/{id}/pages · GET/PATCH/DELETE /v1/websites/{id}/pages/{pageId} - These records are not rendered by the site and do not appear in the owner's dashboard. Prefer source files for pages and collections for content. ### Blog (managed; any site kind) > **Deprecated.** Kept working for existing sites; don't use it for new content. Put content in source files. One call turns "add a blog post" into a post the owner can edit in their dashboard and pages on the live site. - POST /v1/websites/{id}/blog/posts — `{ title, body (markdown), excerpt?, date? (YYYY-MM-DD), tags?: string[], cover?: assetId, slug?, publish? (default true), publishAt? }` `→ 201 { post, collection: { created, addedFields }, rendering: { kind, connected, scaffolded, indexPath, postPathPattern }, notes: string[] }`. First use creates the `blog-posts` collection (fields `title`, `date`, `excerpt`, `body`, `tags`, `cover`; an existing `blog-posts` collection is reused and missing fields added as optional) and, on framework sites, scaffolds pages `blog` (route `/blog`) and `blog-post` (route `/blog/:slug`) that query `cms.blogPosts` — restyle them freely but keep the queries. Static sites need no files: every build generates `/blog/index.html` and `/blog//index.html` from the collection, reusing the ``, `
` and `