# 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.webly.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: https://app.webly.ai (`GET /app` redirects there). `GET /api` lists the npm packages framework sites may import and their pinned versions.

## Get started with an agent

Paste this into any agent: Claude Code, Codex, Cursor, the Claude or ChatGPT app, Grok, Muse. It's the homepage's prompt; the dashboard's copy (**Connect agent**) is the same with a connect code in the link. Its wording is checked by an agent eval (`npm run eval:prompt`), so change it only with that.

```
Set up Webly so you can publish websites and share files and folders for me.
First tell me the first-time setup takes a couple of minutes, then keep me posted as you go.
Open https://api.webly.ai/connect?agent=<your name>&os=<mac|windows|linux|ios|android|web>&can=<what you can do: shell,browser,connectors,secrets> and follow what it says.
Can't open links? Run npx skills add Webly-AI/webly-plugin --skill webly -g -y and use the webly skill.
```

For an agent that runs commands, `/connect` has it install 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`.

**One entry point: `GET /connect`.** Any agent, even one whose only tool is fetching
a URL, can ask Webly how to connect: open
`https://api.webly.ai/connect?agent=<your name>&os=<mac|windows|linux|ios|android|web>&can=<shell,browser,connectors,secrets>`
and follow the Markdown it returns. The prompt from the dashboard's **Connect agent**
carries a code (`/connect/K7QM-4D9XPR?…`): 10 minutes, single use. A code that checks
out is how the agent learns its person is signed in, and whose dashboard it came from;
the prompt's wording never says. It is also the authorization: an agent that can't run
commands or add a connector gets its API key **in that same answer** (when the person
is a workspace owner or admin), shown once, and the code is spent. The first agent to
check in owns the code; its answer carries a `pickup` token for coming back
(`&pickup=…`, e.g. a connector app switching to `can=secrets`). A second, different
agent gets `409` and kills the code; a used code is `410`. An expired code is `410` with
a link for the person to tap where they're signed in (`app.webly.ai/sites?renew={code}`):
it renews that same code for 10 minutes, then the agent opens its link again. Without a
code, an agent that runs commands is sent to anonymous hosting. A request without
`agent` only explains the parameters and changes nothing. The dialog's side is
`POST /v1/connect-sessions`, `GET /v1/connect-sessions/{id}` (with the key's `keyId`, for
Revoke) and `POST /v1/connect-sessions/renew` `{ code }`, for signed-in people only.

**Agents that can't open a browser.** These are the routes `/connect` picks from;
use the first one your host supports.

| Path | Hosts | How |
|---|---|---|
| Skill + OAuth in a browser (best) | Claude Code, Codex, Cursor, Gemini CLI, opencode, VS Code / Copilot, Windsurf, Zed | The prompt above |
| Connector screen + OAuth in the app | Claude (web, desktop, mobile), ChatGPT (developer mode), Grok, Perplexity, Le Chat, Poke | The person adds a custom connector with `https://api.webly.ai/v1/mcp` and signs in there |
| API key as a Bearer header | Muse, Cline, the OpenAI / xAI / Anthropic API MCP tools, hosted bots, CI | The person pastes the **Connect agent** prompt; the agent gets its key in `/connect`'s answer, once (or they make one: Settings → API keys, `POST /v1/api-keys`). Keep it in the host's secure credentials, never the chat; send `Authorization: Bearer wb_…` |

API keys can't claim anonymous sites: print `webly.mjs claim-link` (no `--open`) for the person to open on their phone.

**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 <dir>`
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 <websiteId>`; `--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 <code>` |

**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 <code>`: 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.webly.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 <code>`) 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 <files|folder>… [--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", image, 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"`.
- PUT /v1/workspace/photo — Set a team workspace's photo, shown in the dashboard's workspace switcher: `{ url }` (a public image; link the file, not a page) or `{ base64 }` (the file's bytes) `→ { workspace: { id, name, slug, kind, image } }`. PNG, JPEG, WebP or GIF, checked by its bytes; `413` over 2 MB. A personal workspace shows its person's profile photo instead: `400` with `details.reason: "personal_workspace"`. Needs `websites.manage` (owners and admins) and a workspace-wide key. Change it only when the owner asks. MCP: `set_workspace_photo`.
- DELETE /v1/workspace/photo — Remove it; the workspace shows its initial again. MCP: `remove_workspace_photo`.
- 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 <METHOD> /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}.webly.site` (noindex, no-store).
- The published version is served at `https://{subdomain}.webly.site` 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}.webly.site` too (`urls.vanity`, e.g. `https://fall-out-boy.kev.webly.site`). 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 webly.site address primary, that one first. `urls.primary` is `urls.all[0]`, the address to share; with a primary set (a custom domain, or a webly.site 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.webly.site", 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: () => <main><h1>Hello</h1></main>,\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": "<h1>Hello</h1>"},
    {"path": "style.css", "content": "body { font-family: sans-serif; }"}
  ],
  "message": "First draft"
}'
# { version: 2, changed: true, urls: { draft: "https://draft--zen-wreath-y76r.webly.site", 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.webly.site" } }, 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}.webly.site`. 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). **Free: once per site** (lifetime; Hobby and Pro unlimited): past it `402` with `details: { reason: "subdomain_rename_limit", plan, limit, used, upgradeUrl }`. A taken name is `409` and doesn't use the rename, but check first with the endpoint below.
- GET /v1/websites/{id}/subdomain/availability?label= — Whether a label can become this site's subdomain `→ { label, available, reason?: "invalid"|"taken"|"current", renames: { used, limit, remaining } }` (`limit`/`remaining` `null` = unlimited). Uses no rename.
- PATCH /v1/websites/{id}/vanity — Hobby and Pro: rename the site's label in the namespace `{ label }` → `{label}.{namespace}.webly.site`. 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.
Analytics reach back as far as the plan allows: **Free** 30 days, **Hobby** 1 year, **Pro** all of it. A range that starts earlier is moved up to the window (no error), and every response says so with `retentionDays` (`null` = no limit); a `compare` period that would start before the window comes back as `compare: null`. Older data is kept and shows again after an upgrade.
- 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}.webly.site`) 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, or `domain_limit` (`limit`, `used`, `requested`) when the site's custom domains don't fit the receiving workspace's plan. 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 <code>`) 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.webly.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 (`402 domain_limit` if its custom domains don't fit the receiving plan). 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; <command>`); it sends files up to 1 MiB only, while the Webly helper (`webly deploy <folder> --ticket <ticket> --message "<what changed>"`) 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 `<head>` (`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`. History reaches back as far as the plan allows: **Free** 30 days, **Hobby** 1 year, **Pro** all of it (`historyFrom`: the oldest date shown, `null` = all). Older versions stay stored: reading, diffing, restoring, rolling back to or publishing one is `402` with `details: { reason: "history_limit", version, historyDays, upgradeUrl }` until the workspace upgrades. The head and published versions are always available. The feed below leaves out versions past the window too. (The git clone has every version.)
- 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=<token> <clone>` (or `export WEBLY_GIT_TOKEN=<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 ? <article><Hero title={data.post.title} /></article> : <p>Not found</p>,
})
```

- `@webly` is generated from the website's CMS schema: `cms.<collectionCamelCase>.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.7`. 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 `<form action={formAction('contact')} method="post">`; the edge stores submissions (see Forms below). Add `<input name="_honey" hidden />` as a honeypot and optionally `<input type="hidden" name="_redirect" value="/thanks" />`.
- `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/<slug>/index.html` from the collection, reusing the `<head>`, `<header>` and `<footer>` of `/index.html`; a hand-written `/blog/index.html` is overridden. Content changes re-render the published and head builds in the background (no new source version).
- PATCH /v1/websites/{id}/blog/posts/{postIdOrSlug} — Edit fields; `publish: true|false`, `publishAt` to schedule
- GET /v1/websites/{id}/blog/posts — `status=all` includes drafts/scheduled `→ { enabled, posts: [{ id, slug, title, date, excerpt, tags, status, url, ... }] }`
- GET /v1/websites/{id}/blog — `{ enabled, collection, rendering: { kind, connected, indexUrl, postUrlPattern }, counts, hint }` — check this before touching blog files by hand
Capabilities: `cms.items.read` / `cms.items.write`; the first call additionally needs `cms.schema.write` (creates the collection) and, on framework sites, `source.write` (scaffolds pages).

### CMS Collections
> **Deprecated.** Kept working for existing sites; don't use it for new content. Put content in source files.
- POST /v1/websites/{id}/collections — Create collection (with inline `fields`). Response carries a `warning` while nothing on the site reads the collection (framework: no page queries `cms.<slug>`; static: no file fetches `/public/v1/{siteSlug}/collections/{slug}/items`).
- GET /v1/websites/{id}/schema?usage=true — adds `connected: boolean` per collection (same check)
- GET /v1/websites/{id}/collections · GET/PATCH/DELETE /v1/websites/{id}/collections/{colId}
- GET /v1/websites/{id}/schema — Full CMS schema snapshot

### CMS Fields
- POST /v1/websites/{id}/collections/{colId}/fields — Add field
- PATCH /v1/websites/{id}/fields/{fieldId} · DELETE /v1/websites/{id}/fields/{fieldId}

Field types and what item values must be:
- `plain_text`, `markdown`, `rich_text` — string (≤1 MiB; optional `config.maxLength`)
- `number` — finite number (optional `config.min` / `config.max`)
- `date` — ISO 8601 string
- `color` — hex like `#1a2b3c`
- `option` — one of `config.options` (required, non-empty array of strings)
- `reference` — an item id in this website (optionally restricted with `config.targetCollectionId`)
- `multi_reference` — array of item ids (same rules, ≤200, no duplicates)
- `image` — a **ready image** asset id · `gallery` — array of image asset ids · `file` — a ready asset id

Field keys: lowercase snake_case, start with a letter, max 64 chars. Reserved: id, slug, publishedAt, publishAt, createdAt, updatedAt, position. A field's type cannot be changed after creation.

Item `data` is validated on every write: unknown keys are rejected (add the field first), required/unique are enforced, values are type-checked, and references/assets must exist in the same website. Validation failures return `400` with `details: [{ field, message }]`. On PATCH, `data` is merged; set a key to `null` to clear it.

### CMS Items
- POST /v1/websites/{id}/collections/{colId}/items — Create item
- GET /v1/websites/{id}/collections/{colId}/items — List items (search, filter, sort, depth)
- GET /v1/websites/{id}/items/find?itemId= or ?collectionId=&slug= — Find item
- GET/PATCH/DELETE /v1/websites/{id}/items/{itemId}
- POST /v1/websites/{id}/items/{itemId}/restore · /set-published · GET .../backreferences

`set-published` takes `{ published: true }` (now), `{ publishAt: iso }` (schedule; becomes published automatically when the time passes), or `{ published: false }`. Scheduled items are hidden from `published=true` and from the public API until due.

#### Query parameters for list items
- `published=true|false`
- `search=query` — Text search across all fields
- `filter={"field":{"op":"value"}}` — ops: eq, neq, contains, startsWith, endsWith, gt, gte, lt, lte, in, nin, exists. Keys must be fields of the collection.
- `filterLogic=and|or`
- `sort={"field":"name","direction":"asc|desc"}` — a collection field key, or metadata: id, slug, position, publishedAt, publishAt, createdAt, updatedAt
- `depth=0-3` — Hydrate reference fields (never crosses the website boundary)
- `limit=1-100`, `cursor=` from the previous page's `nextCursor`

### Assets
Two-step upload (preferred; bytes go straight to storage):
- POST /v1/websites/{id}/assets/uploads — `{ filename, mimeType, byteSize, description?, category?, altText? }` → `{ asset (status: pending), upload: { method: "PUT", url, headers, expiresAt } }`
- PUT the raw bytes to `upload.url` with `upload.headers`
- POST /v1/websites/{id}/assets/{assetId}/finalize — verifies size/type and flips `status` to `ready`; returns the asset with its public `url`

An asset's `url` is served by its own site: `https://{site}/_webly/img/{assetId}` (the draft host until the site is published), the original bytes, and the same path on any of the site's hostnames, custom domains included. In page source use the relative `/_webly/img/{assetId}` (or `imageUrl()`), which framework builds also hand you as `asset.url`.

One-step (small files): POST /v1/websites/{id}/assets — multipart field `file` (+ `description`, `category`, `altText`)

Import from the public web (stock photos, a client's logo, brand assets — skip the upload entirely):
- POST /v1/websites/{id}/assets/import — `{ items: [{ url, description?, category?, altText?, filename? }] }` (≤10 URLs, or a single `{ url, … }`) → `{ assets: [asset + sourceUrl], errors: [{ index, url, type, message }], status: "ready"|"partial" }`. Fetched server-side, 30 MiB each, type sniffed from the bytes (link to the file, not an HTML page). Media only: images, video, audio, fonts, PDF. Error `type`: `invalid_url`, `non_public_address`, `unreachable`, `too_large`, `unsupported_type`. `400` only when nothing could be imported. Only import files you have the right to use.

- GET /v1/websites/{id}/assets — `kind=image|file`, `category=`, `search=` (filename, description, alt text), `status=all`; cursor-paginated
- GET/PATCH /v1/websites/{id}/assets/{assetId} — PATCH `{ description?, category?, altText? }`
- DELETE /v1/websites/{id}/assets/{assetId} — archives (URL keeps serving; hidden from listings and CMS validation; still counts toward storage). `?permanent=true` deletes the file and its variants for good and frees the space; pages still linking it break.

Limits: images (png, jpeg, gif, webp, avif, svg) ≤20 MiB; files (pdf, text, csv, json, zip, mp4, webm, mp3, m4a, wav, ogg, fonts, octet-stream) ≤50 MiB. Always set `description` so assets can be found later. Image assets carry `width`/`height` once uploaded.

### Storage websites (sharing files by link)
A website with `kind: "storage"` holds uploaded files instead of a site. There is no build or publish, and nothing reads or scans the files: bytes go straight to storage with presigned URLs, and the API only checks that each upload landed. Upload a batch of files into one **folder**. Every file is live at `https://{label}.webly.site/{folder}/{name}` (on Hobby and Pro the links come back on the workspace's address, `https://{label}.{namespace}.webly.site/…`, the namespace its sites use, once its certificate is live; the flat address keeps working), and the folder at `…/{folder}/` lists them all with **Download all (.zip)** (`…/{folder}/?zip`, streamed, nothing stored). The site's root lists nothing, so a folder is reachable only by whoever has its link; omit `folder` for a random, unguessable one. On Free and without an account, a named new folder gets a random suffix too (`demobowling` → `demobowling-3f9a2c1b5e7d90a1c2b4`, the name kept as its `folderLabel`), since its link is all that keeps it private; Hobby and Pro keep the exact name, and a folder there can also have a password. Always use the `folder` a call returns. When someone uploads several files, ask whether they want **one link per file or one folder link**; both come back, so show the one they chose. Only images, video, audio, PDF and plain text open in the browser; every other type (HTML included) downloads.

- POST /v1/websites/{id}/objects/uploads — `{ files: [{ name, byteSize, mimeType? }] (1–1000; for more, call again with the `folder` the first call returned), folder?, expiresIn?: "1d"|"3d"|"7d"|"30d"|"60d"|"1y"|"never", password? }` (`password`, Hobby and Pro: protects the folder as it's created, as below) `→ 201 { folder, folderUrl, zipUrl, expiresAt, uploadExpiresAt, protected?, files: [{ id, name, path, url, size, contentType, upload? | partSize? + parts? }] }`. Files up to 100 MiB get `upload: { method: "PUT", url, headers, command }`: PUT the exact bytes with those headers (`command` is a ready `curl -T "$FILE" …` line; set `FILE=./path;` first). Larger files get `parts: [{ partNumber, url, size, command }]` of 100 MiB each: PUT each byte range `[(partNumber-1)*partSize, +size)` to its URL, in any order, one or two at a time, and retry any part that fails; a part can be re-sent on its own (`command` cuts the range with `dd`). Large uploads on ordinary connections do get reset now and then, so always retry. URLs are valid for 1 hour. A `name` may include subfolders (`src/main.cpp`, up to 10 deep; `.` and `..` are dropped; hidden names like `.gitignore` are kept as they are) to keep a folder's structure: the folder page lists subfolders, each has its own page (`{folderUrl}src/`) and zip (`?zip`), and the folder's zip keeps the paths. `name` in listings is the file's own name; `path` has the rest. Empty (0-byte) files are fine. Duplicate paths in a batch become `name-2.ext`; a path already in use is `409 path_taken`. Never send file bytes through MCP or JSON.
- POST /v1/websites/{id}/objects/finalize — `{ ids }` checks each upload landed at its declared size (joining multipart parts) and puts its link live `→ { status: "ready"|"partial"|"failed", files: [{ ok, id, url, … } | { ok: false, id, error }] }`. Idempotent; re-upload and finalize again on a failure.
- GET /v1/websites/{id}/objects — `folder?`, `cursor?`, `limit?` (default 200, max 1000), path order → `{ items: [{ id, path, folder, folderLabel, folderProtected, name, url, size, contentType, expiresAt, createdAt }], nextCursor }`. Storage files are only managed here: the `/assets` routes and `/img` don't see them.
- GET /v1/websites/{id}/objects/{objectId}/entries — the files inside an archive upload `→ { folder, items: [{ path, name, size, url }] }`.

Archives (big folders): for more than about 500 files, zip the folder and upload the zip as one file with `"archive": true` (alone in its batch, `.zip`, into a new folder). Finalize reads its table of contents once and refuses zips with encrypted entries, compression other than stored/deflate, unsafe paths (`..`, absolute, backslashes), more than 50,000 entries or a table of contents over 32 MiB (`422 bad_archive`). The folder is then served as if unpacked: the folder page lists files and subfolders, `{folderUrl}src/main.cpp` streams that file out of the zip (deflate is inflated on the way), and `{folderUrl}?zip` is the zip itself (a subfolder's `?zip` builds one, up to 500 files). Paths inside the zip become the folder's paths, hidden files included; macOS `__MACOSX/` and `.DS_Store` entries are hidden. In listings the archive is one item, `{ name, path: "/{folder}/:archive.zip", archive: { files } }`. An archive folder is read-only: uploading or moving files into it, or moving the archive, is `409 archive_folder`; an archive can't join a folder that already holds files (`409 folder_taken`). Rename the folder with its label; delete it with `DELETE …/objects/folders/{folder}`.
- PATCH /v1/websites/{id}/objects/{objectId} — `{ name }` renames the file as listings and the dashboard show it `→ object`. Display only: `path` and `url` keep the original name.
- POST /v1/websites/{id}/objects/{objectId}/move — `{ folder? }` moves the file into that folder (created if new; omit for a new random folder of its own) `→ object` with its new `path` and `url`. The old link stops working. A name already in that folder becomes `name-2.ext`.
- PATCH /v1/websites/{id}/objects/folders/{folder} — `{ label }` (empty or null clears) sets the folder's display name, returned as `folderLabel` `→ { folder, label }`. The folder link keeps the real name. The label goes when the folder's last file does.
- PUT /v1/websites/{id}/objects/folders/{folder}/password — `{ password: string | null }` (8–128 characters; `null` removes it) protects the folder link, every file in it, its subfolders and its `?zip` `→ { folder, protected }`; listings show `folderProtected`. Each folder has its own password: unlocking one doesn't open another. An unlock lasts 7 days in that browser; setting the password again signs everyone out. Only a hash is kept, so give the person the password with the link. **Hobby and Pro only** (`402 plan_required` with `upgradeUrl` on Free, and for anonymous uploads); removing always works. The password goes when the folder's last file does.
- DELETE /v1/websites/{id}/objects/{objectId} — deletes the file now; its link stops working and its storage is freed.
- DELETE /v1/websites/{id}/objects/folders/{folder} — deletes every file in the folder `→ { deleted, folder, files }`. The folder link and every file link in it stop working.

Folders and the top level: a folder exists while it holds files (there are no empty folders). The dashboard shows a file alone in an unnamed (random) folder as a loose file at the top level, so `move_object` without a folder "moves it to the top level". Names are display-only and safe to change; moves and deletes change or end links, so tell the person and share the new `url`.

Limits per workspace (all assets, storage files and site files together, archived assets included until permanently deleted): **Free** 1 GB, files up to 100 MiB, expiry 1d/3d/7d (default 7d); **Hobby** 50 GB and **Pro** 200 GB, files up to 2 GiB, any expiry (default never). Only finished files count as used; uploads in progress don't. Begin refuses a batch that couldn't fit, and finalize checks again as each file finishes (a file that no longer fits stays unfinished with a `storage_limit` error; free space and finalize again, or delete it). Past the quota: `402` with `details: { reason: "storage_limit", plan, limit, used, requested, upgradeUrl }`; too big a file: `413 file_too_large`. Uploads in progress have their own allowance on top of storage: **Free** and anonymous 1 GB, **Base** 5 GB, **Max** 10 GB, and at most 2000 at once; past it, `429 uploads_in_progress` (finish, resume or clear them). An unfinished upload is cleared after **an hour with no activity**: any call for it, or any part reaching storage, counts, so a long upload that keeps sending is never cut off. Starting the same file (name and size) more than 5 times in a short while is `429 upload_retries` with `details.retryAfterSeconds` (5 an hour after that); resuming doesn't count.

Interrupted uploads (dropped connection, restart, a part that kept failing) can be **resumed** instead of started over: storage keeps the parts that already landed.
- POST /v1/websites/{id}/objects/{objectId}/resume — `→ { id, name, size, uploadedBytes, uploadedParts?, complete?, upload? | partSize? + parts? }`: what already landed, and fresh URLs (with `command`s) for only what's missing. Then finalize. `409 already_finished` if it's done; `404` once it was cleared.
- GET /v1/storage/uploads — `websiteId?` → `{ items: [{ id, websiteId, websiteName, kind: "object"|"asset", name, path, size, uploadedBytes, startedAt, lastActivityAt, clearsAt }], totalBytes }`: every unfinished upload in the workspace, with progress read from storage.
- DELETE /v1/storage/uploads — `websiteId?` cancels every unfinished upload (or one website's) `→ { cleared, bytes }`, freeing the allowance. Cleared uploads can't be resumed. `DELETE …/objects/{objectId}` cancels one. Expired files are deleted within a minute and stop counting. `GET /v1/billing` reports `storage: { used, limit, maxFileBytes, uploading, uploadingLimit }`.

#### Image variants
- Sites reference variants lazily: `/_webly/img/{assetId}?w=800&h=600&fit=cover|contain|inside&format=webp|avif|jpeg|png&q=80` on any hostname of the site (or `imageUrl()` / `srcSet()` from `@webly`). Variants are generated on first request, stored next to the original and cached immutably. Max 4000 px per side; svg/gif/non-images pass through unchanged.
- POST /v1/websites/{id}/assets/{assetId}/variants — `{ width?, height?, fit?, format?, quality? }` pre-generate one variant `→ { path, url, contentType, size, width, height, passthrough, original }` (`201` created, `200` already cached).
- GET /img/{websiteId}/{assetId}?w=… — public origin endpoint the edge proxies to (no auth).

### Emailing links
- POST /v1/websites/{id}/share/email — `{ to: ["ann@example.com"], target: "site" | "draft" | { folder } | { file }, message?, password? }` `→ { url, sent, failed }`. Emails a link to 1-10 people, each in their own email (plain text and HTML, no tracking), from Webly with replies going to the signed-in person when there is one. Webly builds the link from `target` (the published site, its draft preview, a storage website's folder, or one of its files by `id`); you can't send an arbitrary URL. `message` is plain text, up to 1000 characters. `password` is the link's password, checked before it is put in the email; optional, for when the person wants it in the email; a link without one refuses it (`400 no_password`) and a mismatch is `400 wrong_password`.
  Hobby and Pro only (`402 plan_required`, `details.upgradeUrl`). A folder needs `assets.write`; site and draft links need `publishing.manage`. Limits: 20 recipients an hour and 100 a day per workspace, 5 a day to any one address (`429`, `Retry-After`). `409 not_published` for `"site"` before the first publish; `503 email_not_configured` when the server has no email service; `502 email_failed` when no email could be sent. Confirm recipients with the person before sending.

### Forms
Published sites accept `POST /_webly/forms/{name}` on their own hostname (`name`: lowercase letters, digits, `-`, `_`, ≤64) with urlencoded, multipart or JSON bodies. HTML posts get a `303` to the form's configured `redirect` (or `_redirect` field, or back to the page with `?submitted=name`); fetch/JSON posts get `{ ok, id }`. `_honey` is a honeypot (non-empty → accepted but dropped); a visitor gets 20 submissions at once then one every 30 seconds, a site 300 at once then 600 per hour; beyond that submissions are dropped silently. Draft hosts respond as if stored but keep nothing. ≤50 fields, ≤10 KB per value, ≤64 KB total; `fields` in `website_config.forms` whitelists what is stored; `webhook` receives a JSON POST per submission.
- GET /v1/websites/{id}/forms — Forms seen so far `→ { items: [{ form, total, unread, spam, held, lastSubmittedAt, endpoint }], quota: { used, limit, held, resetsAt }, howTo }`. Each workspace takes a number of submissions per calendar month (UTC), across all its sites: **Free** 1,000, **Hobby** 10,000, **Pro** 100,000. A submission counts when it arrives (spam that gets past the honeypot and rate limits included). On **Hobby** and **Pro**, marking it `spam` gives the slot back and lets one held submission through; un-marking takes the slot again. On **Free**, spam always counts. Deleting never gives a slot back. Past it, submissions are still accepted and saved but **held**: they're left out of every list and read, no alerts or webhooks fire, and the owner and admins get one email a month saying so. Upgrading shows them (their alerts and webhooks are not sent afterwards); otherwise held submissions are deleted when the allowance resets on the 1st. Tell the owner when `quota.held` isn't 0, and give them `upgradeUrl` from `GET /v1/billing`.
- GET /v1/websites/{id}/forms/{form}/submissions — `status=new|read|archived|spam|all` (default `all` = everything except spam), `format=csv` (≤1000 newest rows), cursor-paginated
- GET /v1/websites/{id}/forms/{form}/submissions/{submissionId} — Reading marks `new` → `read`
- PATCH /v1/websites/{id}/forms/{form}/submissions/{submissionId} — `{ status }`
- DELETE /v1/websites/{id}/forms/{form}/submissions/{submissionId}
- GET /v1/websites/{id}/form-notifications — Who is emailed when any form on the site gets a submission `→ { enabled, me, workspace, collaborators, emails, subscribers, emailConfigured, upgradeUrl }`. Off by default; `me` is `null` for API keys. Emails are sent on Hobby and Pro only: on Free the settings save but nothing is sent, and `upgradeUrl` (otherwise `null`) is the Billing page to give the owner.
- PATCH /v1/websites/{id}/form-notifications — `{ me?, workspace?, collaborators?, emails? }`; only fields sent change. `me`: the signed-in person (400 for API keys). `workspace`: every workspace member. `collaborators`: everyone invited to this site. `emails`: replaces the list of up to 10 other addresses. People who lose access stop getting emails; a site transfer clears the list. Each email carries the site, form, fields (≤2000 chars) and a dashboard link, and its Reply-To is the visitor's `email` field when that is a valid address; at most 30 at once then 60 an hour per site, and 50 a day to any one address. Change recipients only when the owner asks.
Capabilities: `forms.read` (every role), `forms.write` (every role). Spam is pruned after `FORMS_SPAM_RETENTION_DAYS` (30).

### Plans and billing
Each workspace is on a Webly plan that caps how many websites (not deleted) it can hold: **Free** 25, **Hobby** and **Pro** unlimited (storage websites don't count; `limit` is `null` when unlimited), and how much it can store: **Free** 1 GB, **Hobby** 50 GB, **Pro** 200 GB across all assets, storage files and site files (source, pages and media, each distinct file once across a site's versions). Plans also set custom domains (0 / 5 / 25), form submissions per month (1,000 / 10,000 / 100,000), version history and analytics (30 days / 1 year / all) and renames of each site's webly.site subdomain (1 / unlimited / unlimited); each is described with its endpoint. The activity log (`GET /v1/audit-log`) is on every plan. Pro includes **5 people** (members plus pending invitations); each one past that is billed per seat on the workspace's subscription, prorated, as people are invited and removed (`seats: { used, included, extra, pricePerSeat }` in `GET /v1/billing`). Inviting is done by signed-in people in the dashboard; agents, API keys and site visitors never take a seat. Creating, restoring, duplicating, moving or accepting a transfer of a website past the limit fails with `402 payment_required` and `details: { reason: "plan_limit", plan, limit, used, upgradeUrl }`. After a downgrade every website stays live, but only the oldest `limit` stay editable: changing the others (source, publishing, CMS, pages, assets, domains) fails with `402` and `details.reason: "over_plan_limit"` until the workspace upgrades or deletes websites. Reading always works. A workspace may also be on a plan **comped by Webly** (`source: "comp"`, e.g. founders), with no subscription. When a comp ends, the workspace is back on its own plan (Free, or what it pays for) within a few hours. The Webly badge comes back if that plan is Free. **You cannot upgrade for the owner**: tell them the limit and give them `upgradeUrl` (the dashboard's Billing page), or delete a website they no longer need, with their say-so. Dashboard links (`dashboardUrl`, `billingUrl`, `upgradeUrl`) carry `?workspace={slug}`: dashboard paths name no workspace (`https://app.webly.ai/sites`, `/files`, `/sites/{id}`), and the parameter switches the person's browser to the right one. Pass them on unchanged.
- GET /v1/billing — `→ { enabled, plan, planName, source: "subscription"|"comp"|"free", comp: { plan, expiresAt, note } | null, status, currentPeriodEnd, cancelAtPeriodEnd, sites: { used, limit }, storage: { used, limit, maxFileBytes, uploading, uploadingLimit }, domains: { used, limit }, formSubmissions: { used, limit, held, resetsAt }, seats: { used, included, extra, pricePerSeat }, historyDays, analyticsDays, plans: [{ id, name, sites, storageBytes, maxFileBytes, uploadingBytes, customDomains, formSubmissionsPerMonth, historyDays, analyticsDays, subdomainRenames, includedMembers, price, current }], billingUrl }` (`null` = unlimited). Needs `billing.read` (full_editor, admin) and a workspace-wide key.
- POST /v1/billing/checkout — `{ plan: "hobby" | "pro" }` `→ { url }` (signed-in admins only, via the dashboard)
- POST /v1/billing/sync — `{ sessionId }` Apply a finished Checkout session (the dashboard calls it on return from Stripe; signed-in admins only)
- POST /v1/billing/portal — `→ { url }` Stripe customer portal: switch plan, update card, invoices, cancel (signed-in admins only)

### Vanity namespace (Hobby and Pro)
A Hobby or Pro workspace gets a generated namespace (`calm-otter-42`) the first time it needs one, and every site then also answers at `https://{vanity}.{namespace}.webly.site` (`website.vanity` is `"{vanity}.{namespace}"`; `urls.vanity` is the published address once the namespace's certificate is active, and `null` while it is pending; drafts stay on the `draft--` host). The flat `{subdomain}.webly.site` keeps working. The owner can rename the namespace: **Hobby 5 times, Pro 10 times**, lifetime. Every site's vanity address moves with it and the old ones stop working at once; the old name stays reserved to that workspace (it may take it back, which counts as a rename) and is never given to anyone else. A new namespace needs a certificate, so its addresses can take about a minute to work over HTTPS (`certificate: "pending"` → `"active"`). After a downgrade the namespace and its addresses stay; only renames stop. Renames are scarce: change it only when the owner asked for that exact name.
- GET /v1/namespace — `→ { namespace, pattern: "{site}.kev.webly.site", example, plan, renames: { used, limit, remaining }, certificate: "active"|"pending"|"none", upgradeUrl }`. `namespace` is null on Free. Needs `billing.read` and a workspace-wide key.
- GET /v1/namespace/availability?name= — `→ { name, available, reason?: "invalid"|"taken"|"current" }`. Names are 3-20 lowercase letters, digits or inner hyphens; not reserved, not `anon-…`, not shaped like a generated name (`word-word-1234`), and not another workspace's namespace (current or former) or a site's flat subdomain.
- PUT /v1/namespace — `{ name }` Rename (admin). `402` with `details.reason: "plan_required"` on Free or `"namespace_rename_limit"` (`limit`, `used`, `upgradeUrl`) when used up; `409 namespace_unavailable` if taken; `400` if invalid. Renaming to the current name is a no-op. The new name's addresses work over HTTPS once `GET /v1/namespace` reports `certificate: "active"` (about a minute): check it before sharing them, and tell the owner when it's live.

### Domains
- GET /v1/domains — Every custom domain in the organization (the websites you can see), each with `website: { id, name, subdomain }`, status and pending DNS records
- GET /v1/websites/{id}/domains — The platform subdomain (`kind: "platform"`) plus custom domains, each with status and pending DNS records
- POST /v1/websites/{id}/domains — `{ hostname }` Add a custom domain; returns `dns.records` to configure. Custom domains are per workspace, across its sites: **Free** none, **Hobby** 5, **Pro** 25; past it `402` with `details: { reason: "domain_limit", plan, limit, used, upgradeUrl }`. Domains kept after a downgrade keep working. Every custom domain carries `dns`, live ones too (for reference or to check the records again; their `dns.instructions` say so). `503` if custom domains are not enabled on the server
- GET /v1/websites/{id}/domains/{domainId}
- POST /v1/websites/{id}/domains/{domainId}/verify — Re-check DNS and the certificate now (also returns `sslStatus`, and for a `www.` domain with `apexForward`, whether the bare domain forwards to it: `apexForward.ok` and `apexForward.detail`). Pending domains are re-checked automatically every few minutes, so calling this is optional
- POST /v1/websites/{id}/domains/{domainId}/connect — `{ redirectUri? }` For a domain not yet live: if the owner's DNS can be set for them, returns `{ provider: { id, name }, method, url }`. `method: "oauth"` means the DNS is at Cloudflare (any hostname): the owner signs in to Cloudflare and approves, and Webly writes the records into their zone and gives up the access straight away. For a www domain it also forwards the bare domain (a Cloudflare redirect rule) when nothing is served there yet and the owner allowed redirects; if `apexForward` still isn't ok after verifying, relay it. Records of the owner's in the way (an A/AAAA/CNAME at that name) are never replaced silently: after signing in they see the exact changes (delete / update / create) and approve or cancel them in that tab. Approving switches the hostname to Webly at once, so for a site that is live today offer the manual records (TXT first, switch once active) instead. `method: "domain_connect"` (www domains, providers that onboarded our template): the owner signs in at their provider and approves, and the provider adds the CNAME and forwards the bare domain. Either way the owner opens `url` in a browser while signed in to Webly. `{ provider: null, url: null }` means relay the manual `dns.records`. `redirectUri` must be on https://app.webly.ai (default: the site's Domains page)
- POST /v1/websites/{id}/primary — `{ hostname }` Make any live address primary: an active custom domain or one of the site's webly.site addresses (a hostname, or a URL exactly as `urls.all` shows it). Every other address, custom domains included, 308-redirects to it. A custom domain is the default once one goes live; choose a webly.site address only when the owner asks, and tell them visitors to their custom domain will land there. Choosing a custom domain (here or below) drops a webly.site choice, and while one is chosen a custom domain going live doesn't take over. Returns `{ primary, urls }`; a 400 lists the allowed hostnames in `details.allowed`.
- POST /v1/websites/{id}/domains/{domainId}/primary — Canonical hostname; every other hostname 308-redirects to it when visited directly. The first custom domain to go live on a website becomes primary by itself (`isPrimary: true`), unless a primary was already chosen (a webly.site address, via POST /v1/websites/{id}/primary); a later one doesn't take over, so use this to switch. A page loaded in an iframe (the dashboard's preview, an embed) is served at the address it was asked for, so the preview keeps working if the custom domain isn't reachable from the viewer. The redirect is sent with `Cache-Control: no-store`, so browsers don't remember it and switching or removing the primary address takes effect at once for them (a browser that cached an older Webly redirect, from before redirects were sent this way, can keep using it until its cache clears). A domain removed and added again (or whose records were deleted and restored) can show "server IP address could not be found" in a browser for up to the zone's negative-cache time (30 minutes by default on Cloudflare) even though Webly serves it: the viewer's DNS remembered the "no such domain" answer. It clears by itself (flushing the device's DNS cache helps only if the answer is cached on that device, not by a router or ISP).
- DELETE /v1/websites/{id}/domains/{domainId} — Returns `{ deleted: true, dnsCleanup }`. `dnsCleanup` (`{ provider: { id, name }, url }`, otherwise null) is set when Webly wrote this domain's DNS records at Cloudflare: ask the owner whether to delete them too (they usually want to) and, if yes, give them `url`. They sign in at Cloudflare once and only the records Webly added (tagged "Added by Webly") and its bare-domain redirect are removed. The link works for 24 hours.

Domain statuses: `pending_dns` (waiting for the routing record) → `pending_verification` (DNS found, certificate issuing) → `active` (serving over HTTPS), or `failed` with `failureMessage`. A domain only serves once it is `active`; a live domain whose DNS later moves away turns `failed` and stops serving. Max 10 custom domains per website, within the workspace's plan limit (above). Custom domains serve the same `publishedVersion` as the platform subdomain.

**Bare domains (`example.com`).** A root domain can't hold a normal CNAME. It works directly only when the owner's DNS provider supports CNAME flattening, ALIAS or ANAME at the root (Cloudflare, Namecheap, Porkbun, DNSimple…). Otherwise, and when unsure, add `www.example.com` instead: that domain comes back with `apexForward: { from, to, instructions }`, telling the owner to turn on domain forwarding for `example.com` → `https://www.example.com` at their registrar (free at most registrars). `apexForward` disappears once the bare domain is connected as its own custom domain. Make `www` the primary once it is active.

DNS records: each has `{ type, name, value, purpose, required }`. Only the routing record is required: a `CNAME` from the subdomain (e.g. `www`) to the returned target. For a bare domain (`example.com`, name `@`) the DNS provider must support CNAME flattening, ALIAS or ANAME; if it does not, tell the owner to use `www.example.com` and forward the bare domain to it at their registrar. The optional `TXT` records verify ownership and issue the certificate before traffic moves, so a domain that is already live elsewhere can switch with no downtime: add them first, wait for `active` (ownership verified and certificate deployed; `pending_verification` still lacks the certificate), then change the CNAME. `active` means Webly is ready, not that traffic has moved: confirm it by checking that the host's own HTTPS response now comes from Webly (the helper's `domain-watch` does this; Webly's responses carry `X-Webly-Version`).

**Setup progress (`next`).** Every custom domain in a response carries `next: { step, of: 3, label, action, checkAgainSeconds? }`. The steps are 1 "Add DNS record" (`pending_dns`), 2 "Issue HTTPS certificate" (`pending_verification`) and 3 "Live" (`active`). A `failed` domain gets `label: "Needs attention"`. `action` says what to tell the owner and do next.

Agents should:
- For a bare domain, ask the owner whether to connect `www` (recommended) or the root.
- Call `/connect` first; if it returns a `url`, give the owner that link (one sign-in and approve at their DNS provider) and offer the manual records as the fallback. Otherwise relay the records exactly as returned.
- Removing a domain: if the response has `dnsCleanup`, ask the owner whether to also delete the DNS records Webly added, and if yes give them `dnsCleanup.url`.
- Keep checking in the background until the domain is live. Use the Webly helper's `webly.mjs domain-watch <hostname>` (no credentials; exits when Webly serves the host over HTTPS), or call `/verify` every `checkAgainSeconds`.
- Report each step as it completes.

A hostname belongs to one website. Adding a hostname that another website already holds returns `409`. The one exception: if that other claim never went live and is more than 7 days old, it is released and the new claim succeeds. A domain that has ever been `active` stays with its website through any later failure until it is removed. A website can re-add its own hostname after it fails, to retry from scratch.

### Public API (No Auth)
- GET /public/v1/{siteSlug}/collections/{colSlug}/items — Published items
- GET /public/v1/{siteSlug}/items/{itemSlug}
- GET /public/v1/{siteSlug}/pages · GET /public/v1/{siteSlug}/pages/{path}

## Agent-safe workflow

1. Framework sites: `put_source_file` / `str_replace` → each write is a new draft version and starts a background preview build. Static sites: `str_replace` with `{ path, edits }` for small text edits, `deploy_files` for a few small files, `begin_deploy` for a folder on disk (a build's `dist/`), then the Webly helper's `webly deploy <folder> --ticket <ticket>` or the ticket's `upload.command`. The live site is unchanged either way.
2. `check_head` and fix every diagnostic and render failure before going further.
3. Open `urls.draft` (or send it to the user) and confirm it looks right.
4. `publish_website` only after approval. It fails safe: a red gate never replaces the live site.
5. If something is wrong live: `rollback_website` to the last good version, or `unpublish_website` to take it offline immediately.
6. Concurrency: several people and agents can edit one site at once; there is no lock to take. Prefer `str_replace` for small edits (it always applies to the current head). For full-file writes and deletes send `baseVersion` (the version you read): edits to different lines are merged, overlapping ones come back as `409 merge_conflict` with both sides — re-read, re-apply, resend. `wait_for_changes` (`GET /v1/websites/{id}/source/feed?since=N&wait=30`) shows what others wrote since version N and waits for the next write. Publish with `version` set to what you previewed. Every version records who wrote it (`list_versions`: `authorName`, `client`, `baseVersion`). A burst of framework source writes builds only the newest version: older background builds still waiting end as `superseded` (the published version and builds you asked for with `publish` or `ensure_deployment` always run). Static sites: `deploy_files` needs `expectedHeadVersion` (the version you read) to change or delete existing files (`400 base_required` without it), and merges onto newer writes the same way (`merged`, or `409 merge_conflict` naming who); a deploy ticket's upload (`webly deploy`, `upload.command`) takes `baseVersion`.
7. CMS item changes re-render framework builds automatically within seconds; `rebuild_website` exists for forcing it.

## Client SDK

`@webly/sdk` (`packages/sdk` in this repo; zero dependencies, Node 18+/Bun/Deno/browsers/Workers) mirrors every endpoint 1:1 with typed inputs, responses and errors:

```ts
import { Webly, WeblyError } from '@webly/sdk'
const webly = new Webly({ apiKey: process.env.WEBLY_API_KEY! })
const site = await webly.websites.create({ name: 'Studio' })
await webly.source.putFile(site.id, { file: { type: 'page', name: 'home' }, content })
const check = await webly.source.check(site.id)          // quality gate
if (check.ok) await webly.publishing.publish(site.id)
await webly.publishing.waitForBuild(site.id, 'head')      // polls deployments/status
for await (const item of webly.cms.allItems(site.id, colId)) { /* cursor pagination handled */ }
```

Namespaces: `websites` (incl. `emailLink`, `renameVanity`), `namespace` (`get`, `check`, `rename`), `billing` (`get`), `publishing`, `source` (framework), `deploy` (static), `cms`, `assets` (incl. one-call `upload`, `import`, and `createVariant`), `objects` (storage websites; one-call `upload` handles parts), `forms` (`list`, `submissions`, `get`, `setStatus`, `delete`, `exportCsv`), `domains`, `apiKeys`. Errors throw `WeblyError { status, kind, message, details }` with `isInvalidLease` / `isSourceChanged` helpers; `429` is retried using `Retry-After`.

## MCP Server

Remote (preferred): Streamable HTTP at `POST https://api.webly.ai/v1/mcp` with `Authorization: Bearer wb_...`. Stateless; `tools/list` only returns the tools your key's role can call.

```json
{ "mcpServers": { "webly": { "url": "https://api.webly.ai/v1/mcp", "headers": { "Authorization": "Bearer wb_your_key" } } } }
```

MCP clients that support OAuth can omit the header: the server's 401 challenge starts sign-in, and the person picks All my workspaces (the default) or one workspace, and an access level (`webly:content`, `webly:edit`, or `webly:admin`, the default and the one creating websites needs). See Workspaces (OAuth) above.

Local stdio alternative: `WEBLY_API_KEY=wb_... npx tsx src/mcp.ts` (same tools).

Tools map to the endpoints above:

Websites: list_websites, create_website, get_website, update_website, rename_subdomain, rename_vanity, delete_website, restore_website, get_analytics, query_analytics, get_analytics_timeseries, get_realtime_analytics, list_move_destinations, duplicate_website, move_website, start_website_transfer, get_pending_transfer, cancel_website_transfer, accept_website_transfer
Source: deploy_files, begin_deploy, get_source_summary, list_source_files, read_source_files, delete_source_files, list_versions, list_changes, wait_for_changes, get_git_access, get_deployment_status, ensure_deployment
Publishing: publish_website, unpublish_website, set_website_password, rollback_website, restore_head
Pages: create_page, list_pages, get_page, update_page, delete_page
Blog: add_blog_post, update_blog_post, list_blog_posts, get_blog
CMS: create_collection, list_collections, get_collection, update_collection, delete_collection, get_schema, add_field, update_field, delete_field, create_item, list_items, get_item, find_item, update_item, delete_item, restore_item, publish_item, get_backreferences
Assets: list_assets, begin_asset_upload, finalize_asset_upload, import_assets, get_asset, update_asset, delete_asset, create_image_variant
Storage (kind "storage"): begin_object_upload, resume_object_upload, finalize_object_upload, list_uploads_in_progress, clear_uploads_in_progress, list_objects, list_archive_entries, move_object, rename_object, label_folder, set_folder_password, delete_object, delete_folder
Sharing: email_link
Forms: list_forms, list_form_submissions, get_form_submission, update_form_submission, delete_form_submission
Billing: get_billing
Namespace (Hobby and Pro): get_namespace, check_namespace, rename_namespace
Domains: add_domain, list_domains, get_domain, connect_domain, verify_domain, set_primary_domain, set_primary_address, remove_domain
Claiming (signed-in people only; hidden for API keys): create_claim_code, claim_anonymous_site
Access: whoami, list_workspaces, create_api_key, list_api_keys, get_api_key, update_api_key, rotate_api_key, revoke_api_key, list_audit_log

Stdio configuration:
```json
{
  "mcpServers": {
    "webly": {
      "command": "npx",
      "args": ["tsx", "src/mcp.ts"],
      "env": {
        "WEBLY_API_URL": "http://localhost:3001",
        "WEBLY_API_KEY": "wb_your_key"
      }
    }
  }
}
```
