{"openapi":"3.1.0","info":{"title":"Dotsy API","version":"1.0.0","description":"Build, edit and publish Dotsy sites over HTTP. The dashboard, the `dotsy` CLI and the MCP server all use this API.\n\nProduction base URL: `https://api.dotsy.ai`. The **Try it** console in this reference sends requests to the origin you are reading it on, so it needs no host setting.\n\n## Authentication\n\nSend `Authorization: Bearer <token>` on every request. Three kinds of token work:\n\n- **Account API key** (`pga_…`). Create one in the dashboard or with `POST /api/v1/account/keys`. It acts as one account.\n- **OAuth token.** MCP clients that sign in with OAuth send their opaque access token in the same header.\n- **Guest token** (`pgg_…`). `POST /api/v1/anon/sites` returns one with a new site. It works on that one site only, and publish, rename, delete, domains, imports and asset writes need an account instead.\n\nTo choose the workspace a request acts in, send `X-Dotsy-Workspace: <workspace id or slug>`. It applies to sign-in sessions and OAuth tokens. A `pga_` key is tied to one account and ignores it. A workspace you don't belong to answers `403`.\n\n## Errors\n\nFailures answer with a JSON body `{ \"error\": \"<code>\", \"hint\": \"<what to do next>\" }`. `error` is a stable machine code, so branch on it and not on the text. Validation failures add `issues`, the list of problems, and some errors add fields that belong to them, such as `jobId` or `keys`. Every error response in this reference uses the `ApiError` schema.\n\n## Lists and paging\n\nEach list operation that supports paging documents `limit` (1 to 1000) and `cursor`. Send `limit` to page. The response then carries `nextCursor`: a string while more results remain, and `null` on the last page. Pass it back unchanged as `cursor` to get the next page. Cursors are opaque, so don't build or parse them.\n\n## Sites, slugs and ids\n\nSite operations live under `/api/v1/sites/{slug}`. `{slug}` takes the site's slug or its id (a UUID, returned as `id`). A slug changes when the site is renamed, so keep the id if you need a handle that stays put.\n\n- While a renamed, published site's old slug stays reserved, it answers `301` for GET and HEAD and `308` for every other method. `Location` holds the same path under the current slug.\n- A site id answers `307` with `Location` set to the path under the current slug.\n\n**Follow redirects** (`curl -L`, and the default in `fetch`), then use the slug from `Location` from that point on. Slug-scoped API keys and guest tokens only work with the site's current slug.\n\nContent keys and asset paths in `{key}` and `{path}` can contain `/`. Percent-encode each segment. A `/` may be sent raw or as `%2F`.\n\n## Rate limits\n\nWrites (POST, PUT, PATCH and DELETE under `/api/v1/sites/…`) share a budget of 600 per minute per account. Raw asset uploads (`PUT …/assets-raw/…`) have their own budget of 3,000 per minute, and sending site invites has another of 30 per minute. Reads are not counted. Over budget, you get `429` with `error: \"rate_limited\"` and a `Retry-After` header in seconds.\n\n## Long-running work\n\nImports, large renames and large changesets answer `202 Accepted` with a `job` object (`id`, `kind`, `status`). Poll `GET /api/v1/sites/{slug}/jobs/{id}` every few seconds until `status` is `succeeded` or `failed`. An operation that finishes right away returns its result and no job."},"servers":[{"url":"/"}],"tags":[{"name":"Account","description":"Your account, its workspaces and its API keys."},{"name":"Sites","description":"Create, list, read, update and delete sites."},{"name":"Jobs","description":"Poll long-running work (imports, bulk content writes, renames) by job id."},{"name":"Content","description":"Read and write page bodies and other content files in a site's draft."},{"name":"Pages","description":"List a site's pages with their routes, titles and draft state."},{"name":"Routes","description":"Map URL paths to content, including redirects."},{"name":"Assets","description":"Upload, list and delete images, fonts and other files a site serves."},{"name":"Import","description":"Bring an existing website into Dotsy from its URL."},{"name":"Draft & publish","description":"Review what changed in the draft, then publish it or discard it."},{"name":"Domains","description":"Connect custom domains to a site and check their status."},{"name":"Members","description":"Invite people to a site and manage their roles."},{"name":"Brief","description":"The site brief: what the site is for, who it serves and how it should sound."},{"name":"Forms","description":"Read the submissions a site's forms have collected."},{"name":"Guest","description":"Start a site without an account using a guest token, then claim it later."}],"security":[{"BearerAuth":[]}],"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"Account API key (pga_…). OAuth MCP sessions use the same header with an opaque token."},"GuestBearerAuth":{"type":"http","scheme":"bearer","description":"Guest token (pgg_…) returned by POST /api/v1/anon/sites. Works only on that one site, and some operations (publish, rename, delete, domains, imports, asset writes) need an account instead."}},"parameters":{"WorkspaceHeader":{"name":"X-Dotsy-Workspace","in":"header","required":false,"description":"Org id or slug to act as for this request (session/MCP user actors only — ignored for pga_ keys). Validated against the caller's own memberships (id match first, then slug); 403 if not a member. Takes precedence over session.activeOrganizationId and the auto-provisioned home-workspace fallback.","schema":{"type":"string"}}},"schemas":{"SitePreflightRequest":{"type":"object","properties":{"requiredRoutes":{"type":"integer","minimum":0,"description":"How many routes you plan to add. It is counted against the Free plan's soft limit of about 10 routes.","example":12},"requiredAssets":{"type":"integer","minimum":0,"description":"How many assets you plan to upload. Used to remind you to upload them before publishing."}},"description":"What you plan to add. Both fields are optional and the body can be empty."},"ChangesetRouteInput":{"type":"object","properties":{"contentKey":{"type":["string","null"],"minLength":1,"description":"Explicit content key (defaults to a path-derived key when omitted). null clears an explicit key back to that path default — same as omitting it, but usable on a PATCH to undo a prior set."},"layout":{"type":["string","null"],"description":"Layout NAME, bare: \"default\" for layouts/default.html — not the file key. It must already exist as layouts/<name>.html (400 layout_not_found otherwise). null removes the layout."},"title":{"type":["string","null"]},"vars":{"type":["object","null"],"additionalProperties":{"type":"string"}},"purpose":{"type":["string","null"]},"redirectTo":{"type":["string","null"],"minLength":1,"description":"Redirect target, a root-relative path (/…). Required when purpose is \"redirect\". null clears it — only valid once purpose stops being \"redirect\" (a redirect route always needs a target)."},"redirectStatus":{"anyOf":[{"type":"number","enum":[301]},{"type":"number","enum":[302]},{"type":"number","enum":[308]},{"type":"null"}],"description":"301 permanent / 302 or 308 temporary (default 301 on a redirect). null clears an explicit override."},"preserveQuery":{"type":["boolean","null"],"description":"Append the visitor's original query string to the redirect target (default true on a redirect). null clears an explicit override."},"navLabel":{"type":["string","null"],"description":"DEPRECATED — use put_menu_item / menu API. Menu link text for <dotsy-nav> (separate from title). Still accepted during transition and synced to menu items."},"nav":{"type":["array","null"],"items":{"type":"object","properties":{"menu":{"type":"string","minLength":1,"description":"Menu this page joins — matches a <dotsy-nav menu=\"…\"> in the layout/fragment (e.g. \"primary\", \"footer\")."},"order":{"type":"integer","description":"Sort position among siblings in this menu, ascending. Use gaps (10, 20, 30) to leave room for inserts."},"parent":{"type":["string","null"],"description":"Parent route path within this menu for a nested/dropdown item (e.g. \"/services\"). Omit for top level."}},"required":["menu"]},"description":"DEPRECATED — use put_menu_item / menu API. Menu memberships on the route row. Still accepted during transition and synced to menu items. See get_skill('nav')."},"experiment":{"type":["object","null"],"properties":{"id":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Stable experiment slug — cookie namespace; unique per site forever."},"name":{"type":"string","minLength":1,"maxLength":80},"hypothesis":{"type":"string","maxLength":200},"status":{"type":"string","enum":["draft","running","ended"]},"variants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Variant arm id — default \"a\" / \"b\" on create."},"label":{"type":"string","maxLength":80},"weight":{"type":"integer","minimum":0,"maximum":100},"contentKey":{"type":"string","minLength":1,"description":"Published body key (may be routeless)."}},"required":["id","weight","contentKey"]},"minItems":2,"maxItems":3},"goalId":{"type":"string","minLength":1,"description":"Optional Dotsy conversion goal; omit when tracking in external analytics."},"changeNote":{"type":"string","maxLength":120,"description":"One line on what the variant changes (0.56e M2). Kept here so route PATCH round-trips (weights/settings updates) don't strip it."},"handsOff":{"type":"boolean","description":"Hands-off mode (0.56e M4): the daily sweep may auto-promote a ≥95% winner or auto-end keep-A after 30 days. Prefer setting this via start_test; kept here so route PATCH round-trips (weights/settings updates) don't strip the flag."},"startedAt":{"type":"string"},"endedAt":{"type":"string"}},"required":["id","name","status","variants"]},"noindex":{"type":["boolean","null"],"description":"Exclude from sitemap/RSS; inject noindex meta (0.69)."}},"description":"The route's settings: layout, title, purpose (`redirect` with `redirectTo`), noindex, and so on."},"ChangesetWrite":{"type":"object","properties":{"key":{"type":"string","minLength":1,"description":"Content key to write: a page (`about.html`), a layout (`layouts/<name>.html`) or a fragment (`fragments/<name>.html`). A `<dotsy-fragment name>` tag and a route's `layout` use the bare name, not the key.","example":"about.html"},"body":{"type":"string","maxLength":5000000,"description":"The whole file as text. Send exactly one of `body` or `bodyStaging`.","example":"<h1>About us</h1><p>We make good things.</p>"},"bodyStaging":{"type":"string","pattern":"^stg_[a-zA-Z0-9_-]+$","description":"Id of a finished staging session (`stg_…`) holding a body too large to inline. Send exactly one of `body` or `bodyStaging`.","example":"stg_9c1f0a"},"contentType":{"type":"string","description":"Stored Content-Type. Defaults to `text/html; charset=utf-8`, or the staging session's type."},"ifMatch":{"type":"string","description":"The `contentHash` you read. If the key changed since, this write is not applied and is listed in `conflicts`."},"route":{"type":"object","properties":{"path":{"type":"string","minLength":1,"description":"URL path the page serves at.","example":"/about"},"input":{"$ref":"#/components/schemas/ChangesetRouteInput"}},"required":["path","input"],"description":"Create or update the route for this page in the same call."},"menuItems":{"type":"array","items":{"type":"object","properties":{"sort":{"type":"integer"},"parent":{"type":["string","null"]},"label":{"type":"string","minLength":1},"kind":{"type":"string","enum":["page","url","anchor","mailto","group"]},"ref":{"type":"string"},"newTab":{"type":"boolean","default":false},"attrs":{"type":"object","additionalProperties":{"type":"string"}},"data":{"type":"object","additionalProperties":{"type":"string"}},"html":{"type":"string"},"childShell":{"type":["string","null"]},"childTemplate":{"type":"string"},"menu":{"type":"string","minLength":1},"id":{"type":"string","minLength":1}},"required":["sort","parent","label","kind","ref","menu"]},"description":"Menu items to place in the same call. Each names its `menu`."}},"required":["key"]},"ApplyChangesetBody":{"type":"object","properties":{"writes":{"type":"array","items":{"$ref":"#/components/schemas/ChangesetWrite"},"default":[],"description":"Content to write. A key cannot appear in both `writes` and `deletes`."},"deletes":{"type":"array","items":{"type":"string","minLength":1},"default":[],"description":"Content keys to delete. The site brief and `_data/*` keys are skipped (listed in `skippedDeletes`).","example":["old-page.html"]},"message":{"type":"string","maxLength":2000,"description":"Short note for the site's edit history (up to 2,000 characters).","example":"Add About page"}},"description":"A batch of content writes and deletes. Send at least one write or delete. `.dotsy/*` and `.structure/*` keys are reserved, and CSS or JavaScript files belong in the asset store (`asset_write_not_allowed`)."},"RouteRequest":{"type":"object","properties":{"contentKey":{"type":["string","null"],"minLength":1,"description":"Explicit content key (defaults to a path-derived key when omitted). null clears an explicit key back to that path default — same as omitting it, but usable on a PATCH to undo a prior set."},"layout":{"type":["string","null"],"description":"Layout NAME, bare: \"default\" for layouts/default.html — not the file key. It must already exist as layouts/<name>.html (400 layout_not_found otherwise). null removes the layout."},"title":{"type":["string","null"]},"vars":{"type":["object","null"],"additionalProperties":{"type":"string"}},"purpose":{"type":["string","null"]},"redirectTo":{"type":["string","null"],"minLength":1,"description":"Redirect target, a root-relative path (/…). Required when purpose is \"redirect\". null clears it — only valid once purpose stops being \"redirect\" (a redirect route always needs a target)."},"redirectStatus":{"anyOf":[{"type":"number","enum":[301]},{"type":"number","enum":[302]},{"type":"number","enum":[308]},{"type":"null"}],"description":"301 permanent / 302 or 308 temporary (default 301 on a redirect). null clears an explicit override."},"preserveQuery":{"type":["boolean","null"],"description":"Append the visitor's original query string to the redirect target (default true on a redirect). null clears an explicit override."},"navLabel":{"type":["string","null"],"description":"DEPRECATED — use put_menu_item / menu API. Menu link text for <dotsy-nav> (separate from title). Still accepted during transition and synced to menu items."},"nav":{"type":["array","null"],"items":{"type":"object","properties":{"menu":{"type":"string","minLength":1,"description":"Menu this page joins — matches a <dotsy-nav menu=\"…\"> in the layout/fragment (e.g. \"primary\", \"footer\")."},"order":{"type":"integer","description":"Sort position among siblings in this menu, ascending. Use gaps (10, 20, 30) to leave room for inserts."},"parent":{"type":["string","null"],"description":"Parent route path within this menu for a nested/dropdown item (e.g. \"/services\"). Omit for top level."}},"required":["menu"]},"description":"DEPRECATED — use put_menu_item / menu API. Menu memberships on the route row. Still accepted during transition and synced to menu items. See get_skill('nav')."},"experiment":{"type":["object","null"],"properties":{"id":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Stable experiment slug — cookie namespace; unique per site forever."},"name":{"type":"string","minLength":1,"maxLength":80},"hypothesis":{"type":"string","maxLength":200},"status":{"type":"string","enum":["draft","running","ended"]},"variants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Variant arm id — default \"a\" / \"b\" on create."},"label":{"type":"string","maxLength":80},"weight":{"type":"integer","minimum":0,"maximum":100},"contentKey":{"type":"string","minLength":1,"description":"Published body key (may be routeless)."}},"required":["id","weight","contentKey"]},"minItems":2,"maxItems":3},"goalId":{"type":"string","minLength":1,"description":"Optional Dotsy conversion goal; omit when tracking in external analytics."},"changeNote":{"type":"string","maxLength":120,"description":"One line on what the variant changes (0.56e M2). Kept here so route PATCH round-trips (weights/settings updates) don't strip it."},"handsOff":{"type":"boolean","description":"Hands-off mode (0.56e M4): the daily sweep may auto-promote a ≥95% winner or auto-end keep-A after 30 days. Prefer setting this via start_test; kept here so route PATCH round-trips (weights/settings updates) don't strip the flag."},"startedAt":{"type":"string"},"endedAt":{"type":"string"}},"required":["id","name","status","variants"]},"noindex":{"type":["boolean","null"],"description":"Exclude from sitemap/RSS; inject noindex meta (0.69)."}},"description":"Route fields. Everything is optional. Send `null` on a nullable field to clear it back to its default.","example":{"title":"About us","layout":"layouts/main.html"}},"RenameRouteRequest":{"type":"object","properties":{"from":{"type":"string","minLength":1,"description":"The page's current route path: a site path without `?`, `#` or spaces.","example":"/old-page"},"to":{"type":"string","minLength":1,"description":"The new route path: a site path without `?`, `#` or spaces.","example":"/new-page"},"leaveRedirect":{"type":"boolean","description":"Leave a 301 redirect from `from` to `to` (default true). `false` removes the old path outright, so old links break."}},"required":["from","to"]},"BulkPutAssetsRequest":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","minLength":1,"description":"Path after /assets/ on the site — e.g. photos/hero.webp or style.css. Do not prefix with assets/ or /. Uploaded files are live in the editor and draft preview at once; the published site picks them up at the next publish.","example":"photos/hero.webp"},"bodyBase64":{"type":"string","description":"The file's bytes, base64-encoded. Send this or `importFromUrl`, not both."},"bodyText":{"type":"string","description":"Plain UTF-8 text for text asset keys only (.css, .js, .mjs, .json, .svg, .txt, .xml). Bulk items: send this or `bodyBase64` or `importFromUrl`, not more than one."},"bodySha256":{"type":"string","pattern":"^[a-f0-9]{64}$","description":"SHA-256 (64 hex characters) of the bytes you send. A mismatch answers 400 `body_sha256_mismatch` before anything is written."},"contentType":{"type":"string","minLength":1,"description":"The file's MIME type. Defaults to the one implied by the key's extension.","example":"image/jpeg"},"importFromUrl":{"type":"string","format":"uri","description":"Fetch the file from this URL instead of sending `bodyBase64`. Send this or `bodyBase64`, not both.","example":"https://example.com/photo.jpg"},"intent":{"type":"string","enum":["hero","column","inline","logo"],"description":"The layout role of an image, which sets its size: `hero` (up to 2,400 px) and `column` (1,200 px) also store a smaller `-800` variant; `inline` (800 px) and `logo` (512 px) do not. Defaults to `logo` under `logos/` or `icons/`, otherwise `column`."}},"required":["key"]},"minItems":1,"maxItems":200}},"required":["items"],"description":"1–200 assets. Each item needs exactly one of `bodyBase64`, `bodyText`, or `importFromUrl`."},"PutAssetRequest":{"type":"object","properties":{"bodyBase64":{"type":"string","description":"The file's bytes, base64-encoded. Send this or `importFromUrl`, not both."},"bodySha256":{"type":"string","pattern":"^[a-f0-9]{64}$","description":"SHA-256 (64 hex characters) of the bytes you send. A mismatch answers 400 `body_sha256_mismatch` before anything is written."},"contentType":{"type":"string","minLength":1,"description":"The file's MIME type. Defaults to the one implied by the key's extension.","example":"image/jpeg"},"importFromUrl":{"type":"string","format":"uri","description":"Fetch the file from this URL instead of sending `bodyBase64`. Send this or `bodyBase64`, not both.","example":"https://example.com/photo.jpg"},"intent":{"type":"string","enum":["hero","column","inline","logo"],"description":"The layout role of an image, which sets its size: `hero` (up to 2,400 px) and `column` (1,200 px) also store a smaller `-800` variant; `inline` (800 px) and `logo` (512 px) do not. Defaults to `logo` under `logos/` or `icons/`, otherwise `column`."},"title":{"type":"string","minLength":1,"maxLength":500,"description":"Display title (up to 500 characters). Defaults to the file name."}},"description":"Exactly one of `bodyBase64` or `importFromUrl` is required.","example":{"bodyBase64":"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=","contentType":"image/svg+xml"}},"VerifyRequest":{"type":"object","properties":{"paths":{"type":"array","items":{"type":"string"},"description":"Route paths to check. Omit it to check every route on the site; a `:param` route such as `/blog/:slug` is checked through one real page of the draft (`checked`), and skipped when it has none.","example":["/","/about"]}},"description":"Which pages to check. The body can be empty."},"RevertDraftsRequest":{"type":"object","properties":{"key":{"type":"string","minLength":1,"description":"Discard the draft of this one content key. Ignored when `keys` or `routes` is non-empty.","example":"blog/launch.html"},"keys":{"type":"array","items":{"type":"string","minLength":1},"description":"Discard the drafts of these content keys. Unknown keys are ignored.","example":["blog/launch.html"]},"routes":{"type":"array","items":{"type":"string","minLength":1},"description":"Discard the drafts of these route paths. Unknown paths are ignored. Combine with `keys` for a page and its route.","example":["/blog/launch"]}},"description":"What to discard. `keys` and `routes` (when either is non-empty) pick a subset; otherwise `key` picks one content key; an empty body or `{}` discards ALL drafts. A `keys` or `routes` list that is present but empty selects nothing and is a 400 (`empty_selection`), never \"all\"."},"UpdateSiteBriefRequest":{"type":"object","properties":{"head":{"type":"object","properties":{"purpose":{"type":"string","minLength":5,"maxLength":280},"audience":{"type":["string","null"],"maxLength":200},"kind":{"type":["string","null"],"enum":["exploration","production"]},"priorities":{"type":["object","null"],"properties":{"seo":{"type":"string","enum":["must","nice","skip"]},"analytics":{"type":"string","enum":["must","nice","skip"]},"design":{"type":"string","enum":["must","nice","skip"]},"mobile":{"type":"string","enum":["must","nice","skip"]}}},"editingModel":{"type":["string","null"],"enum":["ai","simple-editor","creator"]},"sections":{"type":["array","null"],"items":{"type":"string","maxLength":60},"maxItems":20},"design":{"type":["object","null"],"properties":{"palette":{"type":"array","items":{"type":"string"},"maxItems":8},"type":{"type":"array","items":{"type":"string"},"maxItems":6},"layout":{"type":"string","maxLength":80}},"additionalProperties":false},"rules":{"type":["array","null"],"items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]{2,40}$"},"card":{"type":"string","enum":["intent","design","content","seo"]},"text":{"type":"string","minLength":1,"maxLength":160},"level":{"type":"string","enum":["block","warn"]},"locked":{"type":"boolean"},"check":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["title-max"]},"max":{"type":"integer","minimum":10,"maximum":300}},"required":["type","max"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["title-suffix"]},"suffix":{"type":"string","minLength":1,"maxLength":60}},"required":["type","suffix"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["description-length"]},"min":{"type":"integer","minimum":0,"maximum":1000},"max":{"type":"integer","minimum":1,"maximum":1000}},"required":["type","min","max"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["one-h1"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["banned-words"]},"words":{"type":"array","items":{"type":"string","minLength":1,"maxLength":40},"minItems":1,"maxItems":50}},"required":["type","words"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["image-alt"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["allowed-colors"]},"colors":{"type":"array","items":{"type":"string","pattern":"^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$"},"minItems":1,"maxItems":24}},"required":["type","colors"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["allowed-fonts"]},"fonts":{"type":"array","items":{"type":"string","minLength":1,"maxLength":60},"minItems":1,"maxItems":12}},"required":["type","fonts"],"additionalProperties":false},{"type":"null"}]}},"required":["id","card","text"],"additionalProperties":false},"maxItems":30}}},"body":{"type":"string","maxLength":50000},"goal":{"type":"string","maxLength":4000},"notes":{"anyOf":[{"type":"string","maxLength":4000},{"type":"object","properties":{"design":{"type":["string","null"],"maxLength":4000},"content":{"type":["string","null"],"maxLength":4000},"seo":{"type":["string","null"],"maxLength":4000}},"additionalProperties":false}]},"appendHandoff":{"type":"string","minLength":1,"maxLength":500},"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]{2,40}$"},"card":{"type":"string","enum":["intent","design","content","seo"]},"text":{"type":"string","minLength":1,"maxLength":160},"level":{"type":"string","enum":["block","warn"]},"locked":{"type":"boolean"},"check":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["title-max"]},"max":{"type":"integer","minimum":10,"maximum":300}},"required":["type","max"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["title-suffix"]},"suffix":{"type":"string","minLength":1,"maxLength":60}},"required":["type","suffix"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["description-length"]},"min":{"type":"integer","minimum":0,"maximum":1000},"max":{"type":"integer","minimum":1,"maximum":1000}},"required":["type","min","max"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["one-h1"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["banned-words"]},"words":{"type":"array","items":{"type":"string","minLength":1,"maxLength":40},"minItems":1,"maxItems":50}},"required":["type","words"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["image-alt"]}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["allowed-colors"]},"colors":{"type":"array","items":{"type":"string","pattern":"^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$"},"minItems":1,"maxItems":24}},"required":["type","colors"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","enum":["allowed-fonts"]},"fonts":{"type":"array","items":{"type":"string","minLength":1,"maxLength":60},"minItems":1,"maxItems":12}},"required":["type","fonts"],"additionalProperties":false},{"type":"null"}]}},"required":["id","card","text"],"additionalProperties":false},"maxItems":30},"removeRules":{"type":"array","items":{"type":"string","pattern":"^[a-z0-9-]{2,40}$"},"maxItems":30}},"description":"Site brief edit. Send at least one of `head`, `body`, `goal`, `notes`, `rules`, `removeRules`, `appendHandoff`. A first write must include `head.purpose`. `notes` is a string (the free-form notes) or `{ design, content, seo }` (per-section notes). `rules` upserts rules and guidelines by id; `removeRules` deletes by id."},"UpdatePageBriefRequest":{"type":"object","properties":{"head":{"type":"object","properties":{"purpose":{"type":["string","null"],"maxLength":280},"design":{"type":["object","null"],"properties":{"direction":{"type":"string","maxLength":80},"hero":{"type":"string","maxLength":80}},"additionalProperties":false}}},"body":{"type":"string","maxLength":10000},"goal":{"type":"string","maxLength":2000},"notes":{"type":"string","maxLength":2000}},"description":"Page brief edit (with `?page=`). Send at least one of `head`, `body`, `goal`, `notes`."},"UpdateBriefRequest":{"anyOf":[{"$ref":"#/components/schemas/UpdateSiteBriefRequest"},{"$ref":"#/components/schemas/UpdatePageBriefRequest"}],"description":"A site brief edit, or — with `?page=` — a page brief edit."},"AddCustomDomainRequest":{"type":"object","properties":{"hosts":{"type":"array","items":{"type":"string"},"description":"One or two hostnames. Two is only valid as an apex and its `www` together (they share one connect session). Takes precedence over `host`.","example":["example.com","www.example.com"]},"host":{"type":"string","description":"A single hostname — the same as `hosts: [host]`.","example":"www.example.com"}},"description":"Send `hosts` (or `host`). One of them is required."},"ConnectProviderRequest":{"type":"object","properties":{"credentials":{"type":"object","additionalProperties":{"type":"string"},"description":"The provider credentials, keyed by the `providerApi.fields[].key` the connect response listed (Name.com: `username` + `token`; GoDaddy: `pat`). Dotsy verifies them, stores them encrypted, and applies the records. Omit them to reuse the credentials saved from a previous connect.","example":{"pat":"YOUR_GODADDY_PERSONAL_ACCESS_TOKEN"}}},"description":"Optional. `{}` (or no body) reuses saved credentials."},"ActivityRevertRequest":{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string","minLength":1},"maxItems":50,"description":"Undo only these keys of the change. Default: every key."},"reason":{"type":"string","maxLength":200,"description":"Why — shown next to 'Reverted by …'. Visible to everyone on the site."}}},"CreateSiteInviteRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","description":"Who to invite.","example":"grace@example.com"},"role":{"type":"string","enum":["editor","viewer"],"description":"`editor` can edit content and publish. `viewer` can only look."},"isInternal":{"type":"boolean","description":"Mark the person as inside your own organization rather than an outside guest. Default false."}},"required":["email","role"]},"CreateTransferRequest":{"type":"object","properties":{"toEmail":{"type":"string","format":"email","description":"Who the site is offered to.","example":"grace@example.com"},"keepCollaborators":{"type":"boolean","description":"True to keep the site's collaborators and pending invites after the move. False clears them."}},"required":["toEmail","keepCollaborators"]},"AcceptTransferRequest":{"type":"object","properties":{"destinationWorkspaceId":{"type":"string","minLength":1,"description":"The workspace that should receive the site. You must be its Owner or Admin. Take the id from `getAccountMe` (`memberships`)."}},"required":["destinationWorkspaceId"]},"RenameSiteRequest":{"type":"object","properties":{"newSlug":{"type":"string","minLength":1,"description":"The new slug: lowercase letters, numbers and hyphens (1–63), not reserved, and not shaped like a site id (UUID).","example":"acme-studio-2"}},"required":["newSlug"]},"CreateSiteRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"description":"The address you want: `{slug}.dotsy.site`. Lowercase letters, numbers and hyphens, 1 to 63 characters. A slug that looks like a site id (a UUID) or is reserved for the platform is refused.","example":"acme-studio"},"title":{"type":"string","maxLength":200,"description":"The site's display name, up to 200 characters.","example":"Acme Studio"},"keyConvention":{"type":"string","enum":["flat","directory"],"description":"How content keys map to URLs. `flat` (default): `about.html` serves at `/about`. `directory`: `about/index.html` serves at `/about`."},"purpose":{"type":"string","minLength":5,"maxLength":280,"description":"One sentence on what the site is for, 5 to 280 characters. It seeds the site brief. Left out, it is inferred from the title or slug.","example":"Show Acme's work and bring in new client enquiries."},"onSlugTaken":{"type":"string","enum":["fail","auto-suffix","merge"],"default":"auto-suffix","description":"What to do when another account already has the slug. `auto-suffix` (default) picks `{slug}-{adjective}-{noun}` and answers `slugAdjusted: true`. `fail` answers 409 `slug_taken` instead. `merge` reuses a slug you already own, where the other policies answer 409 `slug_taken_self`."},"editingMode":{"type":"string","enum":["live","staged"],"description":"How the editor's Save behaves. `live` (default) publishes each save right away. `staged` keeps saves as drafts until you publish. Writes made through the API always stage, whatever this says."}},"required":["slug"]},"CreateApiKeyRequest":{"type":"object","properties":{"label":{"type":"string","maxLength":100,"description":"A name for the key, up to 100 characters.","example":"CI deploys"},"scope":{"type":"string","enum":["read","write","admin"],"default":"write","description":"`read`, `write` (default) or `admin`. `read` can only GET; `admin` can also manage keys."},"siteSlugs":{"type":"array","items":{"type":"string","minLength":1},"maxItems":100,"description":"Limit the key to these sites. Leave it out or send `[]` for all sites. Each slug must be a site in this workspace, or the request answers 400 `site_not_found`.","example":["acme-studio"]}}},"UpdateApiKeyRequest":{"type":"object","properties":{"label":{"type":["string","null"],"maxLength":100,"description":"A new name. Send `null` or an empty string to clear it."},"scope":{"type":"string","enum":["read","write","admin"],"description":"`read` can only GET. `write` can also change sites. `admin` can also manage API keys."},"siteSlugs":{"type":"array","items":{"type":"string","minLength":1},"maxItems":100,"description":"Replace the site list. `[]` makes the key work on all sites. Each slug must be a site in this workspace."}}},"StartGuestSiteRequest":{"type":"object","properties":{"title":{"type":"string","maxLength":200,"description":"Site title, up to 200 characters.","example":"Maple Street Bakery"},"purpose":{"type":"string","maxLength":2000,"description":"What the site is for, up to 2,000 characters. It seeds the site brief. When you leave it out, Dotsy infers a placeholder from the title.","example":"A one-page site for a neighbourhood bakery: opening hours, the menu and a contact form."}},"description":"Both fields are optional. An empty body (or `{}`) is fine."},"ClaimGuestSiteRequest":{"type":"object","properties":{"guestToken":{"type":"string","minLength":20,"description":"The `guestToken` that `POST /api/v1/anon/sites` returned for the site you want to keep.","example":"pgg_Zk3qR1m7vT0aXc9LwYb2NfHj5uPdE8sGiO4tAx6VnMo"}},"required":["guestToken"],"description":"The guest token of the site to claim."},"CreateWorkspaceInviteRequest":{"type":"object","properties":{"email":{"type":"string","format":"email","description":"Who to invite.","example":"grace@example.com"},"role":{"type":"string","enum":["admin","member"],"description":"`admin` or `member`. Never `owner`: promote someone to owner after they join."}},"required":["email","role"]},"UpdateWorkspaceRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120,"description":"The new workspace name, 1 to 120 characters.","example":"Acme Studio"}},"required":["name"]},"UpdateWorkspaceMemberRoleRequest":{"type":"object","properties":{"role":{"type":"string","enum":["owner","admin","member"],"description":"The new role. Only an Owner can make someone an owner or change an owner's role, and a workspace always keeps at least one owner."}},"required":["role"]},"PublishRequest":{"type":"object","properties":{"feedback":{"type":"object","properties":{"category":{"type":"string","minLength":1},"impactPercent":{"type":"integer","minimum":1,"maximum":100},"title":{"type":"string","minLength":1},"description":{"type":"string"},"suggestedImprovement":{"type":"string"},"findings":{"type":"array","items":{"type":"object","properties":{"category":{"type":"string","minLength":1},"impactPercent":{"type":"integer","minimum":1,"maximum":100},"title":{"type":"string","minLength":1},"description":{"type":"string"},"suggestedImprovement":{"type":"string"},"operationId":{"type":"string"}},"required":["category","impactPercent","title"]},"maxItems":20},"intents":{"type":"array","items":{"type":"object","properties":{"tool":{"type":"string","minLength":1},"context":{"type":"string","minLength":1},"at":{"type":"number"}},"required":["tool","context"]},"maxItems":200}},"required":["category","impactPercent","title"],"description":"Optional session feedback (what was hard, what to improve). Agents send it so Dotsy can learn; a routine publish can omit it. When present it must be valid, otherwise the publish answers 400 `invalid_body`."},"handoff":{"type":"string","description":"Optional one-line hand-off note (decisions, preferences, open threads) appended to the site brief's hand-off log before the publish. Routine no-information notes such as \"Routine dashboard publish.\" are not appended.","example":"Switched the hero to the new photo; pricing copy still needs review."},"keys":{"type":"array","items":{"type":"string"},"description":"Selective publish: content keys to promote. Everything else stays staged. Unknown keys are ignored. Omit `keys` and `routes` (or send both empty) to publish everything.","example":["blog/launch.html"]},"routes":{"type":"array","items":{"type":"string"},"description":"Selective publish: route paths to promote (see `keys`). Unknown paths are ignored.","example":["/blog/launch"]},"auto":{"type":"boolean","description":"Set by the dashboard's live mode, where every save publishes. Leave it out."},"reviewedSet":{"type":"object","properties":{"content":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","minLength":1},"hash":{"type":"string","minLength":1}},"required":["key","hash"]},"maxItems":5000},"routes":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","minLength":1},"hash":{"type":"string","minLength":1}},"required":["key","hash"]},"maxItems":5000},"fingerprint":{"type":"string","minLength":1,"maxLength":256}},"required":["content","routes"],"description":"Optional safety check: the `{key, hash}` pairs you displayed from `GET …/draft-status` before this publish. If the staged set moved since, the publish answers 409 `drafts_changed` instead of shipping something you did not review."}},"description":"Publish options. Every field is optional, and an empty body (or `{}`) publishes everything that is staged."},"ImportPreflightRequest":{"type":"object","properties":{"sourceUrl":{"type":"string","format":"uri","description":"The website you plan to import.","example":"https://example.com"},"samplePaths":{"type":"array","items":{"type":"string"},"description":"Paths to sample. Default: the home page and the first path segment of `sourceUrl`.","example":["/","/blog/"]},"mode":{"type":"string","enum":["auto","mirror","render"],"description":"The capture mode you intend to use. Default `auto`."}},"required":["sourceUrl"],"description":"The site to inspect."},"ImportSiteRequest":{"type":"object","properties":{"sourceUrl":{"type":"string","format":"uri","description":"The website to import. Without `pages` and `segments`, Dotsy imports the whole site it can discover from here.","example":"https://example.com"},"mode":{"type":"string","enum":["auto","mirror","render"],"description":"Default `auto`."},"pages":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","description":"Page URL, absolute or relative to `sourceUrl`.","example":"/about"},"key":{"type":"string","description":"Content key to store the page under.","example":"about.html"},"mode":{"type":"string","enum":["auto","mirror","render"],"description":"How a page is captured. `mirror` copies the served HTML, `render` runs the page in a browser first (for sites that build their content with JavaScript), `auto` mirrors each page and switches it to render when it looks like a JavaScript-built shell."}},"required":["url","key"]},"description":"An explicit page list, taken as given. Omit it (and `segments`) to let Dotsy find the pages from the sitemap and the home page links. Two pages sharing a key collapse into one, which comes back as a warning."},"segments":{"type":"array","items":{"type":"object","properties":{"pathPrefix":{"type":"string","description":"The part of the site this segment covers.","example":"/blog"},"sitemap":{"type":"string","description":"Sitemap path, relative to `sourceUrl` (for example `/blog/sitemap.xml`). Without it only the segment's hub page is imported, and a `segment_hub_only` warning says so."},"mode":{"type":"string","enum":["auto","mirror","render"],"description":"How a page is captured. `mirror` copies the served HTML, `render` runs the page in a browser first (for sites that build their content with JavaScript), `auto` mirrors each page and switches it to render when it looks like a JavaScript-built shell."},"maxPages":{"type":"integer","minimum":1,"description":"Cap for this segment (default 500). A larger sitemap needs an explicit higher cap."}},"required":["pathPrefix"]},"description":"For a site served from several places: each segment expands into pages through its own sitemap."},"keyConvention":{"type":"string","enum":["flat","directory"],"description":"Shape of the content keys. `directory` stores `about/index.html` (static-site output); `flat` (default) stores `about.html`."},"onSlugTaken":{"type":"string","enum":["auto-suffix","fail","merge"],"description":"What to do when `{slug}` is already in use. `auto-suffix` (default): when another account holds it, import into `{slug}-{adjective}-{noun}` instead; when you own it, answer 409 `slug_taken_self`. `fail`: when another account holds it, answer 409 `slug_taken` with suggestions. `merge`: when you own it, add or update the pages on that site; when another account holds it, behaves like `auto-suffix`."},"assetMirror":{"type":"string","enum":["full","same-host","off"],"description":"Which asset URLs are copied into Dotsy. `full` (default): the source's own assets and the cross-origin assets they use (CDN images, fonts, scripts). `same-host`: only assets served from the source host. `off`: none, so asset URLs keep pointing at the source."},"assetWarm":{"type":"string","enum":["auto","inline","deferred"],"description":"When mirrored assets are copied. `inline`: during the import. `deferred`: in a background job afterwards. `auto` (default): inline up to 100 external assets, deferred beyond."},"skipHosts":{"type":"array","items":{"type":"string"},"description":"Asset hosts to leave alone: matching URLs stay pointing at the source.","example":["cdn.example-analytics.com"]},"mirrorHosts":{"type":"array","items":{"type":"string"},"description":"With `assetMirror: full`, copy cross-origin assets only from hosts that match one of these."}},"required":["sourceUrl"],"description":"What to import. Only `sourceUrl` is needed: it imports every page Dotsy can discover on that site."},"WarmImportRequest":{"type":"object","properties":{"sourceUrl":{"type":"string","description":"The original import origin. The asset copy fetches from it.","example":"https://example.com"}},"required":["sourceUrl"],"description":"Where the assets come from."},"SiteSummary":{"type":"object","properties":{"id":{"type":"string","description":"Stable site id (UUID). Unlike the slug it never changes, and it can stand in for the slug in site URLs (the API answers with a 307 to the canonical slug path). Absent only on a list cached before the field existed; it is back within a minute.","example":"3f6c1d52-8a4b-4e0e-9c57-2f1b7a9d6e10"},"slug":{"type":"string","example":"acme-studio"},"title":{"type":["string","null"]},"updatedAt":{"type":"number","description":"Last change to the site, Unix ms."},"publishedAt":{"type":["number","null"],"description":"Last publish time, Unix ms. Null until the first publish."},"hasPendingChanges":{"type":"boolean","description":"True when the draft has changes that are not published yet."},"shared":{"type":"boolean","description":"True when the site belongs to another workspace and was shared with you. False when you own it."},"role":{"type":"string","enum":["owner","editor","viewer"],"description":"Your role on the site. Present only when `shared` is true."},"workspaceName":{"type":"string","description":"Name of the workspace that owns the site. Present only on shared sites, and only when the list is paged with a `limit` of 100 or less."},"publicUrl":{"type":"string","description":"Canonical public site URL for this slug.","example":"https://acme-studio.dotsy.site/"},"liveUrl":{"type":"string","description":"Alias of publicUrl.","example":"https://acme-studio.dotsy.site/"},"liveReady":{"type":"boolean","description":"True after the first publish. Until then the public URL answers 404."},"cardPreviewLiveAt":{"type":["number","null"],"description":"Unix ms when the workspace card live homepage preview was captured. Drives cache-busting on GET …/card-preview?arm=live."},"cardPreviewDraftAt":{"type":["number","null"],"description":"Unix ms when the workspace card draft homepage preview was captured. GET …/card-preview?arm=draft."}},"required":["slug","title","updatedAt","publishedAt","hasPendingChanges","shared","publicUrl","liveUrl","liveReady"],"example":{"id":"3f6c1d52-8a4b-4e0e-9c57-2f1b7a9d6e10","slug":"acme-studio","title":"Acme Studio","updatedAt":1759401600000,"publishedAt":1759315200000,"hasPendingChanges":true,"shared":false,"publicUrl":"https://acme-studio.dotsy.site/","liveUrl":"https://acme-studio.dotsy.site/","liveReady":true}},"SiteList":{"type":"object","properties":{"sites":{"type":"array","items":{"$ref":"#/components/schemas/SiteSummary"}},"nextCursor":{"type":["string","null"],"description":"Only present when `limit` was sent. Pass it as `cursor` (with the same `limit`) for the next page; null on the last page."}},"required":["sites"]},"ApiError":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable code, e.g. `invalid_body`.","example":"invalid_body"},"hint":{"type":"string","description":"What to do next, in plain words."},"message":{"type":"string"},"field":{"type":"string","description":"The request field the error is about, when there is one."},"issues":{"type":"array","items":{},"description":"Validation issues (zod issues, or gate issue strings/objects)."}},"required":["error"]},"AccountMe":{"type":"object","properties":{"user":{"type":["object","null"],"properties":{"name":{"type":"string"},"email":{"type":"string"}},"required":["name","email"],"description":"The signed-in person. Null for a `pga_` key, which has no user."},"account":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":["string","null"]}},"required":["id","name","slug"],"description":"The workspace this request acted in."},"isPlatformAdmin":{"type":"boolean"},"isConsoleAdmin":{"type":"boolean","description":"True when the signed-in person may use the Dotsy admin console. Always false for a `pga_` key or an OAuth token."},"servingOriginTemplate":{"type":["string","null"],"description":"Where published sites are served, with `{slug}` as the placeholder.","example":"https://{slug}.dotsy.site"},"memberships":{"type":"array","items":{"type":"object","properties":{"orgId":{"type":"string","description":"Workspace id. Send it (or the slug) as `X-Dotsy-Workspace` to act in that workspace."},"name":{"type":"string"},"slug":{"type":["string","null"]},"role":{"type":"string","enum":["owner","admin","member"]},"ownerEmail":{"type":["string","null"],"description":"The workspace owner's email. Tells apart workspaces you joined as a guest."},"plan":{"type":"string","enum":["free","core"],"description":"Workspace plan. A workspace with no plan row is free."},"isActive":{"type":"boolean","description":"True for the workspace this request acted in (`account`)."}},"required":["orgId","name","slug","role","ownerEmail","plan","isActive"]},"description":"Every workspace the person belongs to. Always empty for a `pga_` key."},"telemetry":{"type":"object","properties":{"feedbackSink":{"type":"string","enum":["ok","degraded","unconfigured"],"description":"Whether feedback sent with publishes is being stored: `ok`, `degraded` (queued and retried), or `unconfigured`."}},"required":["feedbackSink"],"description":"Health of the services behind this account."}},"required":["user","account","isPlatformAdmin","isConsoleAdmin","servingOriginTemplate","memberships","telemetry"]},"NotificationsSummary":{"type":"object","properties":{"unread":{"type":"integer","description":"Unread items across the member's first ten workspaces."}},"required":["unread"]},"NotificationItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"seq":{"type":"integer"},"kind":{"type":"string","enum":["form.entry","agent.changes","publish.warnings","publish.stopped","domain.status","experiment.outcome"]},"siteSlug":{"type":"string"},"siteTitle":{"type":["string","null"]},"count":{"type":"integer"},"needsYou":{"type":"boolean"},"updatedAt":{"type":"integer","description":"Unix ms."},"unread":{"type":"boolean"},"workspaceId":{"type":"string"},"workspaceName":{"type":"string"},"data":{"type":"object","additionalProperties":{}}},"required":["id","seq","kind","siteSlug","siteTitle","count","needsYou","updatedAt","unread","workspaceId","workspaceName","data"]},"NotificationsList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/NotificationItem"}}},"required":["items"]},"NotificationsReadResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"unread":{"type":"integer"}},"required":["ok","unread"]},"NotificationsReadBody":{"anyOf":[{"type":"object","properties":{"all":{"type":"boolean","enum":[true]},"workspace":{"type":"string"}},"required":["all"]},{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1},"workspace":{"type":"string"}},"required":["ids"]}]},"ManifestEntry":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"hash":{"type":"string","description":"SHA-256 of the body.","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"type":{"type":"string","description":"Content-Type.","example":"text/html; charset=utf-8"},"status":{"type":"string","enum":["live","modified","new"],"description":"Draft manifest only. Same meaning as `status` on the content list."}},"required":["key","hash","type"]},"ManifestRoute":{"type":"object","properties":{"path":{"type":"string","example":"/about"},"contentKey":{"type":["string","null"]},"layout":{"type":["string","null"]},"title":{"type":["string","null"]},"purpose":{"type":["string","null"],"description":"`redirect` for a redirect row."},"redirectTo":{"type":["string","null"]},"redirectStatus":{"anyOf":[{"type":"number","enum":[301]},{"type":"number","enum":[302]},{"type":"number","enum":[308]},{"type":"null"}]}},"required":["path","contentKey","layout","title","purpose","redirectTo","redirectStatus"]},"SiteManifest":{"type":"object","properties":{"version":{"type":"integer","description":"Published version counter (0 when never published)."},"entries":{"type":"array","items":{"$ref":"#/components/schemas/ManifestEntry"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` for the next page; null on the last page."},"collections":{"type":"object","additionalProperties":{},"description":"Published mode only: the site's collection registry."},"forms":{"type":"object","additionalProperties":{},"description":"Published mode only: the site's form settings."},"routes":{"type":"array","items":{"$ref":"#/components/schemas/ManifestRoute"},"description":"Only with `routes=1`."}},"required":["version","entries","nextCursor"]},"PagePart":{"type":"object","properties":{"kind":{"type":"string","enum":["section","menu","list","slot","chrome"],"description":"`section`: a shared fragment. `menu`: a navigation block. `list`: a collection listing. `slot`: where the page body goes. `chrome`: the page's own header or footer."},"name":{"type":"string"},"dynamic":{"type":"boolean","enum":[true]}},"required":["kind"]},"PageRow":{"type":"object","properties":{"path":{"type":"string","description":"The URL path. For a page with no route, the path its file name serves.","example":"/about"},"contentKey":{"type":["string","null"]},"layout":{"type":["string","null"],"description":"The layout's file key.","example":"layouts/default.html"},"title":{"type":["string","null"],"description":"The route title, else the page's `<title>`."},"parts":{"type":["array","null"],"items":{"$ref":"#/components/schemas/PagePart"},"description":"What the page places (sections, menus, listings). `null` until the page is indexed."},"ownChrome":{"type":["boolean","null"],"description":"The page carries its own header or footer. `null` until indexed."},"noindex":{"type":"boolean"},"experiment":{"type":"boolean","description":"The page has a draft or running A/B test."},"hasChanges":{"type":"boolean","description":"The page has a staged change."},"published":{"type":"boolean","description":"False for a page that was never published."},"pattern":{"type":"object","properties":{"count":{"type":"integer"},"collection":{"type":["string","null"]}},"required":["count","collection"],"description":"A dynamic route that stands for several pages."},"noRoute":{"type":"boolean","enum":[true],"description":"The page exists as a file but no route points at it."}},"required":["path","contentKey","layout","title","parts","ownChrome","noindex","experiment","hasChanges","published"]},"PagesList":{"type":"object","properties":{"routes":{"type":"array","items":{"$ref":"#/components/schemas/PageRow"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` (with the same `limit`) for the next page; null on the last page."},"routesListTotal":{"type":["integer","null"],"description":"Rows that match the filter. `null` when the list is a partial window."},"partial":{"type":"boolean","description":"True when the list is a window of the site rather than all of it."},"indexing":{"type":"object","properties":{"pending":{"type":"integer","description":"Pages whose structure is not indexed yet."},"running":{"type":"boolean","description":"Indexing is still running."}},"required":["pending","running"]}},"required":["routes","nextCursor","routesListTotal","partial","indexing"]},"SharedSectionNudge":{"type":"object","properties":{"count":{"type":"integer","description":"Blocks with an exact copy on 3 or more pages that are not shared or dismissed."},"next":{"type":"string","enum":["share_section"],"description":"The next step: MCP `share_section` with no arguments (or GET …/shared-sections/suggestions) lists them."}},"required":["count","next"],"description":"Blocks that repeat across pages and could become shared sections. Never blocks; absent when there are none."},"SitePreflightResult":{"type":"object","properties":{"ok":{"type":"boolean","description":"False when any blocking suggestion exists. Suggestions ending in `(advisory)`, `(optional)` or `(info)` never flip it."},"slug":{"type":"string"},"collections":{"type":"object","additionalProperties":{},"description":"The site's collection index by prefix (item count, build state, data size)."},"integrations":{"type":"object","properties":{"pending":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"vendor":{"type":"string","example":"gtm"},"sourceId":{"type":"string"},"status":{"type":"string"},"pageCount":{"type":"integer"},"mayAffectUi":{"type":"boolean"}},"required":["id","vendor","sourceId","status","pageCount"]}}},"required":["pending"],"description":"Third-party scripts found on imported pages that still need a decision. They never block a publish."},"stats":{"type":"object","properties":{"pages":{"type":"integer"},"assets":{"type":"integer"},"htmlPages":{"type":"integer"}},"required":["pages","assets","htmlPages"]},"routes":{"type":"object","properties":{"count":{"type":"integer"},"softLimit":{"type":"integer","description":"The Free plan's advisory route limit."},"required":{"type":"integer","description":"The `requiredRoutes` you sent."},"partial":{"type":"boolean","enum":[true],"description":"The route or page scan hit its cap, so some redirect checks may be incomplete."}},"required":["count","softLimit","required"]},"suggestions":{"type":"array","items":{"type":"string"},"description":"Plain-language findings: collection and template problems, redirect loops, SEO notes, missing page bytes."},"blocking":{"type":"array","items":{"type":"string"},"description":"The `suggestions` that made `ok` false, word for word. Empty when `ok` is true. Fix these first."},"contentBytesIssues":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"reason":{"type":"string","enum":["missing","empty"]}},"required":["key","reason"]},"description":"Pages whose stored bytes are missing or empty: re-write them before publishing."},"sharedSectionSuggestions":{"$ref":"#/components/schemas/SharedSectionNudge"}},"required":["ok","slug","collections","integrations","stats","routes","suggestions","blocking"]},"SearchContentResponse":{"type":"object","properties":{"q":{"type":"string","description":"The query, trimmed."},"matches":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"snippet":{"type":"string","description":"A short excerpt around the match."}},"required":["key","snippet"]}},"truncated":{"type":"boolean","description":"True when more pages match than `limit` allowed. There is no cursor: narrow the query."}},"required":["q","matches","truncated"]},"ContentRenameResponse":{"type":"object","properties":{"key":{"type":"string","description":"The content key after the rename.","example":"blog/my-first-post.html"},"path":{"type":"string","description":"The route path the page now serves at, when it has one.","example":"/blog/my-first-post"},"oldKey":{"type":"string","description":"The key before the rename.","example":"blog/untitled.html"},"oldPath":{"type":"string","description":"The route path before the rename."},"written":{"type":"array","items":{"type":"string"},"description":"Every content key the rename staged: the new key and the old key's removal — for a shared section (`fragments/<name>.html`) also each page, layout and section that places it. Publish exactly these to make it live."},"published":{"type":"boolean","enum":[false],"description":"Always false: a rename is staged until you publish."},"hint":{"type":"string","description":"Reminder that the change is staged."}},"required":["key","written","published","hint"]},"ApplyChangesetResponse":{"type":"object","properties":{"staged":{"type":"object","properties":{"created":{"type":"integer"},"updated":{"type":"integer"},"reverted":{"type":"integer","description":"Writes that put a key back to its live version."},"deleted":{"type":"integer"},"unchanged":{"type":"integer","description":"Writes whose body already matched."}},"required":["created","updated","reverted","deleted","unchanged"]},"conflicts":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"}},"required":["key"]},"description":"Writes that were not applied because their `ifMatch` no longer matched. The status is still 200, so check this list."},"publishable":{"type":"boolean","description":"True when the draft has anything to publish."},"version":{"type":"integer","description":"The site's published version counter."},"skippedDeletes":{"type":"array","items":{"type":"string"},"description":"Deletes that were ignored because the key is protected."},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking notes about tags that will not compose, one per problem, each as `<key>: <code> — <what to change>`. Codes: `fragment_unclosed`, `fragment_bad_name`, `fragment_full_document`, `fragment_self_reference`, `fragment_attr_hyphen`, `legacy_paige_tag`, `layout_missing_content`, `unknown_dotsy_syntax`. The write still happened. At most 50 are listed."},"published":{"type":"boolean","enum":[false],"description":"Always false: the change is staged in the draft until you publish."},"hint":{"type":"string","description":"Reminder that the change is staged and how to make it live."}},"required":["staged","conflicts","publishable","version","published","hint"]},"JobRef":{"type":"object","properties":{"id":{"type":"string","description":"Public job id. Poll GET /api/v1/sites/{slug}/jobs/{id}."},"kind":{"type":"string","enum":["import","content","rename"]},"status":{"type":"string","enum":["queued","running","succeeded","failed"]}},"required":["id","kind","status"],"description":"Poll `GET /api/v1/sites/{slug}/jobs/{job.id}` until `status` is `succeeded` or `failed`. Absent when the server cannot issue job ids."},"ApplyChangesetQueuedResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"slug":{"type":"string"},"status":{"type":"string","enum":["queued"]},"id":{"type":"string","description":"Workflow id. Poll `job` instead (`GET …/jobs/{id}`); this id is only for the older `…/content/jobs/{id}` URL."},"items":{"type":"integer","description":"Number of writes queued."},"skippedDeletes":{"type":"array","items":{"type":"string"}},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking notes about tags that will not compose, one per problem, each as `<key>: <code> — <what to change>`. Codes: `fragment_unclosed`, `fragment_bad_name`, `fragment_full_document`, `fragment_self_reference`, `fragment_attr_hyphen`, `legacy_paige_tag`, `layout_missing_content`, `unknown_dotsy_syntax`. The write still happened. At most 50 are listed."},"job":{"$ref":"#/components/schemas/JobRef"},"published":{"type":"boolean","enum":[false],"description":"Always false: the change is staged in the draft until you publish."},"hint":{"type":"string","description":"Reminder that the change is staged and how to make it live."}},"required":["ok","slug","status","id","items","published","hint"]},"Job":{"type":"object","properties":{"id":{"type":"string","description":"Public job id. Poll GET /api/v1/sites/{slug}/jobs/{id}."},"kind":{"type":"string","enum":["import","content","rename"]},"status":{"type":"string","enum":["queued","running","succeeded","failed"]},"progress":{"type":"object","properties":{"done":{"type":"number"},"total":{"type":"number"}},"required":["done","total"]},"result":{"type":"object","additionalProperties":{}},"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}},"required":["code"]}},"required":["id","kind","status"]},"ContentSummary":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"contentType":{"type":"string","example":"text/html; charset=utf-8"},"contentHash":{"type":"string","description":"SHA-256 of the effective body: the staged draft if there is one, else the live version. Send it back as `If-Match` on a write to refuse the write if someone else changed the key meanwhile.","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"updatedAt":{"type":"number","description":"Last write, Unix ms."},"status":{"type":"string","enum":["live","modified","new"],"description":"`live`: published, no staged edit. `modified`: published, with a staged edit. `new`: staged, never published."},"size":{"type":["number","null"],"description":"Body size in bytes. Measured only on pages of 100 rows or fewer, or when `withSize=1`; otherwise the field is absent. `null` means the stored bytes are missing (see `missing`)."},"missing":{"type":"boolean","description":"`true` when the row points at stored bytes that are gone. A page that renders blank for this reason shows `size: null` and `missing: true`; a genuinely empty page shows `size: 0`."}},"required":["key","contentType","contentHash","updatedAt","status"]},"ContentList":{"type":"object","properties":{"content":{"type":"array","items":{"$ref":"#/components/schemas/ContentSummary"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` (with the same `limit`) for the next page; null on the last page."},"partial":{"type":"boolean","description":"`true` when these keys are one window of the site's key space rather than all of it (the page index was missing, so the keys were read in bounded windows). Keep following `nextCursor`: a `null` cursor with `partial: true` does not mean the site is small."}},"required":["content","nextCursor","partial"]},"ContentItem":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"contentType":{"type":"string","example":"text/html; charset=utf-8"},"contentHash":{"type":"string","description":"SHA-256 of the effective body: the staged draft if there is one, else the live version. Send it back as `If-Match` on a write to refuse the write if someone else changed the key meanwhile.","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"updatedAt":{"type":"number","description":"Last write, Unix ms."},"body":{"type":"string","description":"The effective body."}},"required":["key","contentType","contentHash","updatedAt","body"]},"ContentSelection":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"contentHash":{"type":"string","description":"SHA-256 of the effective body: the staged draft if there is one, else the live version. Send it back as `If-Match` on a write to refuse the write if someone else changed the key meanwhile.","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"selector":{"type":"string","description":"The selector you sent.","example":"h1"},"count":{"type":"integer","description":"How many elements matched."},"matches":{"type":"array","items":{"type":"object","properties":{"tag":{"type":"string","example":"h1"},"attributes":{"type":"object","additionalProperties":{"type":"string"},"description":"The element's attributes."},"text":{"type":"string","description":"The element's text, without its child markup."}},"required":["tag","attributes","text"]},"description":"One entry per matched element, in document order."}},"required":["key","contentHash","selector","count","matches"]},"PostDocument":{"type":"object","properties":{"slug":{"type":"string","example":"hello-world"},"meta":{"type":"object","properties":{"title":{"type":"string","description":"Post title — shown on the blog card/listing and used as the <title> unless seoTitle overrides it."},"seoTitle":{"type":"string","description":"SEO override for the <title>/og:title tags only. The blog card and listing always show `title`; empty or absent inherits `title`."},"date":{"type":"string"},"dateDisplay":{"type":"string"},"excerpt":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"author":{"type":"string"},"image":{"type":"string"},"url":{"type":"string"},"visibleAt":{"type":"string"},"collections":{"type":"array","items":{"type":"string"}},"custom":{"type":"object","additionalProperties":{}}},"required":["title"],"additionalProperties":false},"head":{"type":"array","items":{"type":"string"},"description":"Extra `<head>` tags the post carries (SEO and social tags the platform derives are left out)."},"body":{"type":"string","description":"The post body HTML."},"warnings":{"type":"array","items":{"type":"string"},"description":"Read problems that would otherwise lose data on the next save, for example a malformed collection list that was dropped from `meta`."},"urlChangeable":{"type":"boolean","description":"Whether the post's URL can be moved once it is published (the rename route accepts `movePath` for it)."}},"required":["slug","meta","head","body","urlChangeable"]},"PutContentResponse":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"contentType":{"type":"string","example":"text/html; charset=utf-8"},"contentHash":{"type":"string","description":"SHA-256 of the effective body: the staged draft if there is one, else the live version. Send it back as `If-Match` on a write to refuse the write if someone else changed the key meanwhile.","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"updatedAt":{"type":"number","description":"Write time, Unix ms."},"created":{"type":"boolean","description":"True when the key did not exist before this write (the status is then 201)."},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking notes about the content: tags that will not compose (`<key>: <code> — <what to change>`; codes `fragment_unclosed`, `fragment_bad_name`, `fragment_full_document`, `fragment_self_reference`, `fragment_attr_hyphen`, `legacy_paige_tag`, `layout_missing_content`, `unknown_dotsy_syntax`) or post fields that were not saved. The write still happened."},"published":{"type":"boolean","enum":[false],"description":"Always false: the change is staged in the draft until you publish."},"hint":{"type":"string","description":"Reminder that the change is staged and how to make it live."}},"required":["key","contentType","contentHash","updatedAt","created","published","hint"]},"PutContentRequest":{"type":"object","properties":{"body":{"type":"string","maxLength":5000000,"description":"The whole file as text (up to 5,000,000 characters). An HTML page needs some non-whitespace text.","example":"<h1>About us</h1><p>We make good things.</p>"},"contentType":{"type":"string","minLength":1,"description":"Stored Content-Type. Defaults from the key's extension (`.html` becomes `text/html`).","example":"text/html; charset=utf-8"}},"required":["body"]},"PutPostRequest":{"type":"object","properties":{"meta":{"type":"object","properties":{"title":{"type":"string","description":"Post title — shown on the blog card/listing and used as the <title> unless seoTitle overrides it."},"seoTitle":{"type":"string","description":"SEO override for the <title>/og:title tags only. The blog card and listing always show `title`; empty or absent inherits `title`."},"date":{"type":"string"},"dateDisplay":{"type":"string"},"excerpt":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"author":{"type":"string"},"image":{"type":"string"},"url":{"type":"string"},"visibleAt":{"type":"string"},"collections":{"type":"array","items":{"type":"string"}},"custom":{"type":"object","additionalProperties":{}}},"required":["title"],"additionalProperties":false},"body":{"type":"string","description":"The post body HTML."},"head":{"type":"array","items":{"type":"string"},"description":"Extra `<head>` tags to keep on the post."},"slug":{"type":"string","description":"File name without extension. Defaults to the key's last segment."}},"required":["meta","body"]},"DeleteContentResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"written":{"type":"array","items":{"type":"string"},"description":"The key whose removal was staged. Publish it to make the delete live."},"published":{"type":"boolean","enum":[false],"description":"Always false: the change is staged in the draft until you publish."},"hint":{"type":"string","description":"Reminder that the change is staged and how to make it live."}},"required":["ok","written","published","hint"]},"SectionInUseError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"usedBy":{"type":"array","items":{"type":"string"},"maxItems":20,"description":"Up to 20 content keys (pages, layouts, sections) that place it."},"more":{"type":"boolean"}},"required":["usedBy","more"]}]},"PutContentRawResponse":{"type":"object","properties":{"key":{"type":"string","description":"The content key.","example":"about.html"},"hash":{"type":"string","description":"SHA-256 of the stored body (hex).","example":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},"bytes":{"type":"integer","description":"Body size in bytes."},"contentType":{"type":"string","example":"text/html"},"created":{"type":"boolean","description":"True when the key did not exist before (the status is then 201)."},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking notes about tags that will not compose, one per problem, each as `<key>: <code> — <what to change>`. Codes: `fragment_unclosed`, `fragment_bad_name`, `fragment_full_document`, `fragment_self_reference`, `fragment_attr_hyphen`, `legacy_paige_tag`, `layout_missing_content`, `unknown_dotsy_syntax`. The write still happened. At most 50 are listed."}},"required":["key","hash","bytes","contentType","created"]},"Route":{"type":"object","properties":{"path":{"type":"string","description":"The URL path. The home page is `/`. A `:param` segment makes a pattern route.","example":"/about"},"contentKey":{"type":["string","null"],"description":"The content key this route serves. Null on a redirect, and on a route with no explicit key (it then serves the key derived from its path).","example":"about.html"},"layout":{"type":["string","null"],"description":"Layout file key, for example `layouts/main.html`.","example":"layouts/main.html"},"title":{"type":["string","null"],"description":"Page title.","example":"About us"},"vars":{"type":["object","null"],"additionalProperties":{"type":"string"},"description":"Template variables passed to the layout."},"purpose":{"type":["string","null"],"description":"`redirect` for a redirect route, otherwise null."},"redirectTo":{"type":["string","null"],"description":"Redirect target, a root-relative path. Set only when `purpose` is `redirect`."},"redirectStatus":{"anyOf":[{"type":"number","enum":[301]},{"type":"number","enum":[302]},{"type":"number","enum":[308]},{"type":"null"}],"description":"Redirect status code. Null means the default (301)."},"preserveQuery":{"type":["boolean","null"],"description":"Whether a redirect appends the visitor's query string to its target. Null means the default (true)."},"navLabel":{"type":["string","null"],"description":"Deprecated. Menu link text — use the menu API instead."},"nav":{"type":["array","null"],"items":{"type":"object","properties":{"menu":{"type":"string","minLength":1,"description":"Menu this page joins — matches a <dotsy-nav menu=\"…\"> in the layout/fragment (e.g. \"primary\", \"footer\")."},"order":{"type":"integer","description":"Sort position among siblings in this menu, ascending. Use gaps (10, 20, 30) to leave room for inserts."},"parent":{"type":["string","null"],"description":"Parent route path within this menu for a nested/dropdown item (e.g. \"/services\"). Omit for top level."}},"required":["menu"]},"description":"Deprecated. Menu memberships — use the menu API instead."},"experiment":{"type":["object","null"],"properties":{"id":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Stable experiment slug — cookie namespace; unique per site forever."},"name":{"type":"string","minLength":1,"maxLength":80},"hypothesis":{"type":"string","maxLength":200},"status":{"type":"string","enum":["draft","running","ended"]},"variants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]+$","description":"Variant arm id — default \"a\" / \"b\" on create."},"label":{"type":"string","maxLength":80},"weight":{"type":"integer","minimum":0,"maximum":100},"contentKey":{"type":"string","minLength":1,"description":"Published body key (may be routeless)."}},"required":["id","weight","contentKey"]},"minItems":2,"maxItems":3},"goalId":{"type":"string","minLength":1,"description":"Optional Dotsy conversion goal; omit when tracking in external analytics."},"changeNote":{"type":"string","maxLength":120,"description":"One line on what the variant changes (0.56e M2). Kept here so route PATCH round-trips (weights/settings updates) don't strip it."},"handsOff":{"type":"boolean","description":"Hands-off mode (0.56e M4): the daily sweep may auto-promote a ≥95% winner or auto-end keep-A after 30 days. Prefer setting this via start_test; kept here so route PATCH round-trips (weights/settings updates) don't strip the flag."},"startedAt":{"type":"string"},"endedAt":{"type":"string"}},"required":["id","name","status","variants"],"description":"The page's A/B test, when it has one."},"noindex":{"type":["boolean","null"],"description":"True when the page is excluded from the sitemap and marked `noindex`."},"createdBy":{"type":["string","null"],"enum":["person","ai","key","move"],"description":"Who made this route a redirect: a signed-in `person`, an `ai` (MCP OAuth), an API `key`, or a page `move`. Present only on redirects."},"createdById":{"type":["string","null"],"description":"The user id when `createdBy` is `person`."},"updatedAt":{"type":"number","description":"Last change, Unix ms."}},"required":["path","contentKey","layout","title","vars","purpose","redirectTo","redirectStatus","preserveQuery","navLabel","nav","updatedAt"]},"RouteList":{"type":"object","properties":{"routes":{"type":"array","items":{"$ref":"#/components/schemas/Route"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` for the next page. Null on the last page."},"routesListTotal":{"type":["integer","null"],"description":"How many routes the site has (or how many match `q`). Null when the total is not known yet — never read it as zero."},"partial":{"type":"boolean","description":"True when this page is a window over a large site rather than the whole set. Keep following `nextCursor`."}},"required":["routes","nextCursor","routesListTotal","partial"]},"RouteWriteResponse":{"allOf":[{"$ref":"#/components/schemas/Route"},{"type":"object","properties":{"created":{"type":"boolean","description":"`PUT` only: true when the route did not exist before."},"published":{"type":"boolean","enum":[false],"description":"Always false: a route change is staged until you publish."},"hint":{"type":"string","example":"Changes staged to draft — call publish_site to go live."}},"required":["published","hint"]}]},"RenameRouteResponse":{"type":"object","properties":{"route":{"allOf":[{"$ref":"#/components/schemas/Route"},{"description":"The route at its new path."}]},"redirect":{"allOf":[{"$ref":"#/components/schemas/Route"},{"description":"The 301 left at the old path. Absent when `leaveRedirect` is false."}]},"menuItemsUpdated":{"type":"integer","description":"Menu links that followed the page to its new URL."},"redirectsRetargeted":{"type":"integer","description":"Other redirects that pointed at the old path and now point at the new one."},"published":{"type":"boolean","enum":[false],"description":"Always false: a route change is staged until you publish."},"hint":{"type":"string","example":"Changes staged to draft — call publish_site to go live."}},"required":["route","menuItemsUpdated","redirectsRetargeted","published","hint"]},"Asset":{"type":"object","properties":{"key":{"type":"string","description":"The asset's key: its path after `/assets/`.","example":"photos/hero.webp"},"contentType":{"type":"string","example":"image/webp"},"size":{"type":"integer","description":"Size in bytes."},"uploadedAt":{"type":"number","description":"Upload time, Unix ms."},"etag":{"type":"string","description":"Content fingerprint (MD5 hex). Skip re-uploading a file whose MD5 already matches."}},"required":["key","contentType","size","uploadedAt","etag"]},"AssetList":{"type":"object","properties":{"assets":{"type":"array","items":{"$ref":"#/components/schemas/Asset"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` for the next page. Null on the last page."}},"required":["assets","nextCursor"]},"BulkPutAssetsResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"applied":{"type":"integer","description":"How many items were stored."},"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"The stored key. For a raster image this is the WebP key, which can differ from the key you sent.","example":"photos/hero.webp"},"created":{"type":"boolean","description":"True when the asset did not exist before."},"size":{"type":"integer","description":"Stored size in bytes."},"contentType":{"type":"string","example":"image/webp"},"sha256":{"type":"string","description":"SHA-256 of the STORED bytes. For JS, CSS and SVG it matches the upload; a raster image is re-encoded to WebP, so it may not."},"width":{"type":"integer","description":"Pixel width, for a processed raster image."},"height":{"type":"integer","description":"Pixel height, for a processed raster image."},"suggestedSrcset":{"type":"string","description":"A ready-made `srcset` for the image and its smaller variant.","example":"photos/hero-800.webp 800w, photos/hero.webp 1600w"},"variants":{"type":"object","properties":{"sm":{"type":"string"}},"required":["sm"],"description":"Keys of the extra sizes stored next to the image."},"processed":{"type":"boolean","description":"True when the image was optimized on upload."},"skippedReencode":{"type":"boolean","description":"True when the image was already small enough to keep as is."},"optimized":{"type":"boolean","enum":[false],"description":"Present as false when the daily image-processing budget was used up and the original was stored instead."},"reason":{"type":"string","enum":["daily_image_budget"],"description":"Why `optimized` is false."}},"required":["key","created","size","contentType","sha256"]},"description":"One entry per item, in request order."}},"required":["ok","applied","items"]},"AssetFile":{"allOf":[{"$ref":"#/components/schemas/Asset"},{"type":"object","properties":{"sha256":{"type":"string","description":"SHA-256 of the STORED bytes. It can differ from the `bodySha256` you uploaded when a raster image was re-encoded to WebP."},"bodyBase64":{"type":"string","description":"The file's bytes, base64-encoded."}},"required":["sha256","bodyBase64"]}]},"AssetMeta":{"type":"object","properties":{"key":{"type":"string","example":"photos/hero.webp"},"size":{"type":"integer","description":"Size in bytes."},"uploadedAt":{"type":"number","description":"Upload time, Unix ms."},"contentType":{"type":"string","example":"image/webp"},"width":{"type":"integer","description":"Pixel width, for a raster image."},"height":{"type":"integer","description":"Pixel height, for a raster image."},"intent":{"type":"string","enum":["hero","column","inline","logo"],"description":"The layout role the image was sized for."},"title":{"type":"string","description":"Display title."}},"required":["key","size","uploadedAt","contentType"]},"AssetUploadResult":{"type":"object","properties":{"key":{"type":"string","description":"The stored key. For a raster image this is the WebP key, which can differ from the key you sent.","example":"photos/hero.webp"},"created":{"type":"boolean","description":"True when the asset did not exist before."},"size":{"type":"integer","description":"Stored size in bytes."},"contentType":{"type":"string","example":"image/webp"},"sha256":{"type":"string","description":"SHA-256 of the STORED bytes. For JS, CSS and SVG it matches the upload; a raster image is re-encoded to WebP, so it may not."},"width":{"type":"integer","description":"Pixel width, for a processed raster image."},"height":{"type":"integer","description":"Pixel height, for a processed raster image."},"suggestedSrcset":{"type":"string","description":"A ready-made `srcset` for the image and its smaller variant.","example":"photos/hero-800.webp 800w, photos/hero.webp 1600w"},"variants":{"type":"object","properties":{"sm":{"type":"string"}},"required":["sm"],"description":"Keys of the extra sizes stored next to the image."},"processed":{"type":"boolean","description":"True when the image was optimized on upload."},"skippedReencode":{"type":"boolean","description":"True when the image was already small enough to keep as is."},"optimized":{"type":"boolean","enum":[false],"description":"Present as false when the daily image-processing budget was used up and the original was stored instead."},"reason":{"type":"string","enum":["daily_image_budget"],"description":"Why `optimized` is false."},"uploadedAt":{"type":"number","description":"Upload time, Unix ms."},"etag":{"type":"string","description":"Content fingerprint (MD5 hex)."}},"required":["key","created","size","contentType","sha256","uploadedAt","etag"]},"VerifyIssue":{"type":"object","properties":{"code":{"type":"string","example":"missing_asset"},"message":{"type":"string"},"ref":{"type":"string","description":"The key, route or asset the issue is about."},"snippet":{"type":"string"},"severity":{"type":"string","enum":["error","warning","info"],"description":"Absent on a blocking issue, which counts the same as `error`."}},"required":["code","message"]},"RuleIssue":{"type":"object","properties":{"code":{"type":"string","enum":["rule_failed"]},"ruleId":{"type":"string","pattern":"^[a-z0-9-]{2,40}$"},"card":{"type":"string","enum":["intent","design","content","seo"]},"level":{"type":"string","enum":["block","warn"]},"text":{"type":"string"},"path":{"type":"string"},"found":{"type":"string","maxLength":120}},"required":["code","ruleId","card","level","text","path"],"description":"One Rule that failed on a page during verify or publish."},"VerifyResult":{"type":"object","properties":{"ok":{"type":"boolean","description":"True when there are no site issues and every checked path is `ok`."},"warnings":{"type":"array","items":{"$ref":"#/components/schemas/VerifyIssue"},"description":"Site-wide findings that do not fail the check (menu wiring)."},"siteIssues":{"type":"array","items":{"$ref":"#/components/schemas/VerifyIssue"},"description":"Site-wide findings that fail the check (menu wiring)."},"paths":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"checked":{"type":"string","description":"Present when `path` is a `:param` route: the real page that was composed for it, such as `/blog/hello`.","example":"/blog/hello"},"ok":{"type":"boolean"},"issues":{"type":"array","items":{"$ref":"#/components/schemas/VerifyIssue"},"description":"Findings that decide `ok`."},"seoIssues":{"type":"array","items":{"$ref":"#/components/schemas/VerifyIssue"},"description":"SEO findings for the page, including warnings."},"ruleIssues":{"type":"array","items":{"$ref":"#/components/schemas/RuleIssue"},"description":"Rules from the site brief that failed on this page. Present only when the brief has Rules; an empty array means all passed. A blocking Rule makes `ok` false."}},"required":["path","ok","issues"]},"description":"One entry per checked path."},"seo":{"type":"object","properties":{"scanned":{"type":"integer"},"errors":{"type":"integer"},"warnings":{"type":"integer"}},"required":["scanned","errors","warnings"]},"redirectScanPartial":{"type":"boolean","enum":[true],"description":"The redirect scan hit its cap, so some redirect targets may have been misjudged."}},"required":["ok","warnings","siteIssues","paths","seo"]},"DraftStatusResponse":{"type":"object","properties":{"total":{"type":"integer","description":"Staged changes in all three lists."},"content":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","example":"blog/launch.html"},"status":{"type":"string","enum":["modified","new","deleted"]},"hash":{"type":"string","description":"Fingerprint of the staged content (`deleted` for a deletion). Echo it in `reviewedSet` when you publish."}},"required":["key","status","hash"]},"description":"Content files that differ from the live site."},"routes":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string","example":"/blog/launch"},"status":{"type":"string","enum":["modified","new","deleted"]},"hash":{"type":"string"}},"required":["path","status","hash"]},"description":"Routes that differ from the live site."},"menus":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"menu":{"type":"string","example":"primary"},"status":{"type":"string","enum":["modified","new","deleted"]}},"required":["id","menu","status"]},"description":"Menu items that differ from the live site. Menus always publish together with the rest."},"fingerprint":{"type":"string","description":"One opaque token for the whole staged state. If it is unchanged when you publish, nothing moved since you looked."},"indexState":{"type":"string","enum":["building"],"description":"The staged-set index is being rebuilt, so the lists above may be a subset of what is staged."},"contentBytesIssues":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"reason":{"type":"string","enum":["missing","empty"]}},"required":["key","reason"]},"description":"Pages whose stored bytes are missing or empty. A publish would be refused (`content_bytes_missing`): re-write them first."},"draftLog":{"type":"array","items":{"type":"object","properties":{"tool":{"type":"string"},"context":{"type":"string"},"at":{"type":"number","description":"Unix ms."}},"required":["tool","context","at"]},"description":"The most recent draft edits an agent recorded (up to 20). Cleared when all drafts are discarded."},"verify":{"type":["object","null"],"properties":{"at":{"type":"number","description":"Unix ms when that check ran."},"scanned":{"type":"integer","description":"Pages the check composed and scanned."},"errors":{"type":"integer"},"warnings":{"type":"integer"},"scope":{"type":"string","enum":["site","paths"],"description":"`site`: every route was checked. `paths`: only the pages the caller named, so a sample."},"stale":{"type":"boolean","description":"True when the draft changed (content, routes or menus) after the check ran, so its result no longer describes it."}},"required":["at","scanned","errors","warnings","scope","stale"],"description":"The last `verifySite` run. Present only with `?verify=1`; `null` when the site has never been verified."}},"required":["total","content","routes","menus","fingerprint"]},"RevertDraftsResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"reverted":{"type":"integer","description":"How many staged items were discarded. 0 when nothing matched."}},"required":["ok","reverted"]},"SiteBriefHead":{"type":"object","properties":{"purpose":{"type":"string","minLength":5,"maxLength":280},"audience":{"type":"string","maxLength":200},"kind":{"type":"string","enum":["exploration","production"]},"priorities":{"type":"object","properties":{"seo":{"type":"string","enum":["must","nice","skip"]},"analytics":{"type":"string","enum":["must","nice","skip"]},"design":{"type":"string","enum":["must","nice","skip"]},"mobile":{"type":"string","enum":["must","nice","skip"]}}},"editingModel":{"type":"string","enum":["ai","simple-editor","creator"]},"sections":{"type":"array","items":{"type":"string","maxLength":60},"maxItems":20},"design":{"type":"object","properties":{"palette":{"type":"array","items":{"type":"string"},"maxItems":8},"type":{"type":"array","items":{"type":"string"},"maxItems":6},"layout":{"type":"string","maxLength":80}},"additionalProperties":false},"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]{2,40}$"},"card":{"type":"string","enum":["intent","design","content","seo"]},"text":{"type":"string","minLength":1,"maxLength":160},"level":{"type":"string","enum":["block","warn"]},"locked":{"type":"boolean"},"check":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":40}},"required":["type"]}},"required":["id","card","text"]},"maxItems":30},"updated":{"type":"string"},"updatedBy":{"type":"string","enum":["agent","human"]}},"required":["purpose"],"description":"The structured top of the site brief. `purpose` is required (5–280 characters)."},"SiteBrief":{"type":"object","properties":{"head":{"$ref":"#/components/schemas/SiteBriefHead"},"notes":{"type":["string","null"],"description":"The brief's `## Notes` section, or null when it has none."},"hash":{"type":"string","description":"Content hash of the stored brief (changes on every write)."},"sectionNotes":{"type":"object","properties":{"design":{"type":"string"},"content":{"type":"string"},"seo":{"type":"string"}},"description":"The Design system, Content & CMS and SEO notes that are set (full text). Omitted when none is."},"hasBody":{"type":"boolean","description":"True when the brief has narrative text beyond the head and notes. Omitted on a site-brief write."},"aiAccess":{"type":"string","enum":["viewer","contributor","editor","owner"],"description":"GET only (also on the 404 `brief_not_set` and 409 `brief_head_corrupt` answers): what AI agents and API keys may do on this site — the effective level, `owner` (Full access) by default."},"ruleFailures":{"type":"object","properties":{"intent":{"type":"integer"},"design":{"type":"integer"},"content":{"type":"integer"},"seo":{"type":"integer"}},"required":["intent","design","content","seo"],"description":"GET only (also on the 404 and 409 answers): how many Rules failed in each section on the site's last check. Omitted until a check has counted any."},"ruleFailuresCheckedAt":{"type":"integer","description":"GET only, with `ruleFailures`: when that check ran (epoch ms). A Rule added after it has not been checked yet."}},"required":["head","notes"]},"PageBriefHead":{"type":"object","properties":{"purpose":{"type":"string","maxLength":280},"design":{"type":"object","properties":{"direction":{"type":"string","maxLength":80},"hero":{"type":"string","maxLength":80}},"additionalProperties":false}}},"PageBrief":{"type":"object","properties":{"head":{"$ref":"#/components/schemas/PageBriefHead"},"notes":{"type":["string","null"],"description":"The page brief's `## Notes` section, or null when it has none."},"hash":{"type":"string"},"hasBody":{"type":"boolean"}},"required":["head","notes","hasBody"]},"BriefResponse":{"anyOf":[{"$ref":"#/components/schemas/SiteBrief"},{"$ref":"#/components/schemas/PageBrief"}],"description":"The site brief, or the page brief when the request carried `?page=`."},"DnsRecord":{"type":"object","properties":{"type":{"type":"string","description":"DNS record type, e.g. `CNAME`, `TXT` or `A`.","example":"CNAME"},"name":{"type":"string","description":"The record name to enter at the DNS provider (relative to the zone, e.g. `www` or `_acme-challenge.www`).","example":"www"},"value":{"type":"string","description":"The record value to enter.","example":"customers.dotsy.site"},"purpose":{"type":"string","enum":["routing","dcv","apex"],"description":"`routing`: points the hostname at Dotsy. `apex`: the same for a bare domain. `dcv`: proves you control the domain so the certificate can be issued."},"host":{"type":"string","description":"On `connect` records only: the session host this row belongs to."},"recordStatus":{"type":"string","enum":["found","missing","mismatch","waiting","duplicate"],"description":"A live DNS check of this record. Absent when no check has run. `duplicate` means extra TXT records at this name are blocking the certificate."},"nameError":{"type":"string","enum":["doubled_suffix"],"description":"The name looks like it was pasted as a full hostname and the provider appended the zone to it."},"useInstead":{"type":"string","description":"With `nameError`: the relative name to enter instead."},"dnsTxtCount":{"type":"number","description":"With `recordStatus: duplicate`: how many TXT answers exist at this name."},"dcvExpectedValues":{"type":"array","items":{"type":"string"},"description":"Every certificate-check token that is currently acceptable at this name (Cloudflare rotated while pending)."}},"required":["type","name","value","purpose"]},"CustomDomain":{"type":"object","properties":{"host":{"type":"string","example":"www.example.com"},"slug":{"type":"string","description":"The site this hostname serves.","example":"acme-studio"},"kind":{"type":"string","enum":["internal","external"],"description":"`internal`: a nested subdomain of the platform's own site domain, connected at once with no DNS. `external`: a domain you own, which needs DNS records and a certificate."},"status":{"type":"string","enum":["pending_dns","pending_cert","connected","error"],"description":"`pending_dns`: waiting for the DNS records. `pending_cert`: DNS is right, the HTTPS certificate is being issued. `connected`: live. `error`: Cloudflare could not set the hostname up (see `lastError`); it no longer holds a hostname slot."},"isPrimary":{"type":"boolean","description":"The primary host is the canonical address: the site's other connected hosts redirect (301) to it."},"lastError":{"type":["string","null"],"description":"Why setup failed or stalled, or null."},"createdAt":{"type":"number","description":"When the hostname was added, Unix ms."},"verifiedAt":{"type":"number","description":"When the hostname was last verified, Unix ms."},"connectSessionId":{"type":["string","null"],"description":"The connect session (`cs_…`) this host belongs to, or null (internal hosts, expired sessions).","example":"cs_3f9a1c2e"},"lastCheckedAt":{"type":"number","description":"When Cloudflare was last asked about this hostname, Unix ms."},"dnsRecords":{"type":"array","items":{"$ref":"#/components/schemas/DnsRecord"},"description":"The records to create. Empty for internal hosts. For a connected host they are all `found`; otherwise each carries a live `recordStatus`."}},"required":["host","slug","kind","status","isPrimary","lastError","createdAt","verifiedAt","connectSessionId","lastCheckedAt","dnsRecords"],"example":{"host":"www.example.com","slug":"acme-studio","kind":"external","status":"pending_dns","isPrimary":false,"lastError":null,"createdAt":1760000000000,"verifiedAt":1760000000000,"connectSessionId":"cs_3f9a1c2e","lastCheckedAt":1760000000000,"dnsRecords":[{"type":"CNAME","name":"www","value":"customers.dotsy.site","purpose":"routing","recordStatus":"missing"},{"type":"TXT","name":"_acme-challenge.www","value":"k3Zr1q9Wn0xLpV7sT2uYb8cD4eFgH6jA","purpose":"dcv","recordStatus":"waiting"}]}},"ListCustomDomainsResponse":{"type":"object","properties":{"domains":{"type":"array","items":{"$ref":"#/components/schemas/CustomDomain"}}},"required":["domains"]},"GetCustomDomainConnectResponse":{"type":"object","properties":{"connect":{"anyOf":[{"$ref":"#/components/schemas/DomainConnect"},{"type":"null"}]}},"description":"`connect` is null when every external host on the site is already connected.","required":["connect"]},"ConnectApplyResult":{"type":"object","properties":{"host":{"type":"string"},"routingOk":{"type":"boolean"},"routingCreated":{"type":"number"},"routingSkipped":{"type":"number"},"dcvOk":{"type":"boolean"},"dcvConflict":{"type":"boolean","description":"An existing TXT/CNAME record blocks the additive write (for example a stale certificate-check record)."},"message":{"type":"string"}},"required":["host","routingOk"]},"DomainConnect":{"type":"object","properties":{"sessionId":{"type":"string","description":"The connect session. Pass it as `sessionId` to getCustomDomainConnect to resume.","example":"cs_3f9a1c2e"},"kind":{"type":"string","enum":["oauth","redirect","widget","manual","wait","done"],"description":"What to do next. `oauth` / `redirect` / `widget`: open `openUrl` in a browser. `manual`: follow `instructions` and enter `records`. `wait`: nothing to do, DNS or the certificate is propagating — poll again. `done`: every host is connected."},"summary":{"type":"string","description":"One line to tell the user."},"openUrl":{"type":"string","description":"A URL to open in a browser (OAuth start, Domain Connect apply)."},"openUrlLabel":{"type":"string"},"oauthLastError":{"type":"string","description":"Last Cloudflare OAuth error code (`access_denied`, `exchange_failed`, …) while still on the OAuth lane."},"instructions":{"type":"array","items":{"type":"string"},"description":"Numbered steps when `kind` is `manual`."},"records":{"type":"array","items":{"$ref":"#/components/schemas/DnsRecord"},"description":"Copy-paste DNS rows for the session's hosts. `host` says which host each row belongs to."},"registrarDeepLink":{"type":"string","description":"A link to the DNS page at the detected provider."},"provider":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"description":"The DNS provider detected from the domain's nameservers."},"nameServers":{"type":"array","items":{"type":"string"},"description":"The domain's nameservers, when the provider was not recognised."},"providerApi":{"type":"object","properties":{"fields":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"secret":{"type":"boolean"},"placeholder":{"type":"string"}},"required":["key","label"]}},"helpUrl":{"type":"string"},"helpLabel":{"type":"string"},"hasSavedCredentials":{"type":"boolean"}},"required":["fields","helpUrl","helpLabel","hasSavedCredentials"],"description":"The paste-credentials lane (Name.com, GoDaddy): send the listed `fields` as `credentials` to connectCustomDomainProvider."},"entriAvailable":{"type":"boolean"},"lane":{"type":"string","enum":["cloudflare_oauth","domain_connect","entri","manual"],"description":"How the DNS records get applied: `cloudflare_oauth` (Cloudflare DNS, with the user's grant), `domain_connect`, `entri`, or `manual` (the user pastes records at their provider)."},"sessionStatus":{"type":"string","enum":["in_progress","partial","complete"]},"hosts":{"type":"array","items":{"type":"object","properties":{"host":{"type":"string"},"status":{"type":"string","enum":["pending_dns","pending_cert","connected","error"],"description":"`pending_dns`: waiting for the DNS records. `pending_cert`: DNS is right, the HTTPS certificate is being issued. `connected`: live. `error`: Cloudflare could not set the hostname up (see `lastError`); it no longer holds a hostname slot."},"isPrimary":{"type":"boolean"}},"required":["host","status","isPrimary"]}},"canReapplyOauth":{"type":"boolean","description":"A stored Cloudflare OAuth grant exists, so reapplyCustomDomainOauth can fix the records without a browser."}},"required":["sessionId","kind","summary","entriAvailable","lane","sessionStatus","hosts"],"description":"What to do next to connect the pending hosts: the next action plus the connect session id.","example":{"sessionId":"cs_3f9a1c2e","kind":"manual","summary":"Add 2 DNS records at Namecheap, then check again.","instructions":["Add a CNAME record: Name \"www\" → Value \"customers.dotsy.site\"."],"records":[{"type":"CNAME","name":"www","value":"customers.dotsy.site","purpose":"routing","host":"www.example.com","recordStatus":"missing"}],"provider":{"id":"namecheap","label":"Namecheap"},"entriAvailable":false,"lane":"manual","sessionStatus":"in_progress","hosts":[{"host":"www.example.com","status":"pending_dns","isPrimary":false}]}},"ReapplyCustomDomainOauthResponse":{"type":"object","properties":{"applyResults":{"type":"array","items":{"$ref":"#/components/schemas/ConnectApplyResult"},"description":"What was written for each host."},"connect":{"$ref":"#/components/schemas/DomainConnect"}},"required":["applyResults","connect"]},"CustomDomainResponse":{"type":"object","properties":{"domain":{"$ref":"#/components/schemas/CustomDomain"}},"required":["domain"]},"AddCustomDomainResponse":{"type":"object","properties":{"domain":{"$ref":"#/components/schemas/CustomDomain"},"domains":{"type":"array","items":{"$ref":"#/components/schemas/CustomDomain"},"description":"Every host added (external adds only)."},"connect":{"$ref":"#/components/schemas/DomainConnect"}},"required":["domain"],"description":"`domain` is the first host added. For an external add, `domains` lists every host and `connect` says how to finish connecting."},"CustomDomainConnectResponse":{"type":"object","properties":{"domain":{"$ref":"#/components/schemas/CustomDomain"},"connect":{"$ref":"#/components/schemas/DomainConnect"}},"required":["domain"],"description":"`connect` is present when the host belongs to a live connect session."},"RemoveCustomDomainResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]},"SiteAccess":{"type":"object","properties":{"access":{"type":"string","enum":["public","password"],"description":"public, or password-protected."},"workspacePlan":{"type":"string","enum":["free","core"],"description":"The owning workspace's plan. free when it has no plan row."},"canEnable":{"type":"boolean","description":"True when protection can be turned on: the workspace is on CORE, or this site is already protected."}},"required":["access","workspacePlan","canEnable"]},"UpdateSiteAccessRequest":{"type":"object","properties":{"access":{"type":"string","enum":["public","password"]},"password":{"type":"string","maxLength":128,"description":"Required to turn protection on. 8–128 characters. Omit it to keep the current password when the site is already protected."}},"required":["access"]},"FormBackendRequired":{"type":"object","properties":{"code":{"type":"string","enum":["backend_required"]},"message":{"type":"string","example":"Form submission storage is not available for this site backend."}},"required":["code","message"]},"FormSubmission":{"type":"object","properties":{"id":{"type":"string","description":"Submission id.","example":"5b1c0f6e-3c1a-4a52-9f4e-0d7a2d9b8e31"},"formId":{"type":"string","description":"The form this was submitted through.","example":"contact"},"pagePath":{"type":["string","null"],"description":"Path of the page the visitor submitted from, when known.","example":"/contact"},"fields":{"type":"object","additionalProperties":{"type":"string"},"description":"What the visitor filled in: field name to value. Personal data lives here.","example":{"name":"Ada Lovelace","email":"ada@example.com","message":"Do you take custom orders?"}},"replyTo":{"type":["string","null"],"description":"The visitor's email address, when the form has one. Use it to search.","example":"ada@example.com"},"meta":{"type":["object","null"],"additionalProperties":{},"description":"Extra facts about the submission, such as a hash of the visitor's IP address (`ip_hash`)."},"sizeBytes":{"type":"integer","description":"Size of the stored submission in bytes."},"createdAt":{"type":"integer","description":"When it was submitted, Unix ms.","example":1790000000000}},"required":["id","formId","pagePath","fields","replyTo","meta","sizeBytes","createdAt"]},"FormSubmissionList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FormSubmission"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` (with the same `limit`) for the next page; null on the last page."},"formId":{"type":"string","description":"The form that was listed. Omitted when `form` was not passed (all forms, newest first).","example":"contact"}},"required":["items","nextCursor"]},"ChangeCounts":{"type":"object","properties":{"pages":{"type":"integer"},"layouts":{"type":"integer"},"fragments":{"type":"integer"},"menus":{"type":"integer"},"routes":{"type":"integer"},"deleted":{"type":"integer"}},"required":["pages","layouts","fragments","menus","routes","deleted"],"description":"What a change touched, by kind. `deleted` counts the pages and files it removed."},"ChangeSummary":{"type":["object","null"],"properties":{"counts":{"$ref":"#/components/schemas/ChangeCounts"},"keys":{"type":"array","items":{"type":"string"},"description":"The first five content keys of the change."},"n":{"type":"integer","description":"How many content keys the change touched in all."},"truncated":{"type":"boolean","description":"True for a very large change: it has counts but no per-file diff or revert."}},"required":["counts","keys","n","truncated"]},"RevertedInfo":{"type":["object","null"],"properties":{"by":{"type":"object","properties":{"id":{"type":["string","null"]},"label":{"type":["string","null"],"description":"A person's email, or the label of the API key."},"channel":{"type":"string","example":"dashboard-session"}},"required":["id","label","channel"]},"reason":{"type":["string","null"]},"at":{"type":"integer","description":"Unix ms."}},"required":["by","reason","at"],"description":"Who undid this change, and why."},"PublishCounts":{"type":["object","null"],"properties":{"pages":{"type":"integer"},"menus":{"type":["integer","null"]},"agentChanges":{"type":"integer","description":"How many agent changes this publish put live."}},"required":["pages","menus"],"description":"Publish rows only."},"ActivityEvent":{"type":"object","properties":{"id":{"type":"string","description":"Stable event id — the `:id` of the diff and revert routes."},"actorType":{"type":"string","description":"`user`, `key`, `editor-handoff` or `system`.","example":"user"},"actorId":{"type":["string","null"]},"actorLabel":{"type":["string","null"],"description":"A person's email or an API key's label. Null when not known."},"actorChannel":{"type":"string","description":"How the change reached Dotsy: `dashboard-session`, `mcp-oauth`, `api-key`, `editor-handoff`, `anonymous` or `unknown`.","example":"mcp-oauth"},"client":{"type":["string","null"],"description":"The tool that made the request, as it announced itself (display only)."},"tool":{"type":["string","null"],"description":"The MCP tool or CLI command (display only).","example":"patch_content"},"note":{"type":["string","null"],"description":"The agent's own note for this change (up to 280 characters). Visible to everyone on the site."},"state":{"type":["string","null"],"enum":["pending","published","reverted"],"description":"`pending`: staged, not published. `published`: it went live. `reverted`: undone. Null for events that are not draft changes (settings, members, domains)."},"summary":{"$ref":"#/components/schemas/ChangeSummary"},"reverted":{"$ref":"#/components/schemas/RevertedInfo"},"revertOf":{"type":["string","null"],"description":"On a revert event: the id of the change it undid."},"action":{"type":"string","example":"content.edit"},"target":{"type":["string","null"]},"editCount":{"type":"integer","description":"Edits folded into this row (repeated edits to one page within 10 minutes merge)."},"firstAt":{"type":"integer"},"createdAt":{"type":"integer","description":"Unix ms of the latest edit in this row."},"keyCreatedBy":{"type":["string","null"],"description":"For an API key's change: the name (or email) of the person who created the key. Null for older keys and for everything else."},"diffAvailable":{"type":"boolean","description":"True when `GET …/activity/{id}/diff` can show this change."},"counts":{"$ref":"#/components/schemas/PublishCounts"}},"required":["id","actorType","actorId","actorLabel","actorChannel","client","tool","note","state","summary","reverted","revertOf","action","target","editCount","firstAt","createdAt","keyCreatedBy","diffAvailable"]},"ActivitySummary":{"type":"object","properties":{"changes":{"type":"object","properties":{"n":{"type":"integer"},"more":{"type":"boolean","description":"True when the real number is higher than `n`."}},"required":["n","more"],"description":"Events in the range, counted up to 1,000."},"actors":{"type":"object","properties":{"n":{"type":"integer"},"more":{"type":"boolean","description":"True when the real number is higher than `n`."}},"required":["n","more"],"description":"Distinct actors among the newest events in the range, up to 20."},"needsReview":{"type":"object","properties":{"n":{"type":"integer"},"more":{"type":"boolean","description":"True when the real number is higher than `n`."}},"required":["n","more"],"description":"Agent changes still waiting for a person to publish, up to 100."},"actorList":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":["string","null"]},"channel":{"type":"string"},"client":{"type":["string","null"]}},"required":["id","label","channel","client"]},"maxItems":20,"description":"The most recent distinct actors, for a picker; pass an `id` as `actor`."},"retentionDays":{"type":"integer","description":"How far back this site's plan keeps activity (90 on Free, 365 on paid)."}},"required":["changes","actors","needsReview","actorList","retentionDays"],"description":"Present only with `summary=1`."},"ActivityList":{"type":"object","properties":{"enabled":{"type":"boolean"},"events":{"type":"array","items":{"$ref":"#/components/schemas/ActivityEvent"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` for the next page; null on the last page."},"summary":{"$ref":"#/components/schemas/ActivitySummary"}},"required":["enabled","events","nextCursor"]},"RevertBlock":{"type":"string","enum":["published_creation","deleted_with_routes","brief","truncated","already_reverted","not_content"],"description":"Why a change cannot be undone here. `published_creation`: it created a page that is live now (delete the page instead). `deleted_with_routes`: it deleted a page and its routes. `brief`: the site brief has its own rules. `truncated`: too many files. `already_reverted`: it was undone. `not_content`: this event changed no page."},"DiffRow":{"type":"object","properties":{"type":{"type":"string","enum":["ctx","add","del","skip"]},"text":{"type":"string","description":"The line. Empty on `skip`."},"count":{"type":"integer","description":"On `skip`: how many unchanged lines it stands for."}},"required":["type","text"]},"ActivityDiff":{"type":"object","properties":{"id":{"type":"string"},"key":{"type":"string","description":"The key `rows` belong to."},"keys":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"change":{"type":"string","enum":["created","edited","deleted"]},"revertable":{"type":"boolean"},"reason":{"$ref":"#/components/schemas/RevertBlock"}},"required":["key","change","revertable"]},"description":"Every content key of the change, with whether it can be undone."},"rows":{"type":"array","items":{"$ref":"#/components/schemas/DiffRow"},"description":"Changed lines with 3 lines of context; `skip` rows stand for the unchanged stretches."},"added":{"type":"integer"},"removed":{"type":"integer"},"truncated":{"type":"boolean","description":"A side was over 512 KB (only its head was compared), or the rows were cut at 2,000."},"revert":{"type":"object","properties":{"allowed":{"type":"boolean"},"reason":{"$ref":"#/components/schemas/RevertBlock"}},"required":["allowed"],"description":"Whether the whole change can be undone, and the reason when it cannot."}},"required":["id","key","keys","rows","added","removed","truncated","revert"]},"ActivityRevertResult":{"type":"object","properties":{"reverted":{"type":"array","items":{"type":"string"},"description":"Content keys put back. They are staged as a draft — publish to make them live."},"skipped":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"reason":{"anyOf":[{"type":"string","enum":["changed_since"]},{"$ref":"#/components/schemas/RevertBlock"}]}},"required":["key","reason"]},"description":"Keys left alone. `changed_since`: the page was edited after this change, so undoing it would overwrite the newer edit."},"eventId":{"type":["string","null"],"description":"The id of the new `revert` event when at least one file was written back. Null when the audit insert failed, or when nothing was written (every key was already at the requested version — the source change may still be marked reverted)."},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking notes about tags that will not compose, one per problem, each as `<key>: <code> — <what to change>`. Codes: `fragment_unclosed`, `fragment_bad_name`, `fragment_full_document`, `fragment_self_reference`, `fragment_attr_hyphen`, `legacy_paige_tag`, `layout_missing_content`, `unknown_dotsy_syntax`. The write still happened. At most 50 are listed."}},"required":["reverted","skipped","eventId"]},"ActivityChangeConflictError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"conflicts":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"expected":{"type":["string","null"]},"current":{"type":["string","null"]}},"required":["key","expected","current"]},"description":"`expected` is the version this change left behind; `current` is what the page is now (null = deleted). Hashes."}},"required":["conflicts"]}]},"ActivityRevertBlockedError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"reason":{"$ref":"#/components/schemas/RevertBlock"}},"required":["reason"]}]},"SharedSectionNotEligible":{"type":["string","null"],"enum":["inside_section","spans_template","not_a_block","too_large","collection_page","no_layout","several_layouts","not_at_edge","layout_owned","nested","not_on_page","page_config_invalid","too_deep","has_code"],"description":"Why `every_page` is not offered."},"AnalyzeSharedSectionResult":{"type":"object","properties":{"sourceKey":{"type":"string"},"sourceEtag":{"type":"string","description":"The page's content hash. Send it back as `ifMatch` to share."},"block":{"type":"object","properties":{"exact":{"type":"string","description":"Hash of the element's normalised HTML (text and classes kept). For `selectors`: the run's id (as in suggestions)."},"tag":{"type":"string","description":"The element's tag; for `selectors`, the wrapper the section gets.","example":"section"},"text":{"type":"string","description":"Its first 120 characters of text, for a label."},"bytes":{"type":"integer"}},"required":["exact","tag","text","bytes"]},"proposedName":{"type":"string","description":"A free section name made from its heading or text.","example":"booking-banner"},"matches":{"type":"object","properties":{"pages":{"type":"integer","description":"Distinct pages, counted in full."},"keys":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"count":{"type":"integer","description":"Copies on that page (all are replaced)."}},"required":["key","count"]},"maxItems":200,"description":"Pages with an exact copy, the source page first, at most 200. For `selectors`: pages with the same run."},"more":{"type":"boolean","description":"True when there are more pages than `keys` lists."}},"required":["pages","keys","more"]},"similar":{"type":"object","properties":{"pages":{"type":"integer","description":"Distinct pages, counted in full."},"keys":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"score":{"type":"number","description":"0–1; higher is closer."}},"required":["key","score"]},"maxItems":200,"description":"Pages with the same structure but different words, closest first, at most 200. Not shared unless sent in `similar`. Always empty for `selectors` (a run is only shared where it repeats exactly)."},"more":{"type":"boolean","description":"True when there are more pages than `keys` lists."}},"required":["pages","keys","more"]},"placement":{"type":"object","properties":{"everyPageAllowed":{"type":"boolean"},"reason":{"$ref":"#/components/schemas/SharedSectionNotEligible"},"layout":{"type":["string","null"],"example":"layouts/default.html"},"edge":{"type":["string","null"],"enum":["top","bottom"],"description":"Where the layout slot goes, around the page content."},"layoutPages":{"type":"integer","description":"Pages that use that layout."},"gaining":{"type":"object","properties":{"pages":{"type":"integer","description":"Distinct pages, counted in full."},"keys":{"type":"array","items":{"type":"string"},"maxItems":200,"description":"Pages on the layout without a copy today. With `every_page` they show it too, unless you list them in `hide`."},"more":{"type":"boolean","description":"True when there are more pages than `keys` lists."}},"required":["pages","keys","more"]}},"required":["everyPageAllowed","reason","layout","edge","layoutPages","gaining"]},"indexWarming":{"type":"boolean","description":"The index is still being built: counts may be low, and sharing is refused until it is done."},"warnings":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"hint":{"type":"string"}},"required":["code","hint"]}}},"required":["sourceKey","sourceEtag","block","proposedName","matches","similar","placement","indexWarming","warnings"]},"SharedSectionConflictError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"conflicts":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"reason":{"type":"string","enum":["changed","not_a_match"]}},"required":["key","reason"]},"description":"`changed`: the page changed since it was read. `not_a_match`: the page no longer has an exact copy."}},"required":["conflicts"]}]},"SharedSectionNotEligibleError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"reason":{"allOf":[{"$ref":"#/components/schemas/SharedSectionNotEligible"},{"type":"string"}]}},"required":["reason"]}]},"AnalyzeSharedSectionRequest":{"type":"object","properties":{"sourceKey":{"type":"string","minLength":1,"description":"Content key of the page the element is on, e.g. 'about.html'."},"selector":{"type":"string","minLength":1,"maxLength":2000,"description":"CSS selector of the element in the page's stored HTML (the editor's selector, or one checked with get_content?selector=). It must match exactly one element."},"ifMatch":{"type":"string","minLength":1,"description":"The page's content hash (analyze's sourceEtag). If the page changed since, nothing is written."},"selectors":{"type":"array","items":{"type":"string","minLength":1,"maxLength":2000},"minItems":1,"maxItems":50,"description":"A run of elements that sit next to each other in one parent (only whitespace or comments between), in page order — instead of `selector`. Each must match exactly one element. The section is the run inside one wrapper (`wrapTag`); the pages that hold the same run (the same elements, in the same order, next to each other) take it."},"wrapTag":{"type":"string","enum":["div","section","footer","header","article","aside"],"description":"With `selectors`: the wrapper element. Default `footer` when the run is the last of the page content, else `section`."},"keepLayout":{"type":"boolean","description":"With `selectors`: give the wrapper style=\"display:contents\" so a flex or grid parent still lays out the run's elements as before. Default true."}},"required":["sourceKey"]},"CompareSharedSectionResult":{"type":"object","properties":{"key":{"type":"string"},"changes":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["text","link","image","attr"]},"tag":{"type":"string","example":"h2"},"before":{"type":"string","description":"This page's own wording, which it would lose."},"after":{"type":"string","description":"The shared wording."}},"required":["kind","tag","before","after"]},"maxItems":50},"more":{"type":"boolean"}},"required":["key","changes","more"]},"CompareSharedSectionRequest":{"type":"object","properties":{"sourceKey":{"type":"string","minLength":1,"description":"Content key of the page the element is on, e.g. 'about.html'."},"selector":{"type":"string","minLength":1,"maxLength":2000,"description":"CSS selector of the element in the page's stored HTML (the editor's selector, or one checked with get_content?selector=). It must match exactly one element."},"ifMatch":{"type":"string","minLength":1,"description":"The page's content hash (analyze's sourceEtag). If the page changed since, nothing is written."},"key":{"type":"string","minLength":1,"description":"A page from analyze's similar.keys: the words it would lose by taking the shared version."}},"required":["sourceKey","selector","key"]},"SharedSectionSuggestion":{"type":"object","properties":{"id":{"type":"string","description":"Stable id (the block hash, or for a run its id). Dismiss with it."},"pages":{"type":"integer","description":"Pages with an exact copy (3 or more)."},"blocks":{"type":"integer","minimum":1,"description":"How many elements it is: 1 for one block; 2–6 for a run of blocks that sit next to each other and repeat together (share it with `sample.selectors`, it gets one wrapper)."},"bytes":{"type":"integer"},"rank":{"type":"number","description":"Higher first: more pages and bigger blocks rank higher."},"why":{"type":"string","example":"Same banner on 6 pages"},"proposedName":{"type":"string"},"sample":{"type":"object","properties":{"key":{"type":"string"},"selector":{"type":"string","description":"The element's selector; for a run, its first element's."},"selectors":{"type":"array","items":{"type":"string"},"minItems":2,"description":"A run: every element's selector, in page order."}},"required":["key","selector"],"description":"One page that has it and its selector there — send `selector` (one block) or `selectors` (a run) to analyze or share."},"here":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"maxItems":5,"description":"With `key`: its copies on that page — one selector per copy, or for a run one array of selectors per copy. Absent when that page has none."}},"required":["id","pages","blocks","bytes","rank","why","proposedName","sample"]},"SharedSectionSuggestions":{"type":"object","properties":{"suggestions":{"type":"array","items":{"$ref":"#/components/schemas/SharedSectionSuggestion"}},"indexWarming":{"type":"boolean"}},"required":["suggestions","indexWarming"]},"DismissSharedSectionSuggestionRequest":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":128,"description":"A suggestion id from GET …/shared-sections/suggestions."}},"required":["id"]},"SharedSectionRow":{"type":"object","properties":{"name":{"type":"string"},"key":{"type":"string","example":"fragments/booking-banner.html"},"showsOn":{"type":"string","enum":["every_page","pages"],"description":"`every_page` when a layout places it."},"layouts":{"type":"array","items":{"type":"string"},"description":"Layouts that place it."},"pageCount":{"type":"integer","description":"Pages that show it (pages that hide it are not counted)."},"hiddenCount":{"type":"integer"},"exists":{"type":"boolean","description":"False when pages reference a section file that is missing."},"hasChanges":{"type":"boolean","description":"The section file has a staged change."},"updatedAt":{"type":["integer","null"],"description":"Last write, Unix ms. Null when the store can't tell without extra reads."}},"required":["name","key","showsOn","layouts","pageCount","hiddenCount","exists","hasChanges","updatedAt"]},"SharedSectionList":{"type":"object","properties":{"sections":{"type":"array","items":{"$ref":"#/components/schemas/SharedSectionRow"}},"nextCursor":{"type":["string","null"]},"indexing":{"type":"object","properties":{"pending":{"type":"integer","description":"Pages whose structure is not indexed yet."},"running":{"type":"boolean"}},"required":["pending","running"]},"partial":{"type":"boolean"}},"required":["sections","nextCursor","indexing","partial"]},"SharedSectionPages":{"type":"object","properties":{"section":{"$ref":"#/components/schemas/SharedSectionRow"},"pages":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"contentKey":{"type":["string","null"]},"title":{"type":["string","null"]},"via":{"type":"string","enum":["page","layout","section"],"description":"`page`: the page places it (detach works). `layout`: its layout does (hide works). `section`: another section does."},"hidden":{"type":"boolean"}},"required":["path","contentKey","title","via","hidden"]}},"nextCursor":{"type":["string","null"]},"indexing":{"type":"object","properties":{"pending":{"type":"integer","description":"Pages whose structure is not indexed yet."},"running":{"type":"boolean"}},"required":["pending","running"]},"partial":{"type":"boolean"}},"required":["section","pages","nextCursor","indexing","partial"]},"SharedSectionScope":{"type":"string","enum":["where_found","every_page"]},"ShareSectionResult":{"type":"object","properties":{"name":{"type":"string"},"key":{"type":"string","example":"fragments/booking-banner.html"},"scope":{"$ref":"#/components/schemas/SharedSectionScope"},"pages":{"type":"integer","description":"Pages that show it now."},"hidden":{"type":"integer","description":"every_page: pages on the layout that hide it."},"skipped":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"reason":{"type":"string","enum":["ambiguous_similar","not_a_match"]}},"required":["key","reason"]},"description":"Pages left alone. `ambiguous_similar`: none or several similar copies on the page; on every page such a page hides the section (it keeps its own copies, and counts in `hidden`). `not_a_match` (`selectors` only): the index listed the page but its stored HTML doesn't hold the same run of elements, so it keeps what it has."},"written":{"type":"array","items":{"type":"string"},"description":"Every content key this call staged (or, on a dry run, would stage): the section file, pages, the layout. Staged as a draft; in live publishing mode the dashboard then publishes exactly these keys."},"alreadyApplied":{"type":"boolean","description":"True on a retry of a share that already happened: nothing was written."},"eventId":{"type":["string","null"],"description":"The activity event of this change — undo with POST …/activity/{eventId}/revert. Null on a dry run, when nothing was written, or when the audit insert failed."}},"required":["name","key","scope","pages","hidden","skipped","written","alreadyApplied","eventId"]},"SectionNameTakenError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"suggestion":{"type":"string","description":"A free name to use instead.","example":"booking-banner-2"}},"required":["suggestion"]}]},"ShareSectionRequest":{"type":"object","properties":{"sourceKey":{"type":"string","minLength":1,"description":"Content key of the page the element is on, e.g. 'about.html'."},"selector":{"type":"string","minLength":1,"maxLength":2000,"description":"CSS selector of the element in the page's stored HTML (the editor's selector, or one checked with get_content?selector=). It must match exactly one element."},"ifMatch":{"type":"string","minLength":1,"description":"The page's content hash (analyze's sourceEtag). If the page changed since, nothing is written."},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,64}$","description":"Name of the new section. Default: analyze's proposedName. A name in use answers 409 section_name_taken."},"scope":{"type":"string","enum":["where_found","every_page"],"default":"where_found","description":"where_found: each page that has it gets a reference. every_page: one slot in the layout, at the top or bottom of the page content; pages listed in `hide` don't show it."},"keys":{"type":"array","items":{"type":"string","minLength":1},"maxItems":1000,"description":"Pages with an exact copy to share it on (analyze's matches.keys). Omit for every exact copy. The source page is always included."},"similar":{"type":"array","items":{"type":"string","minLength":1},"maxItems":200,"description":"Pages with a similar copy (analyze's similar.keys) that take the shared version, losing their own wording. Default none."},"hide":{"type":"array","items":{"type":"string","minLength":1},"maxItems":1000,"description":"every_page only: pages on the layout that should not show it. Default none."},"selectors":{"type":"array","items":{"type":"string","minLength":1,"maxLength":2000},"minItems":1,"maxItems":50,"description":"A run of elements that sit next to each other in one parent (only whitespace or comments between), in page order — instead of `selector`. Each must match exactly one element. The section is the run inside one wrapper (`wrapTag`); the pages that hold the same run (the same elements, in the same order, next to each other) take it."},"wrapTag":{"type":"string","enum":["div","section","footer","header","article","aside"],"description":"With `selectors`: the wrapper element. Default `footer` when the run is the last of the page content, else `section`."},"keepLayout":{"type":"boolean","description":"With `selectors`: give the wrapper style=\"display:contents\" so a flex or grid parent still lays out the run's elements as before. Default true."}},"required":["sourceKey"]},"SharedSectionPlacementResult":{"type":"object","properties":{"name":{"type":"string"},"key":{"type":"string"},"scope":{"$ref":"#/components/schemas/SharedSectionScope"},"pages":{"type":"integer"},"hidden":{"type":"integer"},"skipped":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"reason":{"type":"string","enum":["ambiguous_similar","not_a_match"]}},"required":["key","reason"]},"description":"Pages left alone. `ambiguous_similar`: none or several similar copies on the page; on every page such a page hides the section (it keeps its own copies, and counts in `hidden`). `not_a_match` (`selectors` only): the index listed the page but its stored HTML doesn't hold the same run of elements, so it keeps what it has."},"written":{"type":"array","items":{"type":"string"},"description":"Every content key this call staged (or, on a dry run, would stage): the section file, pages, the layout. Staged as a draft; in live publishing mode the dashboard then publishes exactly these keys."},"dryRun":{"type":"boolean"},"alreadyApplied":{"type":"boolean"},"eventId":{"type":["string","null"],"description":"The activity event of this change — undo with POST …/activity/{eventId}/revert. Null on a dry run, when nothing was written, or when the audit insert failed."}},"required":["name","key","scope","pages","hidden","written","dryRun","alreadyApplied","eventId"]},"SharedSectionPlacementRequest":{"type":"object","properties":{"scope":{"type":"string","enum":["where_found","every_page"],"description":"Move the section between where_found and every_page, in one all-or-nothing change."},"keys":{"type":"array","items":{"type":"string","minLength":1},"maxItems":1000,"description":"every_page in slices, for a move over the per-call cap: the pages that place it whose own tag this call removes (default all of them). The first call also puts it in the layout; pages not moved yet keep their tag and still show it once. Call again with the next slice until every page is moved."},"hide":{"type":"array","items":{"type":"string","minLength":1},"maxItems":1000,"description":"Pages that should stop showing this every-page section (a marker in each page's <dotsy-config>). Moving to every page, pages kept out (they keep their own copy) go with the first move or ahead of it (hide without scope, before any move)."},"show":{"type":"array","items":{"type":"string","minLength":1},"maxItems":1000,"description":"Pages that should show it again (removes their marker)."},"dryRun":{"type":"boolean","description":"Report what would change; write nothing."}}},"DetachSharedSectionResult":{"type":"object","properties":{"name":{"type":"string"},"pageKey":{"type":"string"},"written":{"type":"array","items":{"type":"string"},"description":"Every content key this call staged (or, on a dry run, would stage): the section file, pages, the layout. Staged as a draft; in live publishing mode the dashboard then publishes exactly these keys."},"dryRun":{"type":"boolean"},"alreadyApplied":{"type":"boolean"},"eventId":{"type":["string","null"],"description":"The activity event of this change — undo with POST …/activity/{eventId}/revert. Null on a dry run, when nothing was written, or when the audit insert failed."}},"required":["name","pageKey","written","dryRun","alreadyApplied","eventId"]},"DetachSharedSectionRequest":{"type":"object","properties":{"pageKey":{"type":"string","minLength":1,"description":"Content key of the page that gets its own copy, e.g. 'pricing.html'."},"ifMatch":{"type":"string","minLength":1,"description":"The page's content hash. If the page changed since, nothing is written."},"dryRun":{"type":"boolean","description":"Report what would change; write nothing."}},"required":["pageKey"]},"SiteMember":{"type":"object","properties":{"userId":{"type":"string"},"email":{"type":["string","null"]},"name":{"type":["string","null"]},"role":{"type":"string","enum":["owner","editor","viewer"]},"isInternal":{"type":"boolean","description":"True for someone inside your own organization, as opposed to an outside guest."},"invitedBy":{"type":["string","null"],"description":"The id of whoever invited them."},"createdAt":{"type":"number","description":"When access was granted, Unix ms."}},"required":["userId","email","name","role","isInternal","invitedBy","createdAt"]},"SitePendingInvite":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"role":{"type":"string","enum":["editor","viewer"]},"isInternal":{"type":"boolean"},"invitedBy":{"type":"string"},"expiresAt":{"type":"number","description":"Unix ms."},"createdAt":{"type":"number","description":"Unix ms."}},"required":["id","email","role","isInternal","invitedBy","expiresAt","createdAt"]},"SiteMemberList":{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/SiteMember"}},"nextCursor":{"type":["string","null"],"description":"Present only when the request sent `limit`. Pass it as `cursor` (with the same `limit`) for the next page; null on the last page."},"pendingInvites":{"type":"array","items":{"$ref":"#/components/schemas/SitePendingInvite"},"description":"Always the complete list, whatever `limit` says."}},"required":["members","pendingInvites"]},"SiteTeammate":{"type":"object","properties":{"userId":{"type":"string"},"email":{"type":["string","null"]},"name":{"type":["string","null"]},"role":{"type":"string","enum":["owner","admin","member"]}},"required":["userId","email","name","role"]},"SiteTeammateList":{"type":"object","properties":{"teammates":{"type":"array","items":{"$ref":"#/components/schemas/SiteTeammate"}}},"required":["teammates"]},"CreateSiteInviteResponse":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string","description":"Lowercased."},"role":{"type":"string","enum":["editor","viewer"]},"isInternal":{"type":"boolean"},"expiresAt":{"type":"number","description":"Invites last 7 days. Unix ms."},"token":{"type":"string","description":"The raw invite token. It is shown once and never stored."},"invitePath":{"type":"string","description":"Dashboard path the invited person opens to accept.","example":"/invite/ONQ4GKMQYmbfppENWARDX"},"emailSent":{"type":"boolean","description":"True when an email was sent. False when email is not configured on this server, so share `invitePath` yourself."}},"required":["id","email","role","isInternal","expiresAt","token","invitePath","emailSent"]},"CreateTransferResponse":{"type":"object","properties":{"id":{"type":"string","description":"The transfer's id. Use it to cancel the offer."},"transferPath":{"type":"string","description":"Dashboard path the recipient opens to review the offer.","example":"/transfer/Xk3m9Qz"},"toEmail":{"type":"string","description":"Lowercased."},"emailSent":{"type":"boolean","description":"True when an email was sent. False when email is not configured on this server, so share `transferPath` yourself."}},"required":["id","transferPath","toEmail","emailSent"]},"TransferPreview":{"type":"object","properties":{"valid":{"type":"boolean","description":"True when the offer can still be accepted."},"expired":{"type":"boolean"},"cancelled":{"type":"boolean"},"accepted":{"type":"boolean"},"siteSlug":{"type":["string","null"]},"siteTitle":{"type":["string","null"]},"fromWorkspaceName":{"type":["string","null"]},"toEmail":{"type":"string","description":"The address the offer was made to."},"keepCollaborators":{"type":"boolean"},"expiresAt":{"type":"number","description":"Unix ms."}},"required":["valid","expired","cancelled","accepted","siteSlug","siteTitle","fromWorkspaceName","toEmail","keepCollaborators","expiresAt"]},"AcceptTransferResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"siteSlug":{"type":"string"},"toWorkspaceId":{"type":"string","description":"The workspace that now owns the site."}},"required":["ok","siteSlug","toWorkspaceId"]},"RenameSiteResponse":{"type":"object","properties":{"slug":{"type":"string","description":"The slug the site now has.","example":"acme-studio-2"},"previousSlug":{"type":"string","example":"acme-studio"},"publicUrl":{"type":"string","example":"https://acme-studio-2.dotsy.site/"},"liveUrl":{"type":"string","example":"https://acme-studio-2.dotsy.site/"},"liveReady":{"type":"boolean","description":"True when the site had been published (the new address serves right away)."},"aliasRedirect":{"type":"boolean","description":"True when the old slug keeps redirecting (the site had been published). False when the old slug was released."},"hint":{"type":"string","description":"The warnings joined into one sentence."},"warnings":{"type":"array","items":{"type":"string"},"description":"What to know after the rename. Plain sentences."}},"required":["slug","previousSlug","publicUrl","liveUrl","liveReady","aliasRedirect","hint","warnings"]},"RenameSiteAccepted":{"type":"object","properties":{"status":{"type":"string","enum":["copying"]},"jobId":{"type":"string","description":"Legacy rename job id. Prefer `job.id` with the jobs endpoint."},"previousSlug":{"type":"string"},"newSlug":{"type":"string"},"hint":{"type":"string"},"warnings":{"type":"array","items":{"type":"string"}},"aliasRedirect":{"type":"boolean"},"job":{"allOf":[{"$ref":"#/components/schemas/JobRef"},{"description":"Poll `GET /api/v1/sites/{slug}/jobs/{id}` with `job.id` until `status` is `succeeded` or `failed`. Omitted only when the server has no job-id secret configured."}]}},"required":["status","jobId","previousSlug","newSlug","hint","warnings","aliasRedirect"]},"WorkspacePlanView":{"type":"object","properties":{"workspaceId":{"type":"string"},"plan":{"type":"string","enum":["free","core"]},"freeSites":{"type":"object","properties":{"used":{"type":"integer"},"max":{"type":"integer"},"base":{"type":"integer"},"perPaid":{"type":"integer"}},"required":["used","max","base","perPaid"]},"paidSites":{"type":"integer","description":"Sites in this workspace on the Pro plan."},"protectedSites":{"type":"integer","description":"Sites in this workspace whose access is password."},"freeWorkspaces":{"type":"object","properties":{"owned":{"type":"integer","description":"Free workspaces this person owns. Invited memberships are not counted."},"limit":{"type":"integer"}},"required":["owned","limit"]}},"required":["workspaceId","plan","freeSites","paidSites","protectedSites","freeWorkspaces"]},"GuidanceHint":{"type":"object","properties":{"missing":{"type":"array","items":{"type":"string","enum":["design","content","seo"]}},"next":{"type":"string","enum":["Before your first change: load get_skill('guidance') and write the missing sections."]}},"required":["missing","next"],"description":"Which rules & guidelines sections are still empty and the MCP skill to fill them (`get_skill('guidance')`)."},"CreateSiteResponse":{"type":"object","properties":{"slug":{"type":"string","description":"The slug the site was created with. It differs from `requestedSlug` when `slugAdjusted` is true.","example":"acme-studio"},"requestedSlug":{"type":"string","description":"The slug you asked for.","example":"acme-studio"},"slugAdjusted":{"type":"boolean","description":"True when the slug was taken and a variant was picked."},"title":{"type":["string","null"],"description":"The title you sent, or null."},"keyConvention":{"type":"string","enum":["flat","directory"]},"editingMode":{"type":"string","enum":["live","staged"]},"createdAt":{"type":"number","description":"Creation time, Unix ms."},"created":{"type":"boolean","enum":[true]},"publicUrl":{"type":"string","example":"https://acme-studio.dotsy.site/"},"liveUrl":{"type":"string","description":"Same as `publicUrl`.","example":"https://acme-studio.dotsy.site/"},"liveReady":{"type":"boolean","description":"False: nothing is published yet."},"guidance":{"$ref":"#/components/schemas/GuidanceHint"}},"required":["slug","requestedSlug","slugAdjusted","title","keyConvention","editingMode","createdAt","created","publicUrl","liveUrl","liveReady"]},"ApiKey":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":["string","null"]},"scope":{"type":"string","enum":["read","write","admin"]},"siteSlugs":{"type":["array","null"],"items":{"type":"string"},"description":"The sites the key is limited to, or null for all sites."},"createdAt":{"type":"number","description":"Creation time, Unix ms."},"secret":{"type":["string","null"],"description":"The key itself (`pga_…`). Null for an old key whose secret was never stored."}},"required":["id","label","scope","siteSlugs","createdAt","secret"]},"ApiKeyList":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}},"nextCursor":{"type":["string","null"],"description":"Present only when the request sent `limit`. Pass it as `cursor` (with the same `limit`) for the next page; null on the last page."}},"required":["keys"]},"InvitePreview":{"type":"object","properties":{"valid":{"type":"boolean","description":"True when the invite can still be accepted."},"expired":{"type":"boolean"},"alreadyAccepted":{"type":"boolean"},"siteSlug":{"type":["string","null"]},"siteTitle":{"type":["string","null"]},"workspaceName":{"type":["string","null"]},"role":{"type":"string","enum":["editor","viewer"]},"invitedBy":{"type":["string","null"],"description":"The inviter's name, or their email when they have no name."},"expiresAt":{"type":"number","description":"Unix ms."}},"required":["valid","expired","alreadyAccepted","siteSlug","siteTitle","workspaceName","role","invitedBy","expiresAt"]},"AcceptInviteResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"siteSlug":{"type":"string"},"role":{"type":"string","enum":["editor","viewer"]}},"required":["ok","siteSlug","role"]},"StartGuestSiteResponse":{"type":"object","properties":{"slug":{"type":"string","description":"The new site's slug: `anon` followed by ten letters and digits.","example":"anon3f9c1b27a4"},"guestToken":{"type":"string","description":"The guest token (`pgg_…`). It is returned only here, so store it. Send it as `Authorization: Bearer <guestToken>`; it authenticates as `GuestBearerAuth` on this one site.","example":"pgg_Zk3qR1m7vT0aXc9LwYb2NfHj5uPdE8sGiO4tAx6VnMo"},"expiresAt":{"type":"number","description":"When the guest site is deleted if nobody claims it, Unix ms. 72 hours after creation.","example":1790000000000},"draftOnly":{"type":"boolean","enum":[true],"description":"Always true: a guest site can be edited as a draft but cannot be published until it is claimed."},"guidance":{"$ref":"#/components/schemas/GuidanceHint"}},"required":["slug","guestToken","expiresAt","draftOnly"]},"ClaimGuestSiteResponse":{"type":"object","properties":{"slug":{"type":"string","description":"The claimed site's slug. It keeps its `anon…` slug.","example":"anon3f9c1b27a4"},"status":{"type":"string","enum":["claimed"]}},"required":["slug","status"]},"CreateWorkspaceInviteResponse":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"role":{"type":"string","description":"`admin` or `member`."},"status":{"type":"string","description":"`pending` until the person accepts, declines or the invite expires."},"expiresAt":{"type":"string","format":"date-time","description":"When the invite stops working.","example":"2026-10-09T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"When the invite was created.","example":"2026-10-09T09:00:00.000Z"},"organizationId":{"type":"string","description":"The workspace the invite is for."},"acceptPath":{"type":"string","description":"Dashboard path the invited person opens to accept.","example":"/invite/workspace/inv_abc123"},"workspaceSiteCount":{"type":"number","description":"How many sites a new teammate will see. Use it in a confirmation like \"Add teammate: sees all N sites\"."},"emailSent":{"type":"boolean","description":"True when an email was sent. False when email is not configured on this server, so share `acceptPath` yourself."}},"required":["id","email","role","status","expiresAt","createdAt","organizationId","acceptPath","workspaceSiteCount","emailSent"]},"WorkspaceInvite":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"role":{"type":"string","description":"`admin` or `member`."},"status":{"type":"string","description":"`pending` until the person accepts, declines or the invite expires."},"expiresAt":{"type":"string","format":"date-time","description":"When the invite stops working.","example":"2026-10-09T09:00:00.000Z"},"createdAt":{"type":"string","format":"date-time","description":"When the invite was created.","example":"2026-10-09T09:00:00.000Z"},"acceptPath":{"type":"string","description":"Dashboard path the invited person opens to accept.","example":"/invite/workspace/inv_abc123"}},"required":["id","email","role","status","expiresAt","createdAt","acceptPath"]},"WorkspaceInviteList":{"type":"object","properties":{"invitations":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvite"}}},"required":["invitations"]},"WorkspaceMember":{"type":"object","properties":{"id":{"type":"string","description":"The membership id. Use it as `memberId` in the role and remove operations."},"userId":{"type":"string"},"name":{"type":["string","null"]},"email":{"type":["string","null"]},"role":{"type":"string","enum":["owner","admin","member"]},"createdAt":{"type":"number","description":"When they joined, Unix ms."}},"required":["id","userId","name","email","role","createdAt"]},"WorkspaceMemberList":{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMember"}}},"required":["members"]},"Site":{"type":"object","properties":{"id":{"type":"string","description":"Stable site id (UUID). Unlike the slug, it never changes.","example":"3f6c1d52-8a4b-4e0e-9c57-2f1b7a9d6e10"},"slug":{"type":"string"},"title":{"type":["string","null"]},"keyConvention":{"type":"string","enum":["flat","directory"]},"backend":{"type":"string","enum":["kv","do-sqlite"]},"status":{"type":"string","enum":["published","draft"]},"editingMode":{"type":"string","enum":["live","staged"]},"publishedAt":{"type":["number","null"]},"publicUrl":{"type":"string","description":"Canonical public site URL for this slug.","example":"https://acme-studio.dotsy.site/"},"liveUrl":{"type":"string","description":"Alias of publicUrl.","example":"https://acme-studio.dotsy.site/"},"liveReady":{"type":"boolean","description":"True after first publish; until then the public URL 404s."},"build":{"type":["object","null"],"properties":{"jobId":{"type":"string"},"phase":{"type":"string","enum":["pages","assets","done","failed"]},"pagesDone":{"type":"number"},"pagesTotal":{"type":"number"},"pagesLanded":{"type":"number"},"updatedAt":{"type":"number"}},"required":["jobId","phase","pagesDone","pagesTotal","pagesLanded","updatedAt"],"description":"Active clone/build progress, or null when no build is running."},"building":{"type":"boolean","description":"build != null && build.phase !== 'done' — clone/build still running."},"buildPhase":{"type":["string","null"],"enum":["pages","assets","done","failed"],"description":"Current build phase, or null when no build is active."},"editorReady":{"type":"boolean","description":"!building && stats.pages > 0 — draft/editor is renderable. Distinct from liveReady (public URL)."},"createdAt":{"type":"number"},"updatedAt":{"type":"number"},"stats":{"type":"object","properties":{"pages":{"type":"number"},"assets":{"type":"number","description":"CMS uploads under assets/ only — not mirrored clone CDN bytes (see mirroredAssets)."},"storageBytes":{"type":"number"},"lastBuild":{"type":["number","null"]}},"required":["pages","assets","storageBytes","lastBuild"]},"pages":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","example":"index.html"},"name":{"type":"string","example":"Home"},"route":{"type":"string","example":"/"},"updatedAt":{"type":"number"}},"required":["key","name","route","updatedAt"]}},"pagesListTotal":{"type":"number","description":"Total HTML pages matching pagesQ (or all pages when pagesQ is omitted)."},"nextPagesCursor":{"type":["string","null"],"description":"Present when more HTML pages exist — pass as pagesCursor on the next call."},"pagesPartial":{"type":"boolean","description":"True when `pages` is one window of the page-key space, not all of it (mirrors /pages's own `partial`) — follow nextPagesCursor rather than treating pagesListTotal as exact."},"statsPartial":{"type":"boolean","description":"True when stats.pages/pagesListTotal is a floor from the KV arm's bounded meta-prefix probe, not an exact count. Distinct from pagesPartial, which is about the returned page WINDOW rather than the count."},"activity":{"type":"array","items":{"type":"object","properties":{"kind":{"type":"string","enum":["published","updated","created"]},"label":{"type":"string","example":"Published to the edge"},"at":{"type":"number"}},"required":["kind","label","at"]}},"brief":{"type":["object","null"],"properties":{"purpose":{"type":"string","minLength":5,"maxLength":280},"audience":{"type":"string","maxLength":200},"kind":{"type":"string","enum":["exploration","production"]},"priorities":{"type":"object","properties":{"seo":{"type":"string","enum":["must","nice","skip"]},"analytics":{"type":"string","enum":["must","nice","skip"]},"design":{"type":"string","enum":["must","nice","skip"]},"mobile":{"type":"string","enum":["must","nice","skip"]}}},"editingModel":{"type":"string","enum":["ai","simple-editor","creator"]},"design":{"type":"object","properties":{"palette":{"type":"array","items":{"type":"string"},"maxItems":8},"type":{"type":"array","items":{"type":"string"},"maxItems":6},"layout":{"type":"string","maxLength":80}},"additionalProperties":false},"hasBody":{"type":"boolean"}},"required":["purpose","hasBody"],"description":"Only with `?brief=1`: the head of the site brief (purpose, audience, priorities…), or null when none is set."},"guidance":{"type":"object","properties":{"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z0-9-]{2,40}$"},"card":{"type":"string","enum":["intent","design","content","seo"]},"text":{"type":"string"},"kind":{"type":"string","enum":["rule","guideline"]},"level":{"type":"string","enum":["block","warn"]},"locked":{"type":"boolean"},"check":{"type":"object","properties":{"type":{"type":"string"}},"required":["type"]}},"required":["id","card","text","kind","locked"]}},"notes":{"type":"object","properties":{"design":{"type":"string","maxLength":300},"content":{"type":"string","maxLength":300},"seo":{"type":"string","maxLength":300}}},"missing":{"type":"array","items":{"type":"string","enum":["design","content","seo"]}},"next":{"type":"string","enum":["Before your first change: load get_skill('guidance') and write the missing sections."]},"aiAccess":{"type":"string","enum":["viewer","contributor","editor","owner"]}},"required":["rules","notes","missing","aiAccess"],"description":"Only with `?brief=1`: the site's rules & guidelines for AI agents — each line (a Rule Dotsy checks at publish, or a Guideline), `check` params on Rules (long lists truncated with `more`), short note previews, which sections are still unwritten (`missing`), optional `next: get_skill('guidance')` when `missing` is non-empty, and the AI access level. Present even when the site has no readable brief (everything `missing`)."},"draftLog":{"type":"array","items":{"type":"object","properties":{"tool":{"type":"string","minLength":1,"maxLength":80},"context":{"type":"string","minLength":1,"maxLength":500},"at":{"type":"number"}},"required":["tool","context","at"]}},"integrations":{"type":"object","properties":{"pending":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"vendor":{"type":"string"},"sourceId":{"type":"string"},"status":{"type":"string","enum":["pending"]},"pageCount":{"type":"number"},"mayAffectUi":{"type":"boolean"}},"required":["id","vendor","sourceId","status","pageCount"]}}},"required":["pending"],"description":"Pending clone integrations stripped at import (0.27.1)."},"mirroredAssets":{"type":"object","properties":{"state":{"type":"string"},"warmMode":{"type":"string"},"resolved":{"type":"boolean","description":"Warm has settled (not pending/running) — 'stop polling', NOT 'assets are fine'. state:\"aborted\" is also resolved; check assetsReady/mirrorComplete (already exclude aborted) or the `abort` field before reading this as healthy."},"counts":{"type":"object","properties":{"total":{"type":"number"},"warmed":{"type":"number"},"failed":{"type":"number"},"capped":{"type":"number"},"skipped":{"type":"number"}},"required":["total","warmed","failed","capped","skipped"]},"externalCapped":{"type":"object","properties":{"count":{"type":"number"},"total":{"type":"number"},"ratio":{"type":"number"}},"required":["count","total","ratio"],"description":"Fraction of assets left origin-backed (302 to source, not on Dotsy R2) — a fidelity/durability risk even when failed:0. ratio > 0.25 should read as visual PARTIAL minimum per the eval rubric (0.63 §6.4)."},"failedAssetUrls":{"type":"array","items":{"type":"string"},"description":"Bounded sample of failed asset source URLs for triage (capped at 50). counts.failed is the true total. Present only when there is something to show."},"abort":{"type":"object","properties":{"stage":{"type":"string"},"reason":{"type":"string"}},"description":"Present only when state:\"aborted\" — which named phase the run reached and why, so a total abort with 0 failures is never read as a clean success with nothing to do."},"generations":{"type":"number","description":"Manifest generations (import/re-import/segment-recovery attempts) folded into `counts`. Omitted for a single-generation site."}},"required":["state","counts","externalCapped"],"description":"Mirrored external CDN warm progress (assetMirror:full), cumulative across every generation this slug has ever warmed. Bytes land in raw/_cdn/ — not counted in stats.assets or list_assets."},"previewReady":{"type":"boolean","description":"Pages staged — preview/publish allowed while asset warm may still run."},"assetsReady":{"type":"boolean","description":"Import complete and external warm settled with zero failures. False when the latest warm aborted, even with 0 failures recorded (see mirroredAssets.abort)."},"mirrorComplete":{"type":"boolean","description":"Alias of assetsReady."},"failedAssetUrls":{"type":"array","items":{"type":"string"},"description":"Sample failed external warm URLs from the latest warm job."},"assetsHint":{"type":"string","description":"Present with mirroredAssets — explains CMS assets/ vs mirrored raw/_cdn/ lanes."}},"required":["id","slug","title","keyConvention","backend","status","editingMode","publishedAt","publicUrl","liveUrl","liveReady","build","building","buildPhase","editorReady","createdAt","updatedAt","stats","pages","pagesListTotal","nextPagesCursor","activity"]},"ImportStatusResponse":{"type":"object","properties":{"build":{"type":["object","null"],"properties":{"jobId":{"type":"string","description":"The import's workflow id (the `id` of the 202 response)."},"phase":{"type":"string","enum":["pages","assets","done","failed"],"description":"`pages`: pages are still being captured. `assets`: pages have landed (the site can be previewed and published) and mirrored assets are still copying. `done`: finished. `failed`: the import stopped; `pagesDone` shows what landed first."},"pagesDone":{"type":"integer","description":"Pages committed so far."},"pagesTotal":{"type":"integer","description":"Pages the import set out to capture."},"pagesLanded":{"type":"integer","description":"Pages the site holds right now (live count, not the job's counter)."},"updatedAt":{"type":"number","description":"Last progress write, Unix ms."},"externalAssets":{"type":"object","properties":{"total":{"type":"integer","description":"External assets found in the imported pages."},"warmed":{"type":"integer","description":"Assets copied into Dotsy so far."},"failed":{"type":"integer"},"capped":{"type":"integer","description":"Assets left out because of the plan's mirror limits."},"skipped":{"type":"integer"},"warmMode":{"type":"string","enum":["inline","deferred"],"description":"`inline` copies during the import, `deferred` in a background job afterwards."},"warmJobState":{"type":"string","enum":["pending","running","done","partial","aborted"],"description":"`partial` means the copy finished with some failures; `aborted` means the asset job itself stopped, so `failed: 0` there does not mean nothing is broken."},"manifestGen":{"type":"integer"},"abort":{"type":"object","properties":{"stage":{"type":"string"},"reason":{"type":"string"}},"description":"Present only with `warmJobState: aborted`."}},"required":["total","warmed","failed","capped","skipped","warmMode","warmJobState"],"description":"Mirrored asset copy progress. Present once the import has found external assets."},"failure":{"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"],"description":"Present only with `phase: failed`."}},"required":["jobId","phase","pagesDone","pagesTotal","pagesLanded","updatedAt"],"description":"The site's latest import, or `null` when the site has never been imported (or the record has expired)."}},"required":["build"]},"UpdateSiteResponse":{"type":"object","properties":{"ok":{"type":"boolean"},"title":{"type":["string","null"]},"editingMode":{"type":"string","enum":["live","staged"]},"keyConvention":{"type":"string","enum":["flat","directory"]},"updatedAt":{"type":"number"},"integrations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"vendor":{"type":"string"},"sourceId":{"type":"string"},"status":{"type":"string","enum":["pending","replaced","dismissed"]},"pageCount":{"type":"number"}},"required":["id","vendor","sourceId","status","pageCount"]},"description":"Present when the request resolved integrations: every integration after the change."},"signature":{"type":"object","properties":{"enabled":{"type":"boolean"},"mark":{"type":"string","enum":["logo","dot"]},"reveal":{"type":"string","enum":["pill","seal"]}},"required":["enabled","mark","reveal"],"description":"The saved signature badge choice (present when the request changed it)."},"aiAccess":{"type":"string","enum":["viewer","contributor","editor","owner"],"description":"The saved AI access level (present when the request changed it)."}},"required":["ok","title","editingMode","keyConvention","updatedAt"]},"UpdateSiteRequest":{"type":"object","properties":{"title":{"type":"string","maxLength":200},"keyConvention":{"type":"string","enum":["flat","directory"]},"editingMode":{"type":"string","enum":["live","staged"],"description":"How the editor's Save behaves. 'live' (default) publishes each save immediately; 'staged' keeps saves as drafts until publish_site. Prefer 'staged' when an agent will hand the editor to a person (see start_edit_session → draftEditorUrl) so keystrokes don't go straight to the public site. API writes always stage regardless of this setting."},"signature":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Show the badge in the bottom-right corner of every published page."},"mark":{"type":"string","enum":["logo","dot"],"description":"Resting look: 'dot' (senna dot, default) or 'logo' (Dotsy logo)."},"reveal":{"type":"string","enum":["pill","seal"],"description":"On hover: 'seal' (rotating stamp, default) or 'pill' (\"Made with Dotsy\")."}},"description":"\"Made with Dotsy\" signature badge. Takes effect on the live site immediately, no publish needed."},"aiAccess":{"type":"string","enum":["viewer","contributor","editor","owner"],"description":"What AI agents and API keys may do on this site: `viewer` (read only), `contributor` (save drafts; a person publishes), `editor` (drafts and publish) or `owner` (full access, the default). Only an owner signed in to the dashboard can change it."},"integrations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","minLength":1},"status":{"type":"string","enum":["replaced","dismissed"]},"replacedWith":{"type":"string","maxLength":200}},"required":["id","status"]},"maxItems":50,"description":"Resolve pending clone integrations (analytics stripped at import)."}},"description":"Only the fields you send are changed. Send at least one of `title`, `keyConvention`, `editingMode`, `signature`, `aiAccess` or `integrations`.","example":{"title":"Acme Studio","editingMode":"staged"}},"RulesCheck":{"type":"object","properties":{"checked":{"type":"integer"},"total":{"type":"integer"},"partial":{"type":"boolean"}},"required":["checked","total","partial"],"description":"Present with `ruleWarnings` when the brief has Rules — how much of the draft was checked."},"PublishRulesError":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"hint":{"type":"string","description":"What to do next — fix the page, update the rule, or publish in batches."},"issues":{"type":"array","items":{"$ref":"#/components/schemas/RuleIssue"},"maxItems":20,"description":"Failed Rules (block first), at most 20."},"checked":{"type":"integer","description":"Pages the gate composed and checked."},"total":{"type":"integer","description":"Pages this publish would change (can exceed `checked` when partial)."}},"required":["hint","issues","checked","total"]}]},"DeleteSiteResponse":{"type":"object","properties":{"ok":{"type":"boolean"},"purge":{"type":"string","enum":["done","queued"],"description":"`queued`: the site is already gone everywhere it can be seen, and its stored files are still being removed in the background. `done`: nothing is left."}},"required":["ok"]},"ImportPreflightResult":{"type":"object","properties":{"sourceUrl":{"type":"string"},"mode":{"type":"string","enum":["auto","mirror","render"]},"segments":{"type":"array","items":{"type":"object","properties":{"pathPrefix":{"type":"string"},"mode":{"type":"string","enum":["mirror","render"],"description":"The capture mode Dotsy recommends for this part of the site."},"confidence":{"type":"number","description":"0 to 1: how sure the recommendation is."},"signals":{"type":"array","items":{"type":"string"},"description":"What was detected (frameworks, empty shells, …)."},"estimatedRenderPages":{"type":"number"}},"required":["pathPrefix","mode","confidence","signals"]},"description":"The parts of the site that were sampled, each with a recommended capture mode."},"warnings":{"type":"array","items":{"type":"string"},"description":"Pages that could not be fetched (`preflight_404:/path`), automatic switches to render, and framework hints."}},"required":["sourceUrl","mode","segments","warnings"]},"ImportAccepted":{"type":"object","properties":{"id":{"type":"string","description":"The import's workflow id. Poll the job with `job.id` on `GET /api/v1/sites/{slug}/jobs/{id}`. The legacy URL `GET …/import/url/{id}` still answers for this id."},"status":{"type":"string","enum":["queued"]},"slug":{"type":"string","description":"The slug the site is imported into. It differs from the `{slug}` you sent when `slugAdjusted` is true."},"requestedSlug":{"type":"string"},"slugAdjusted":{"type":"boolean","description":"True when another account held the slug and a suffix was added."},"pages":{"type":"integer","description":"Pages queued for capture."},"warnings":{"type":"array","items":{"type":"string"},"description":"Notes from page discovery: capped sitemaps (`import_discovery_capped`), key collisions, routes the import would shadow."},"remainingByPrefix":{"type":"object","additionalProperties":{"type":"number"},"description":"Pages left out by a cap, by path prefix. Present only when a cap cut the discovery short."},"assetsHint":{"type":"string","description":"Where to find the copy progress of mirrored assets. Present when `assetMirror` is `full`."},"publicUrl":{"type":"string","example":"https://acme-studio.dotsy.site/"},"liveUrl":{"type":"string"},"liveReady":{"type":"boolean","enum":[false],"description":"Always false: an import lands in the draft until you publish."},"job":{"allOf":[{"$ref":"#/components/schemas/JobRef"},{"description":"Poll `GET /api/v1/sites/{slug}/jobs/{id}` with `job.id`. Absent when the server cannot issue job ids."}]}},"required":["id","status","slug","requestedSlug","slugAdjusted","pages","warnings","publicUrl","liveUrl","liveReady"]},"WarmImportRunning":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"alreadyRunning":{"type":"boolean","enum":[true]},"warmJob":{"type":"object","properties":{"state":{"type":"string","enum":["pending","running"]},"manifestGen":{"type":"integer"},"counts":{"type":"object","properties":{"total":{"type":"integer"},"warmed":{"type":"integer"},"failed":{"type":"integer"},"capped":{"type":"integer"},"skipped":{"type":"integer"}},"required":["total","warmed","failed","capped","skipped"]}},"required":["state","manifestGen","counts"]}},"required":["ok","alreadyRunning","warmJob"]},"WarmImportStarted":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"workflowId":{"type":"string","description":"The asset copy job's workflow id."},"warmJob":{"type":"object","properties":{"state":{"type":"string","enum":["pending"]},"manifestGen":{"type":"integer"},"tier":{"type":"string","description":"The plan tier whose mirror limits apply (`free` or `paid`)."}},"required":["state","manifestGen","tier"]}},"required":["ok","workflowId","warmJob"]}}},"paths":{"/api/v1/sites":{"get":{"operationId":"listSites","tags":["Sites"],"summary":"List your sites","description":"Every site you can open: the ones in your workspace, plus sites other workspaces shared with you (`shared: true`, with your `role`). Without `limit` you get the whole list in one response, newest-updated first, and no `nextCursor`. With `limit` (1–1000) the list is ordered by `slug` instead, so a page boundary cannot move while you read, and `nextCursor` is the cursor for the next page (null on the last). Send the same `limit`, `q` and `scope` with every `cursor`. A page of up to 100 sites costs the same however many sites you have.\n\nEach site carries its stable `id`; use it, or the slug, in the `{slug}` path of every site operation.","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size (1–1000). Omit it to get the whole list in one response, without `nextCursor`.","schema":{"type":"integer","minimum":1,"maximum":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page's `nextCursor`. Send it with `limit` to get the next page.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"},"description":"Optional case-insensitive substring filter on `slug` and `title`, applied before paging. Use with `limit` for the dashboard site switcher search."},{"name":"scope","in":"query","required":false,"schema":{"type":"string","enum":["owned","shared"]},"description":"`owned` lists only the sites in your workspace, `shared` only the ones other workspaces shared with you. Anything else (or omitted) lists both."},{"$ref":"#/components/parameters/WorkspaceHeader"}],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"The sites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteList"}}}},"400":{"description":"`cursor` is not one this API handed out, or was handed out for a different `q` or `scope` (`invalid_cursor`). Restart the listing without a cursor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"You are not a member of the workspace named in `X-Dotsy-Workspace` (`forbidden_workspace`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"operationId":"createSite","tags":["Sites"],"summary":"Create an empty site","description":"Creates an empty site in the active workspace and seeds its brief. Nothing is published until you publish. By default a slug that another account holds is auto-suffixed (`{slug}-{adjective}-{noun}`); the response says so with `slugAdjusted: true`, so always use the returned `slug` afterwards.\n\nFree-site capacity is `base + perPaid × paidSites` in the workspace (defaults 10 + 10 per paid site; fleet and per-workspace overrides exist). Only free-tier sites count toward usage; past the effective max the request answers 403 `free_site_limit`. A key limited to specific sites, and a guest token, can't create sites.","x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X POST \"https://api.dotsy.ai/api/v1/sites\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"slug\":\"acme-studio\",\"title\":\"Acme Studio\",\"purpose\":\"Show Acme's work and bring in new client enquiries.\"}'"}],"security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"requestBody":{"required":true,"description":"The site to create. Only `slug` is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSiteRequest"},"examples":{"minimal":{"summary":"Just a slug","value":{"slug":"acme-studio"}},"full":{"summary":"With a title and purpose","value":{"slug":"acme-studio","title":"Acme Studio","purpose":"Show Acme's work and bring in new client enquiries."}},"strict":{"summary":"Fail instead of renaming a taken slug","value":{"slug":"acme-studio","onSlugTaken":"fail"}}}}}},"responses":{"201":{"description":"The site exists. It is empty and unpublished.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSiteResponse"},"example":{"slug":"acme-studio","requestedSlug":"acme-studio","slugAdjusted":false,"title":"Acme Studio","keyConvention":"flat","editingMode":"live","createdAt":1767225600000,"created":true,"publicUrl":"https://acme-studio.dotsy.site/","liveUrl":"https://acme-studio.dotsy.site/","liveReady":false}}}},"400":{"description":"The body is not valid (`invalid_body`), or the slug is malformed (`invalid_slug`) or reserved (`reserved_slug`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`free_site_limit`: the workspace is at its free-site cap. `forbidden_site_scope`: the key is limited to specific sites. `guest_only`: this account may keep only the site it already claimed. `guest_requires_account`: a guest token can't create sites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`slug_taken`: another account has it and `onSlugTaken` is `fail` (or no variant was free). `slug_taken_self`: you already own it, or it is a former address of one of your sites.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"suggestions":{"type":"array","items":{"type":"string"},"description":"`slug_taken` with `onSlugTaken: \"fail\"`: free alternatives you can try."}}}]}}}},"500":{"description":"`create_failed`: the site could not be created. Try again.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"detail":{"type":"string","description":"`create_failed`: the underlying error message."}}}]}}}}}}},"/api/v1/account/me":{"get":{"operationId":"getAccountMe","tags":["Account"],"summary":"Get the current account, user and workspaces","description":"Who the token is and which workspace the request acts in. `memberships` lists every workspace the person can switch to; pass one's id or slug as `X-Dotsy-Workspace` on later requests to act in it.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"responses":{"200":{"description":"The current account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountMe"},"example":{"user":{"name":"Ada Lovelace","email":"ada@example.com"},"account":{"id":"org_01","name":"Ada's workspace","slug":"ada"},"isPlatformAdmin":false,"isConsoleAdmin":false,"servingOriginTemplate":"https://{slug}.dotsy.site","memberships":[{"orgId":"org_01","name":"Ada's workspace","slug":"ada","role":"owner","ownerEmail":"ada@example.com","plan":"free","isActive":true}],"telemetry":{"feedbackSink":"ok"}}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_workspace`: you are not a member of the workspace named in `X-Dotsy-Workspace`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/notifications/summary":{"get":{"operationId":"getNotificationsSummary","tags":["Account"],"summary":"Unread notification count for the signed-in person","description":"Aggregates unread items from up to ten workspace feeds (active workspace first). Dashboard session only — API keys receive 403. When notifications are disabled, returns zero without touching the feed.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"responses":{"200":{"description":"Unread count.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationsSummary"},"example":{"unread":2}}}},"403":{"description":"`session_required`: sign in as a person. API keys cannot read notifications.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/notifications":{"get":{"operationId":"listNotifications","tags":["Account"],"summary":"List recent notifications for the signed-in person","description":"Merges up to thirty items from up to ten workspace feeds, newest first. `workspace` filters to one membership (ignored when not a member). `needsYou` keeps only items that need review.","parameters":[{"name":"workspace","in":"query","required":false,"description":"Workspace id or slug to filter.","schema":{"type":"string"}},{"name":"needsYou","in":"query","required":false,"description":"Set to 1 or true for the Needs you tab.","schema":{"type":"string"}},{"$ref":"#/components/parameters/WorkspaceHeader"}],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Merged notification items.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationsList"},"example":{"items":[{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","seq":12,"kind":"form.entry","siteSlug":"acme","siteTitle":"Acme Dental","count":3,"needsYou":false,"updatedAt":1760000000000,"unread":true,"workspaceId":"org_01","workspaceName":"Acme agency","data":{"formId":"contact"}}]}}}},"403":{"description":"`session_required`: sign in as a person.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/notifications/read":{"post":{"operationId":"markNotificationsRead","tags":["Account"],"summary":"Mark notification items read","description":"Mark specific ids or everything in one workspace (`all` with `workspace`) or across all member workspaces (`all` without `workspace`).","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationsReadBody"}}}},"responses":{"200":{"description":"Updated unread count.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationsReadResponse"},"example":{"ok":true,"unread":0}}}},"403":{"description":"`session_required` or `forbidden_workspace`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/manifest":{"get":{"operationId":"getSiteManifest","tags":["Content"],"summary":"List content keys with their hashes","description":"A cheap read of the site's content as key, hash and type, without any bodies. By default it is the published (live) set. With `draft=1` it is the draft set, with a `status` per key. Add `routes=1` to include the route table. The response carries an `ETag`; send it back as `If-None-Match` to get 304 when nothing changed.","parameters":[{"name":"draft","in":"query","required":false,"description":"`1`: the draft set instead of the published set.","schema":{"type":"string","enum":["1"]}},{"name":"routes","in":"query","required":false,"description":"`1`: include the route table (`routes`).","schema":{"type":"string","enum":["1"]}},{"name":"entries","in":"query","required":false,"description":"`0` (published mode only): skip the entries and return just `collections` and `forms`. Much cheaper on a large site.","schema":{"type":"string","enum":["0"]}},{"name":"limit","in":"query","required":false,"description":"Page size, 1 to 1000. Defaults to 1000; larger values are clamped. Follow `nextCursor` for the rest.","schema":{"type":"integer","minimum":1,"maximum":1000,"default":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque value from the previous page's `nextCursor`.","schema":{"type":"string"}},{"name":"If-None-Match","in":"header","required":false,"description":"The `ETag` of your cached copy. A match answers 304 with no body.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The manifest.","headers":{"ETag":{"description":"An opaque, quoted string. Send it back unchanged as `If-None-Match`. Do not parse it: its format can change.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteManifest"}}}},"304":{"description":"Not modified: `If-None-Match` matches the current `ETag`."},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot read this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/pages":{"get":{"operationId":"listPages","tags":["Pages"],"summary":"List a site's pages","description":"One row per page, with its URL, layout, title, what it places (sections, menus, listings) and whether it has staged changes. A route that stands for many pages (a collection) appears as one row with a `pattern`. The rows come from an index, so no page bodies are read. Filter with `q` (path or title), `layout` or `noLayout`, `flag` (single chip), and page with `limit` and `cursor`. Pass `group=layout` for layout-key group order and `pv3.` cursors (default order keeps `pv2.`).","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size, 1 to 1000. Defaults to 1000; larger values are clamped. Follow `nextCursor` for the rest.","schema":{"type":"integer","minimum":1,"maximum":1000,"default":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque value from the previous page's `nextCursor`.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Only pages whose path or title contains this text (case-insensitive).","schema":{"type":"string"}},{"name":"layout","in":"query","required":false,"description":"Only pages that use this layout, by file key (`layouts/default.html`) or name (`default`).","schema":{"type":"string"}},{"name":"noLayout","in":"query","required":false,"description":"Only pages with no layout. Cannot be combined with `layout`.","schema":{"type":"boolean"}},{"name":"flag","in":"query","required":false,"description":"Only pages matching this chip (combines with other filters).","schema":{"type":"string","enum":["changes","unpublished","hidden","test","norule"]}},{"name":"group","in":"query","required":false,"description":"When `layout`, rows are ordered by layout file key (code unit), no-layout last, then path within each group. Cursors use the `pv3.` prefix.","schema":{"type":"string","enum":["layout"]}},{"name":"pagesLimit","in":"query","required":false,"description":"Old name for `limit`. Use `limit`.","schema":{"type":"integer"},"deprecated":true},{"name":"pagesCursor","in":"query","required":false,"description":"Old name for `cursor`. Use `cursor`.","schema":{"type":"string"},"deprecated":true},{"name":"pagesQ","in":"query","required":false,"description":"Old name for `q`. Use `q`.","schema":{"type":"string"},"deprecated":true},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"One page of rows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PagesList"}}}},"400":{"description":"The `cursor` or a filter is not valid (`invalid_cursor`, `invalid_params`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot read this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The site's storage is not ready yet (`backend_not_ready`). Retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"The page index could not be read (`outline_unavailable`). Retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/preflight":{"post":{"operationId":"preflightSite","tags":["Draft & publish"],"summary":"Check the draft for problems before publishing","description":"A read-only plan check over the draft: route and page counts, collection and template mistakes, redirect loops and chains, pages whose bytes are missing, pending third-party integrations and SEO notes. Run it before a large build and again before `publishSite`. It reads the whole site, so call it on demand rather than in a loop. It records a draft SEO snapshot, which the dashboard shows.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SitePreflightRequest"},"examples":{"empty":{"summary":"Check what is there","value":{}},"planned":{"summary":"Before adding 12 routes","value":{"requiredRoutes":12}}}}}},"responses":{"200":{"description":"The findings. Read `ok` and `suggestions`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SitePreflightResult"},"examples":{"clean":{"summary":"Nothing to fix","value":{"ok":true,"slug":"acme-studio","collections":{},"integrations":{"pending":[]},"stats":{"pages":6,"assets":4,"htmlPages":6},"routes":{"count":6,"softLimit":10,"required":0},"suggestions":[],"blocking":[]}}}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"This is a POST, so a viewer role (`forbidden_role`) or a read-only key (`forbidden_read_only`) is refused, as is a key scoped to other sites (`forbidden_site_scope`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/search":{"get":{"operationId":"searchContent","tags":["Content"],"summary":"Search page text for a word or phrase","description":"Finds the pages whose text contains a literal word or phrase, without reading any bodies. It searches the draft. Regular expressions are not supported. The result is not paged: `truncated` says whether more pages matched than `limit`. Search needs the site's search and collections feature; a site without it answers 503 `search_unavailable`.","parameters":[{"name":"q","in":"query","required":true,"description":"The text to find (up to 256 characters).","schema":{"type":"string","minLength":1,"maxLength":256}},{"name":"limit","in":"query","required":false,"description":"Most matches to return, 1 to 200. Defaults to 50.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The matching pages.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchContentResponse"}}}},"400":{"description":"`missing_query`, `query_too_long` (with `maxLen`), or `regex_unsupported`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot read this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"Search is not enabled for this site (`search_unavailable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/content/rename":{"post":{"operationId":"renameContent","tags":["Content"],"summary":"Rename a content key (and optionally its URL)","description":"Re-keys a page: the last path segment becomes `newSlug`, the directory and extension stay. Works on a draft page and on a published static page. `key` is sent in the body, so a nested key such as `blog/post.html` needs no percent-encoding. The change is staged — publish to make it live.","x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X POST \"https://api.dotsy.ai/api/v1/sites/acme-studio/content/rename\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"key\":\"blog/untitled.html\",\"newSlug\":\"my-first-post\"}'"}],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string","minLength":1,"description":"The content key to rename. Send it raw: it may contain `/` (for example `blog/post.html`).","example":"blog/untitled.html"},"newSlug":{"type":"string","minLength":1,"description":"The new last path segment. It is slugified (lowercased, hyphenated) and replaces the file name; the directory and extension are kept.","example":"my-first-post"},"movePath":{"type":"boolean","description":"Published pages only: move the page's route to the new URL (menu links follow) instead of dropping the route and serving the page at its key-derived URL. A draft rename ignores it."},"leaveRedirect":{"type":"boolean","description":"With `movePath: true` on a published page, leave a 301 redirect at the old URL. Default true. A draft rename ignores it."}},"required":["key","newSlug"]}}}},"responses":{"200":{"description":"Renamed (staged).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentRenameResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`), the key is blank (`invalid_key`), or `newSlug` has no letter or number (`invalid_slug`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, guest token, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no content at `key` (`content_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The target key or route path is already in use, or the routes could not be updated (`rename_failed`). Nothing changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"The page cannot be renamed yet: a capture route or `meta.url` permalink (`unsupported_route_shape`), or a running experiment (`experiment_active`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/changeset":{"post":{"operationId":"applyChangeset","tags":["Content"],"summary":"Stage many content writes and deletes at once","description":"Stages writes and deletes (with their routes and menu items) to the draft in one call. Nothing is live until you publish.\n\nThe request is checked as a whole before anything is staged: a bad write, a reserved key, a CSS or JS file or an invalid menu item rejects the lot. Each write carries exactly one of `body` or `bodyStaging`. Bodies too big to send inline are uploaded in chunks first and referenced by `bodyStaging`.\n\nA small changeset answers 200 with what was staged. A large one (many writes or many bytes; the server decides) is queued: the answer is 202 with a `job`, and you poll `GET /api/v1/sites/{slug}/jobs/{id}` until it succeeds or fails. A write whose `ifMatch` no longer matches is not applied and is listed in `conflicts`, with status 200.","x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X POST \"https://api.dotsy.ai/api/v1/sites/acme-studio/changeset\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"writes\":[{\"key\":\"about.html\",\"body\":\"<h1>About us</h1><p>We make good things.</p>\"}],\"deletes\":[\"old-page.html\"],\"message\":\"Add About page\"}'"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"description":"The writes and deletes to stage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyChangesetBody"},"examples":{"addPage":{"summary":"Add a page and remove another","value":{"writes":[{"key":"about.html","body":"<h1>About us</h1><p>We make good things.</p>"}],"deletes":["old-page.html"],"message":"Add About page"}},"pageWithRoute":{"summary":"Add a page with its route","value":{"writes":[{"key":"services.html","body":"<h1>Services</h1>","route":{"path":"/services","input":{"title":"Services"}}}]}}}}}},"responses":{"200":{"description":"Staged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyChangesetResponse"}}}},"202":{"description":"Queued: the changeset was too large to apply in the request. Poll `job` until it finishes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApplyChangesetQueuedResponse"}}}},"400":{"description":"Nothing was staged. `invalid_changeset` (with `issues`; also sent as `detail`), `reserved_key`, `asset_write_not_allowed`, `staging_assemble_failed`, `intent_frontmatter_invalid`, `menu_invalid`, `empty_page_body`, or a route problem such as `layout_not_found`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot write to this site, or a plan limit blocks the write (`page_limit_reached`, `signature_required_on_free`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The site's storage changed during the write (`placement_version_conflict`). Retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"`changeset_too_large` (more than 25 MB queued) or `storage_limit_reached`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"A route redirect is not valid (for example a loop, or a target that is not a root-relative path). The body names the `path` and `key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"The queue is not available (`queue_unavailable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/jobs/{id}":{"get":{"operationId":"getJob","tags":["Jobs"],"summary":"Get a background job's status","description":"Poll a job started by a 202 response (a large changeset, a large site rename, or a URL import) until `status` is `succeeded` or `failed`. `progress` and `result` depend on the job's `kind`. A job id only works on the site it was started for.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"content.0c9e2f4a-1b2c-4d5e-8f90-a1b2c3d4e5f6.Qm9vdHN0cmFwSm9iSWRTaWc"},"required":true,"description":"The `job.id` from the 202 response that started the job.","name":"id","in":"path"}],"responses":{"200":{"description":"The job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Your role or token can't read this kind of job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or no such job on this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"Background jobs are not available on this server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/content":{"get":{"operationId":"listContent","tags":["Content"],"summary":"List a site's content keys","description":"Lists the content keys of a site as the draft sees them: a staged edit replaces the live version and a staged delete hides it. That covers pages, layouts, fragments and data files. Site and page briefs are not listed. Each row's `status` tells you whether the key is live, modified or new. To read a key's body use `GET …/content/{key}`.","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size, 1 to 1000. Defaults to 1000; larger values are clamped. Follow `nextCursor` for the rest.","schema":{"type":"integer","minimum":1,"maximum":1000,"default":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque value from the previous page's `nextCursor`. Do not build one yourself.","schema":{"type":"string"}},{"name":"withSize","in":"query","required":false,"description":"Measure `size` for every returned row (one storage read each). Without it, size is measured only on pages of 100 rows or fewer.","schema":{"type":"boolean"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"One page of content keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentList"}}}},"400":{"description":"The `cursor` does not belong to this site's current storage (`invalid_cursor`). Restart the listing without it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot read this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/content/{key}":{"get":{"operationId":"getContent","tags":["Content"],"summary":"Read a content key","description":"Reads one key as the draft sees it (a staged edit wins over the live version). By default the response is the stored body itself, with its stored Content-Type. `format=json` wraps the body with its hash and type, `format=post` parses a blog post, and `selector` returns only the matched elements' tag, attributes and text (a cheap read of a large page). If you send more than one of these, `selector` wins over `format=json`, which wins over `format=post`. `published=1` reads the last published version and ignores the draft.\n\nEvery successful read carries an `ETag` (the quoted `contentHash`); send it as `If-Match` on a later write to catch a concurrent edit.","parameters":[{"name":"format","in":"query","required":false,"description":"`json`: `{ key, contentType, contentHash, updatedAt, body }`. `post`: the parsed blog post (`meta`, `body`, `head`).","schema":{"type":"string","enum":["json","post"]}},{"name":"selector","in":"query","required":false,"description":"A tag, class, id or attribute selector, for example `h1.hero-title` or `nav a[href='/']`. Returns the matched elements' tag, attributes and text instead of the body.","schema":{"type":"string"}},{"name":"published","in":"query","required":false,"description":"`1`: the last published version, ignoring the draft. 404 `no_published_version` if it was never published.","schema":{"type":"string","enum":["1"]}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The body, or the JSON form you asked for. Without `format` or `selector` the body is returned as is, with its stored Content-Type.","headers":{"ETag":{"description":"The quoted `contentHash`.","schema":{"type":"string"}}},"content":{"text/html":{"schema":{"type":"string"}},"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ContentItem"},{"$ref":"#/components/schemas/ContentSelection"},{"$ref":"#/components/schemas/PostDocument"}]}}}},"400":{"description":"`invalid_key`, or the `selector` is not supported (`invalid_selector`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot read this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), no such key (`content_not_found`), or no published version (`no_published_version`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"operationId":"putContent","tags":["Content"],"summary":"Write a content key to the draft","description":"Stages one key (a page, layout, fragment or data file). A layout's key is `layouts/<name>.html` and a fragment's is `fragments/<name>.html`; a `<dotsy-fragment name>` tag and a route's `layout` use the bare name. Nothing is live until you publish. The answer is 201 when the key is new and 200 when it replaced an existing one. Writing the same body again changes nothing.\n\nSend `{ body, contentType? }`. With `?format=post`, send a blog post document `{ meta, body, head?, slug? }` instead; the server builds the page from it. Keys under `.dotsy/`, `.structure/`, `_intent/` and `_data/` are reserved (400 `reserved_key`). For a body over about 60 KB, use `PUT …/content-raw/{key}` or a changeset with staged chunks.","parameters":[{"name":"format","in":"query","required":false,"description":"`post`: the body is a blog post document (`PutPostRequest`) rather than a file.","schema":{"type":"string","enum":["post"]}},{"name":"If-Match","in":"header","required":false,"description":"The `ETag` / `contentHash` you read, quoted or bare. If the key changed since, the write answers 409 `conflict` and changes nothing.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X PUT \"https://api.dotsy.ai/api/v1/sites/acme-studio/content/about.html\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"body\":\"<h1>About us</h1><p>We make good things.</p>\"}'"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"requestBody":{"required":true,"description":"The file, or with `?format=post` a blog post document.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PutContentRequest"},{"$ref":"#/components/schemas/PutPostRequest"}]},"examples":{"page":{"summary":"A page","value":{"body":"<h1>About us</h1><p>We make good things.</p>"}},"post":{"summary":"A blog post (with ?format=post)","value":{"meta":{"title":"Hello, world","date":"2026-10-02","excerpt":"Our first post."},"body":"<p>Welcome to the blog.</p>"}}}}}},"responses":{"200":{"description":"Replaced an existing key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutContentResponse"}}}},"201":{"description":"Created a new key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutContentResponse"}}}},"400":{"description":"`invalid_key`, `reserved_key`, `invalid_body` (with `issues`), `invalid_post` (with `field`), or `empty_page_body` (an HTML page needs some text).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot write to this site, or a plan limit blocks the write (`page_limit_reached`, `signature_required_on_free`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`conflict`: `If-Match` no longer matches. Or `placement_version_conflict`: the site's storage changed; retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"The plan's storage limit is reached (`storage_limit_reached`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"deleteContent","tags":["Content"],"summary":"Delete a content key from the draft","description":"Stages a delete: the key disappears from the draft, and from the live site after the next publish. Page briefs under `_intent/` can be deleted. The site brief and other reserved keys (`.dotsy/`, `.structure/`, `_data/`) cannot (400 `reserved_key`).","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"responses":{"200":{"description":"Staged for deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteContentResponse"}}}},"400":{"description":"`invalid_key` or `reserved_key`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot write to this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`) or no such key (`content_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"A shared section (`fragments/<name>.html`) that pages, a layout or another section still place (`section_in_use`), or the site's index can't tell yet (`index_warming` — retry in a minute).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SectionInUseError"},{"$ref":"#/components/schemas/ApiError"}]}}}}}}},"/api/v1/sites/{slug}/content-raw/{key}":{"put":{"operationId":"putContentRaw","tags":["Content"],"summary":"Write a large content key from raw bytes","description":"Stages one key from the raw request body, for files too big to send as a JSON string (up to 5,000,000 bytes). Send the file as the body, with a `Content-Length` header; chunked uploads are refused. The body must be valid UTF-8 text. The request's `Content-Type` is stored with the key; `application/octet-stream` or none falls back to a type inferred from the key. Everything else matches `PUT …/content/{key}`. Nothing is live until you publish.","parameters":[{"name":"bodySha256","in":"query","required":false,"description":"SHA-256 of the body, in hex. If it does not match, nothing is written (400 `body_sha256_mismatch`).","schema":{"type":"string","pattern":"^[a-fA-F0-9]{64}$"}},{"name":"Content-Length","in":"header","required":true,"description":"The body's size in bytes. Required (411 `length_required` without it).","schema":{"type":"integer","minimum":0,"maximum":5000000}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"requestBody":{"required":true,"description":"The file's bytes.","content":{"*/*":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Replaced an existing key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutContentRawResponse"}}}},"201":{"description":"Created a new key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutContentRawResponse"}}}},"400":{"description":"`invalid_key`, `reserved_key`, `length_mismatch` (with `declared` and `actual`), `invalid_body_sha256`, `body_sha256_mismatch`, `invalid_utf8`, or `empty_page_body`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot write to this site, or a plan limit blocks the write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`conflict` or `placement_version_conflict`. Retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"411":{"description":"No usable `Content-Length` header (`length_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"The body is over 5,000,000 bytes (`content_too_large`), or the plan's storage limit is reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/routes":{"get":{"operationId":"listRoutes","tags":["Routes"],"summary":"List a site's routes","description":"Lists the site's routes (each maps a URL path to a content key and a layout), sorted by path, one page at a time. The default and maximum page is 1,000 routes, so most sites arrive in one response. Follow `nextCursor` until it is null, and treat `partial: true` as a window, not the whole set.\n\nTwo other row shapes are served from this same URL — `?view=pages` (pages grouped by layout) and `?purpose=redirect` (redirect rows with their issues) — and are not part of this contract.","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size, 1–1,000. Defaults to 1,000; larger values are capped.","schema":{"type":"integer","minimum":1,"maximum":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page's `nextCursor`. Pass it back unchanged.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Only routes whose path, content key, layout or title contains this text (case-insensitive).","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"One page of routes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteList"}}}},"400":{"description":"The `cursor` is no longer valid (`invalid_cursor`) — the site's storage changed. Restart the listing without a cursor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (viewer role for a write, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/routes/{path}":{"get":{"operationId":"getRoute","tags":["Routes"],"summary":"Get one route by path","description":"Returns the route at `path`, including staged changes. The home page is `…/routes/` (trailing slash). Other paths are sent without the leading slash, for example `…/routes/blog/launch` for `/blog/launch`.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"path","in":"path"}],"responses":{"200":{"description":"The route.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Route"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (viewer role for a write, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no route at that path (`route_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"operationId":"putRoute","tags":["Routes"],"summary":"Create or replace a route","description":"Creates the route at `path`, or replaces it when it exists. When `contentKey` is omitted it is derived from the path. To make a redirect, send `purpose: \"redirect\"` and a root-relative `redirectTo` in the same call. A redirect cannot also carry `contentKey`, `layout`, `experiment`, `nav` or `navLabel`, and `redirectTo` / `redirectStatus` need `purpose: \"redirect\"`. The home page is `…/routes/` (trailing slash). Other paths are sent without the leading slash, for example `…/routes/blog/launch` for `/blog/launch`. The change is staged to the draft — publish to make it live.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"path","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteRequest"}}}},"responses":{"200":{"description":"The route existed and was replaced (staged).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteWriteResponse"}}}},"201":{"description":"The route was created (staged).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteWriteResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`), the path is platform-owned (`reserved_route`), the content key is reserved (`reserved_content_key`), or the layout file does not exist (`layout_not_found`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"layout":{"type":"string","description":"`layout_not_found`: the layout key that does not exist."},"expectedKey":{"type":"string","description":"`layout_not_found`: the file key the layout should have."}}}]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (viewer role for a write, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"A route experiment or redirect field is not valid. `error` names the rule, and `field` the offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"operationId":"patchRoute","tags":["Routes"],"summary":"Update fields of a route","description":"Changes only the fields you send; an omitted field is left alone, and `null` clears a nullable field back to its default. To make a redirect, send `purpose: \"redirect\"` and a root-relative `redirectTo` in the same call. A redirect cannot also carry `contentKey`, `layout`, `experiment`, `nav` or `navLabel`, and `redirectTo` / `redirectStatus` need `purpose: \"redirect\"`. The check looks only at what you send, so a PATCH that sets `redirectTo` must repeat `purpose: \"redirect\"`, even on a route that is already a redirect. The home page is `…/routes/` (trailing slash). Other paths are sent without the leading slash, for example `…/routes/blog/launch` for `/blog/launch`. The change is staged to the draft — publish to make it live.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"path","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteRequest"}}}},"responses":{"200":{"description":"The updated route (staged).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteWriteResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`), the path is platform-owned (`reserved_route`), the content key is reserved (`reserved_content_key`), or the layout file does not exist (`layout_not_found`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"layout":{"type":"string","description":"`layout_not_found`: the layout key that does not exist."},"expectedKey":{"type":"string","description":"`layout_not_found`: the file key the layout should have."}}}]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (viewer role for a write, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no route at that path (`route_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"A route experiment or redirect field is not valid. `error` names the rule, and `field` the offending field.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"deleteRoute","tags":["Routes"],"summary":"Delete a route","description":"Removes the route at `path`, and menu links to it. The content it served is not deleted. The home page is `…/routes/` (trailing slash). Other paths are sent without the leading slash, for example `…/routes/blog/launch` for `/blog/launch`. The change is staged to the draft — publish to make it live.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"path","in":"path"}],"responses":{"200":{"description":"Deleted (staged).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"published":{"type":"boolean","enum":[false],"description":"Always false: a route change is staged until you publish."},"hint":{"type":"string","example":"Changes staged to draft — call publish_site to go live."}},"required":["ok","published","hint"]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (viewer role for a write, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no route at that path (`route_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/routes/rename":{"post":{"operationId":"renameRoute","tags":["Routes"],"summary":"Change a page's URL","description":"Moves a page's route from `from` to `to`. The whole route record moves (content key, layout, title, …), menu links and other redirects that pointed at the old path retarget automatically, and a 301 redirect is left at the old path unless `leaveRedirect` is false. The change is staged to the draft — publish to make it live.\n\nIt is safe to retry: resend the same call after any error and a partly finished move completes. If `to` already serves exactly the content `from` did, the move counts as already done and answers 200.","x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X POST \"https://api.dotsy.ai/api/v1/sites/acme-studio/routes/rename\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"from\":\"/old-page\",\"to\":\"/new-page\"}'"}],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameRouteRequest"},"examples":{"default":{"summary":"Move a page and leave a redirect","value":{"from":"/old-page","to":"/new-page"}},"noRedirect":{"summary":"Move without a redirect","value":{"from":"/old-page","to":"/new-page","leaveRedirect":false}}}}}},"responses":{"200":{"description":"Moved (staged).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameRouteResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`): send two site paths without `?`, `#` or spaces. Or a path is platform-owned (`reserved_route`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, guest token, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no route at `from` (`route_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"An A/B test is running on `from` (`rename_experiment_running`), or `to` is already a different route (`rename_target_exists`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`from` equals `to` (`rename_same_path`), either is a pattern route with a `:param` segment (`rename_pattern_route`), `from` is itself a different redirect (`rename_redirect_route`), or the route fails validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/assets":{"get":{"operationId":"listAssets","tags":["Assets"],"summary":"List a site's uploaded assets","description":"Lists the files uploaded under `assets/`, sorted by key within a page, one page at a time (up to 1,000 per page). Follow `nextCursor` until it is null. This is not the list of pages — use the content endpoints for those. Uploaded files are live in the editor and draft preview at once; the published site picks them up at the next publish.","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size, 1–1,000. Defaults to 1,000; larger values are capped.","schema":{"type":"integer","minimum":1,"maximum":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page's `nextCursor`. Pass it back unchanged.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"One page of assets.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetList"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/assets/bulk":{"post":{"operationId":"bulkPutAssets","tags":["Assets"],"summary":"Upload up to 200 assets in one call","description":"Uploads 1–200 assets, each as base64 bytes, plain `bodyText` (text keys only), or an `importFromUrl`. The key is the path after `/assets/` — for example `photos/hero.webp` or `style.css`. Do not prefix it with `assets/` or `/`. Keys may not contain `..` segments or start with `.dotsy/`. A single file may be up to 5 MB on the Free plan and 10 MB on a paid plan, and video and audio need a paid plan. HTML files are not accepted. Raster images (not SVG) are optimized on upload: they are re-encoded as WebP and, for the `hero` and `column` intents, get a smaller `-800` variant, so the stored `key` can differ from the one you sent. Items are stored in order, and the first item that fails stops the request: earlier items stay stored, and the error names the failing item in `key`. To upload one large file, send its raw bytes to `assets-raw` instead. Uploaded files are live in the editor and draft preview at once; the published site picks them up at the next publish.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPutAssetsRequest"},"examples":{"default":{"summary":"A stylesheet and an imported photo","value":{"items":[{"key":"style.css","bodyBase64":"Ym9keSB7IG1hcmdpbjogMDsgfQ==","contentType":"text/css"},{"key":"photos/hero.jpg","importFromUrl":"https://example.com/photo.jpg","intent":"hero"}]}}}}}},"responses":{"200":{"description":"Every item was stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPutAssetsResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`), the key is not allowed (`invalid_key`, `key_not_allowed`), the bytes are not valid base64 (`invalid_base64`), the `bodySha256` does not match (`body_sha256_mismatch`), `importFromUrl` could not be fetched (`import_failed`), or the image could not be processed (`transform_failed`, `decode_failed`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"key":{"type":"string","description":"Bulk only: the key of the item that failed."},"expected":{"type":"string","description":"`body_sha256_mismatch`: the digest you sent."},"actual":{"type":"string","description":"`body_sha256_mismatch`: the digest of the bytes received."},"status":{"type":"number","description":"`import_failed`: the upstream HTTP status, when there was one."},"host":{"type":"string","description":"`import_failed`: the host that could not be fetched."}}}]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The Free plan does not allow video or audio (`plan_upgrade_required`), or the credentials cannot write assets: a guest token (`guest_requires_account`), a viewer role, a key scoped to other sites, or AI access below what the upload needs (`ai_access_limited`, with `aiAccess` and a `hint` — on Drafts-only sites, replacing an existing file key is refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"The file is over the plan's size limit (`asset_too_large`), or the site is out of storage (`storage_limit_reached`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"415":{"description":"The file type is not accepted, such as HTML (`asset_type_not_allowed`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/assets/{key}":{"get":{"operationId":"getAsset","tags":["Assets"],"summary":"Download an asset, or read its metadata","description":"Returns the file itself by default, with its stored content type. `?format=json` returns the metadata plus the bytes as base64 and the stored SHA-256; `?format=meta` returns the metadata only, without reading the file. The key is the path after `/assets/` — for example `photos/hero.webp` or `style.css`. Do not prefix it with `assets/` or `/`. Keys may not contain `..` segments or start with `.dotsy/`.","parameters":[{"name":"format","in":"query","required":false,"description":"`json`: metadata plus base64 bytes and `sha256`. `meta`: metadata only. Omit it to download the file.","schema":{"type":"string","enum":["json","meta"]}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The file's bytes (default), or its JSON form when `format` is `json` or `meta`.","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/AssetFile"},{"$ref":"#/components/schemas/AssetMeta"}]}}}},"400":{"description":"The key is blank (`invalid_key`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot access this site (a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no asset at that key (`asset_not_found`; `not_found` with `format=meta`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"operationId":"putAsset","tags":["Assets"],"summary":"Upload or replace one asset","description":"Stores one file at `key`, replacing any file already there. Send the bytes as base64, or an `importFromUrl` for the server to fetch. The key is the path after `/assets/` — for example `photos/hero.webp` or `style.css`. Do not prefix it with `assets/` or `/`. Keys may not contain `..` segments or start with `.dotsy/`. A single file may be up to 5 MB on the Free plan and 10 MB on a paid plan, and video and audio need a paid plan. HTML files are not accepted. Raster images (not SVG) are optimized on upload: they are re-encoded as WebP and, for the `hero` and `column` intents, get a smaller `-800` variant, so the stored `key` can differ from the one you sent. To upload larger files without base64, send the raw bytes to `assets-raw` instead. Uploaded files are live in the editor and draft preview at once; the published site picks them up at the next publish.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutAssetRequest"}}}},"responses":{"200":{"description":"An existing asset was replaced.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetUploadResult"}}}},"201":{"description":"A new asset was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetUploadResult"}}}},"400":{"description":"The body is not valid (`invalid_body`), the key is not allowed (`invalid_key`, `key_not_allowed`), the bytes are not valid base64 (`invalid_base64`), the `bodySha256` does not match (`body_sha256_mismatch`), `importFromUrl` could not be fetched (`import_failed`), or the image could not be processed (`transform_failed`, `decode_failed`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"key":{"type":"string","description":"Bulk only: the key of the item that failed."},"expected":{"type":"string","description":"`body_sha256_mismatch`: the digest you sent."},"actual":{"type":"string","description":"`body_sha256_mismatch`: the digest of the bytes received."},"status":{"type":"number","description":"`import_failed`: the upstream HTTP status, when there was one."},"host":{"type":"string","description":"`import_failed`: the host that could not be fetched."}}}]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The Free plan does not allow video or audio (`plan_upgrade_required`), or the credentials cannot write assets: a guest token (`guest_requires_account`), a viewer role, a key scoped to other sites, or AI access below what the upload needs (`ai_access_limited`, with `aiAccess` and a `hint` — on Drafts-only sites, replacing an existing file key is refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"The file is over the plan's size limit (`asset_too_large`), or the site is out of storage (`storage_limit_reached`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"415":{"description":"The file type is not accepted, such as HTML (`asset_type_not_allowed`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"deleteAsset","tags":["Assets"],"summary":"Delete an asset","description":"Removes the file at `key`. The editor and draft preview drop it at once; the published site drops it at the next publish. The key is the path after `/assets/` — for example `photos/hero.webp` or `style.css`. Do not prefix it with `assets/` or `/`. Keys may not contain `..` segments or start with `.dotsy/`.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"400":{"description":"The key is blank (`invalid_key`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The credentials cannot write assets: a guest token (`guest_requires_account`), a viewer role, or a key scoped to other sites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no asset at that key (`asset_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/assets-raw/{key}":{"put":{"operationId":"putAssetRaw","tags":["Assets"],"summary":"Upload one asset as raw bytes","description":"Stores one file at `key`, replacing any file already there. The request body IS the file — no JSON and no base64 — and a `Content-Length` header is required. The file's type comes from `Content-Type` (or the key's extension when it is `application/octet-stream`). Options go in the query string. The key is the path after `/assets/` — for example `photos/hero.webp` or `style.css`. Do not prefix it with `assets/` or `/`. Keys may not contain `..` segments or start with `.dotsy/`. A single file may be up to 5 MB on the Free plan and 10 MB on a paid plan, and video and audio need a paid plan. HTML files are not accepted. Raster images (not SVG) are optimized on upload: they are re-encoded as WebP and, for the `hero` and `column` intents, get a smaller `-800` variant, so the stored `key` can differ from the one you sent. Uploaded files are live in the editor and draft preview at once; the published site picks them up at the next publish.","parameters":[{"name":"intent","in":"query","required":false,"description":"The layout role of an image.","schema":{"type":"string","enum":["hero","column","inline","logo"]}},{"name":"title","in":"query","required":false,"description":"Display title (up to 500 characters). Defaults to the file name.","schema":{"type":"string","maxLength":500}},{"name":"bodySha256","in":"query","required":false,"description":"SHA-256 (64 hex characters) of the bytes you send. A mismatch answers 400 `body_sha256_mismatch` before anything is written.","schema":{"type":"string","pattern":"^[a-fA-F0-9]{64}$"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"Percent-encode each segment; '/' may be sent raw or as %2F. Keys may contain '/'.","name":"key","in":"path"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"description":"The file's bytes.","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"An existing asset was replaced.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetUploadResult"}}}},"201":{"description":"A new asset was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetUploadResult"}}}},"400":{"description":"The key is not allowed (`invalid_key`, `key_not_allowed`), `intent`, `title` or `bodySha256` is not valid (`invalid_intent`, `title_too_long`, `invalid_body_sha256`), the digest does not match (`body_sha256_mismatch`), or the image could not be processed (`transform_failed`, `decode_failed`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"key":{"type":"string","description":"Bulk only: the key of the item that failed."},"expected":{"type":"string","description":"`body_sha256_mismatch`: the digest you sent."},"actual":{"type":"string","description":"`body_sha256_mismatch`: the digest of the bytes received."},"status":{"type":"number","description":"`import_failed`: the upstream HTTP status, when there was one."},"host":{"type":"string","description":"`import_failed`: the host that could not be fetched."}}}]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The Free plan does not allow video or audio (`plan_upgrade_required`), or the credentials cannot write assets: a guest token (`guest_requires_account`), a viewer role, a key scoped to other sites, or AI access below what the upload needs (`ai_access_limited`, with `aiAccess` and a `hint` — on Drafts-only sites, replacing an existing file key is refused).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"411":{"description":"The request has no valid `Content-Length` (`length_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"The file is over the plan's size limit (`asset_too_large`), or the site is out of storage (`storage_limit_reached`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"415":{"description":"The file type is not accepted, such as HTML (`asset_type_not_allowed`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/verify":{"post":{"operationId":"verifySite","tags":["Draft & publish"],"summary":"Compose draft pages and report broken markup, assets and SEO","description":"Renders each page of the draft the way a visitor would see it after publishing and reports what is wrong: unresolved `<dotsy-*>` tags, missing layouts, content or assets, broken menu wiring, redirect problems and SEO errors. It checks the draft, not the live site. It composes every page, so pass `paths` to check just a few. It records an SEO snapshot, which the dashboard shows.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyRequest"},"examples":{"all":{"summary":"Every route","value":{}},"some":{"summary":"Two pages","value":{"paths":["/","/about"]}}}}}},"responses":{"200":{"description":"The findings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyResult"},"examples":{"clean":{"summary":"One page, no issues","value":{"ok":true,"warnings":[],"siteIssues":[],"paths":[{"path":"/","ok":true,"issues":[],"seoIssues":[]}],"seo":{"scanned":1,"errors":0,"warnings":0}}}}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"This is a POST, so a viewer role (`forbidden_role`) or a read-only key (`forbidden_read_only`) is refused, as is a key scoped to other sites (`forbidden_site_scope`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/draft-status":{"get":{"operationId":"getDraftStatus","tags":["Draft & publish"],"summary":"List what changed in the draft since the last publish","description":"Everything staged and not yet live: content files, routes and menu items, each `new`, `modified` or `deleted`. Review it before `publishSite`: send the `{key, hash}` pairs you reviewed (a route's `path` goes in `key`) and this `fingerprint` as `reviewedSet`, so the publish answers 409 `drafts_changed` instead of shipping something that moved after you looked. Add `verify=1` to also get the last `verifySite` run and whether the draft changed since. Safe to call repeatedly.","parameters":[{"name":"verify","in":"query","required":false,"description":"`1`: add `verify`, the last `verifySite` run and whether it is `stale`.","schema":{"type":"string","enum":["1"]}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The staged changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DraftStatusResponse"},"examples":{"staged":{"summary":"One edited page and one new post","value":{"total":3,"content":[{"key":"index.html","status":"modified","hash":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"},{"key":"blog/launch.html","status":"new","hash":"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"}],"routes":[{"path":"/blog/launch","status":"new","hash":"fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9"}],"menus":[],"fingerprint":"1:7:1759400000000:empty"}},"clean":{"summary":"Nothing staged","value":{"total":0,"content":[],"routes":[],"menus":[],"fingerprint":"1:7:1759400000000:empty"}}}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token is scoped to other sites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/revert":{"post":{"operationId":"revertDrafts","tags":["Draft & publish"],"summary":"Discard draft changes (all, or only what you name)","description":"Throws away staged changes and brings the draft back to what is live. Only the draft is touched: the live site and its published version stay as they are. Name `keys` and/or `routes` to discard part of the draft, or send `key` for one content file. Send an empty body to discard everything, which cannot be undone.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":false,"description":"What to discard. An empty body discards all drafts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevertDraftsRequest"},"examples":{"subset":{"summary":"Discard one page and its route","value":{"keys":["blog/launch.html"],"routes":["/blog/launch"]}},"oneKey":{"summary":"Discard one content key","value":{"key":"index.html"}},"all":{"summary":"Discard all drafts","value":{}}}}}},"responses":{"200":{"description":"Discarded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevertDraftsResponse"},"examples":{"done":{"summary":"Two items restored","value":{"ok":true,"reverted":2}}}}}},"400":{"description":"The body is not valid (`invalid_body`, with `issues`), or `keys`/`routes` was present but empty (`empty_selection`): nothing was discarded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, read-only key, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"Nothing was discarded: `draft_busy` or `draft_not_visible_yet`. Retry shortly.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"error":{"type":"string","enum":["draft_busy","draft_not_visible_yet"],"description":"`draft_busy`: a discard or a compaction is running, retry in a moment. `draft_not_visible_yet`: the draft was written a moment ago and is not readable here yet, so nothing was discarded; retry in a few seconds."}}}]}}}}}}},"/api/v1/sites/{slug}/brief":{"get":{"operationId":"getBrief","tags":["Brief"],"summary":"Get the site brief","description":"The site brief says what the site is for: `head` holds the purpose, audience, priorities, editing model, design direction and the site's rules & guidelines (`head.rules`), and `notes` holds the free-form notes. `sectionNotes` holds the Design system, Content & CMS and SEO notes that are set. `aiAccess` is what AI agents may do on the site, and `ruleFailures` the failing Rules per section from the last check, which ran at `ruleFailuresCheckedAt` (all three also on the 404 and 409 answers). Add `?page=<content key>` to read one page's brief instead.\n\nA site that has no brief yet answers 404 `brief_not_set`. `hash` changes on every write.","parameters":[{"name":"page","in":"query","required":false,"description":"A page's content key (for example `pricing.html`). Reads or writes that page's brief instead of the site brief. Must be a normal content key: not empty, not reserved, no `..`, no leading or trailing `/`.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The site brief, or the page brief when `?page=` was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BriefResponse"}}}},"400":{"description":"`page` is not a usable content key (`invalid_page`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token is limited to other sites (`forbidden_site_scope`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`not_found`), or no brief has been written yet (`brief_not_set`, with `head: null`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The brief's stored head cannot be parsed (`brief_head_corrupt`). Rewrite `_intent/site.md` through the content API.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"operationId":"updateBrief","tags":["Brief"],"summary":"Update the site brief","description":"Edits the site brief and leaves what you do not send alone. Send at least one of: `head` (fields to change; `null` clears an optional field, `purpose` can only be replaced), `body` (replaces the free text), `goal` and `notes` (replace their sections), `rules` (add or change rules & guidelines, matched by `id`), `removeRules` (delete lines by `id`) and `appendHandoff` (adds a line to the hand-off log). `notes` is a string (the free-form notes) or `{ design, content, seo }` (per-section notes; `null` clears one). `rules` patch-merges by id (omitted fields keep stored values; `check: null` clears). A site's first brief write must include `head.purpose` (5–280 characters).\n\nA locked rule can only be changed, removed, locked or unlocked by a person in the dashboard: any other caller gets 403 `rule_locked` or `lock_is_for_people`. The `X-Dotsy-Brief-Actor` header is ignored.\n\nWith `?page=<content key>` the same call edits that page's brief instead (`head`, `body`, `goal`, `notes`). The page must exist.","parameters":[{"name":"page","in":"query","required":false,"description":"A page's content key (for example `pricing.html`). Reads or writes that page's brief instead of the site brief. Must be a normal content key: not empty, not reserved, no `..`, no leading or trailing `/`.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"requestBody":{"required":true,"description":"The brief edit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBriefRequest"},"examples":{"siteBrief":{"summary":"Set the purpose and add a note","value":{"head":{"purpose":"A portfolio site for a ceramics studio","audience":"Local buyers"},"notes":"Warm, plain voice."}},"handoff":{"summary":"Leave a hand-off line","value":{"appendHandoff":"Pricing copy still needs review."}}}}}},"responses":{"200":{"description":"Saved. The merged brief.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BriefResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`, with `issues`), `page` is not a usable content key (`invalid_page`), the site has no purpose yet (`purpose_required`), or the head or a section is over its size limit (`brief_too_large`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, read-only key, or a key scoped to other sites), the edit would change or remove a locked rule (`rule_locked`), or it would lock one (`lock_is_for_people`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`not_found`), or `page` names content that does not exist (`page_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The brief's stored head cannot be parsed (`brief_head_corrupt`; only a person can overwrite it), or the site is being renamed (`rename_active`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Over the account's write budget (`rate_limited`). Retry after the `Retry-After` seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains":{"get":{"operationId":"listCustomDomains","tags":["Domains"],"summary":"List a site's custom domains","description":"Every hostname connected to (or being connected to) the site, with its status and the DNS records still to create. Hostnames that are not connected yet carry a live DNS check on each record. Stale external hostnames are re-checked with Cloudflare in the background, so a poll sees fresher data next time. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"responses":{"200":{"description":"The site's domains.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListCustomDomainsResponse"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"operationId":"addCustomDomain","tags":["Domains"],"summary":"Add a custom domain and start connecting it","description":"Adds one hostname, or an apex and its `www` together. A nested subdomain of the platform's own site domain is connected at once (200 if it was already on this site, otherwise 201). An external domain needs a paid plan and starts a connect session: the 201 body carries `connect`, which says what to do next (open a link, or enter DNS records). Re-sending a host already on the site is safe and returns it again without using another slot. Each site and each account has a cap on external hostnames (`custom_domain_limit`). Then poll refreshCustomDomain until `status` is `connected`. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddCustomDomainRequest"},"examples":{"apexAndWww":{"summary":"A domain and its www","value":{"hosts":["example.com","www.example.com"]}},"single":{"summary":"One host","value":{"host":"www.example.com"}}}}}},"responses":{"200":{"description":"That internal host is already on this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainResponse"}}}},"201":{"description":"Added. For an external domain, `connect` says how to finish.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddCustomDomainResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`: send `host` or `hosts`, at most two, apex+www only), or a host is not acceptable (`invalid_host`, `single_label_host`, `reserved_host`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"A paid plan is required for external domains (`plan_upgrade_required`), a hostname cap is reached (`custom_domain_limit`), or the caller cannot manage domains (owner only, no guest tokens, `guest_only` accounts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The host is already mapped to another site (`host_taken`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"Cloudflare refused the custom hostname (`cf_error`). Retrying is safe.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"External domains are not configured on this server (`serving_unconfigured`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains/connect":{"get":{"operationId":"getCustomDomainConnect","tags":["Domains"],"summary":"Get the next step to connect pending domains","description":"Resumes (or starts) the connect session for the site's pending external hostnames and says what to do next: open a link, enter the listed DNS records, or wait. Pass the `sessionId` from addCustomDomain to resume that session. `connect` is null when every external hostname on the site is already connected. Runs live DNS checks unless you send `live=0`. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","parameters":[{"name":"sessionId","in":"query","required":false,"description":"The connect session id (`cs_…`) from `connect.sessionId`. Omit to resume the newest pending session.","schema":{"type":"string"}},{"name":"live","in":"query","required":false,"description":"Live DNS and nameserver checks run by default. Send `0` to skip them and answer from the stored snapshot (cheap page loads).","schema":{"type":"string","enum":["0","1"]}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"The connect state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCustomDomainConnectResponse"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or nothing to connect: no pending external domain and none connected (`connect_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains/connect/reapply-oauth":{"post":{"operationId":"reapplyCustomDomainOauth","tags":["Domains"],"summary":"Re-apply DNS records with the stored Cloudflare grant","description":"Recovery without a browser: writes the routing and certificate-check records for the site's pending hosts again, using the Cloudflare grant the user already gave, then re-checks the hostnames. Use it when a connect stalled on stale or duplicate records. Needs a previous Cloudflare OAuth connect (`no_cloudflare_oauth_grant` otherwise). The body is ignored. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"responses":{"200":{"description":"Records re-applied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReapplyCustomDomainOauthResponse"}}}},"400":{"description":"No stored Cloudflare grant for this account (`no_cloudflare_oauth_grant`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or no pending external domains (`no_pending_domains`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"Cloudflare OAuth is not configured on this server (`oauth_not_configured`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains/{host}/refresh":{"post":{"operationId":"refreshCustomDomain","tags":["Domains"],"summary":"Re-check one domain with Cloudflare and DNS","description":"Asks Cloudflare for the hostname's current status and re-checks its DNS records. Poll it after the user changes DNS until `domain.status` is `connected`; `connect` (when present) says what is left. An internal host answers with its row and no check. The body is ignored. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","parameters":[{"name":"fresh","in":"query","required":false,"description":"Send `1` to bypass the short DNS lookup cache (use right after changing DNS).","schema":{"type":"string","enum":["1"]}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","example":"www.example.com"},"required":true,"description":"The hostname, e.g. `www.example.com`. Case-insensitive.","name":"host","in":"path"}],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"The refreshed domain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainConnectResponse"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or that host is not on this site (`domain_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"External domains are not configured on this server, or the host has no Cloudflare hostname (`serving_unconfigured`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains/{host}/provider-connect":{"post":{"operationId":"connectCustomDomainProvider","tags":["Domains"],"summary":"Apply DNS records through the DNS provider's API","description":"The paste-credentials lane for providers with an API (Name.com, GoDaddy): verifies the credentials, stores them encrypted, and writes the routing and certificate-check records for every host in the host's connect session. Omit `credentials` to reuse the ones saved earlier. External hosts only; an already connected host just returns its row. If it fails, the manual records from getCustomDomainConnect still work. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","example":"www.example.com"},"required":true,"description":"The hostname, e.g. `www.example.com`. Case-insensitive.","name":"host","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectProviderRequest"},"examples":{"godaddy":{"summary":"GoDaddy token","value":{"credentials":{"pat":"YOUR_GODADDY_PERSONAL_ACCESS_TOKEN"}}},"saved":{"summary":"Reuse saved credentials","value":{}}}}}},"responses":{"200":{"description":"Records applied (or the host was already connected).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainConnectResponse"}}}},"400":{"description":"The host is internal (`invalid_body`), the DNS provider is not recognised or has no API (`unknown_provider`, `provider_not_supported`), or the provider rejected the credentials (`error` carries its message).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), that host is not on this site (`domain_not_found`), or no credentials are saved and none were sent (`no_saved_credentials`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains/{host}/primary":{"post":{"operationId":"setPrimaryCustomDomain","tags":["Domains"],"summary":"Make a connected domain the site's primary address","description":"Marks the host as the canonical address: the site's other connected hosts then redirect (301) to it. Only a connected host can be primary. The body is ignored. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","example":"www.example.com"},"required":true,"description":"The hostname, e.g. `www.example.com`. Case-insensitive.","name":"host","in":"path"}],"responses":{"200":{"description":"The domain is now primary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomDomainResponse"}}}},"400":{"description":"The host is not connected yet (`not_connected`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or that host is not on this site (`domain_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/custom-domains/{host}":{"delete":{"operationId":"removeCustomDomain","tags":["Domains"],"summary":"Remove a custom domain from a site","description":"Disconnects the hostname and, for an external one, deletes its Cloudflare custom hostname and frees its slot. The site stops answering on that host. DNS records at the user's provider are not touched. Needs the site owner. Editors, viewers, guest tokens (`guest_requires_account`) and keys scoped to other sites are refused.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","example":"www.example.com"},"required":true,"description":"The hostname, e.g. `www.example.com`. Case-insensitive.","name":"host","in":"path"}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveCustomDomainResponse"}}}},"400":{"description":"The host is not a valid hostname (`invalid_host`, `single_label_host`, `reserved_host`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Not allowed: only the site owner manages domains, and guest tokens cannot.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`site_not_found`), or that host is not on this site (`domain_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"Custom domains are not configured on this server (`serving_unconfigured`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/access":{"get":{"operationId":"getSiteAccess","tags":["Sites"],"summary":"See who can view this site","description":"Whether the live site is public or password-protected. `canEnable` is true when the workspace is on CORE, or when the site is already protected (the password can still be changed after CORE ends). The password hash is never returned.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"responses":{"200":{"description":"The site's visitor access.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteAccess"}}}},"400":{"description":"The body is not valid (`invalid_body`), the password is missing (`password_required`), shorter than 8 characters (`password_too_short`) or longer than 128 (`password_too_long`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Turning protection on needs CORE (`feature_requires_core`), or the caller is not a site owner (`site_owner_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"operationId":"putSiteAccess","tags":["Sites"],"summary":"Change who can view this site","description":"Set `access` to `public` or `password`. A password is 8–128 characters. Omit `password` to keep the current one, which only works when the site is already protected. Turning protection on needs CORE on the owning workspace (`feature_requires_core`). Turning it off is always allowed. Only a site owner can change this (`site_owner_required`). Takes effect on the live site at once.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"description":"Public, or password-protected.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSiteAccessRequest"}}}},"responses":{"200":{"description":"The site's visitor access after the change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteAccess"}}}},"400":{"description":"The body is not valid (`invalid_body`), the password is missing (`password_required`), shorter than 8 characters (`password_too_short`) or longer than 128 (`password_too_long`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Turning protection on needs CORE (`feature_requires_core`), or the caller is not a site owner (`site_owner_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/form-submissions/export.csv":{"get":{"operationId":"exportFormSubmissionsCsv","tags":["Forms"],"summary":"Export a form's submissions as CSV","description":"Returns the submissions of one form as a CSV file (`text/csv`), one row per submission. The columns are `id`, `formId`, `pagePath`, `replyTo` and `createdAt` (Unix ms), then one column per field name the form has collected, in alphabetical order. Cells are quoted as RFC 4180 describes and rows end with CRLF.\n\nAn export holds at most 2,000 submissions. When it hits that limit the response carries `x-form-export-truncated: true`; narrow it with `fromMs`, `toMs`, and `q` and export again. `form` is required. Optional `q` uses the same search contract as the list endpoint. Submissions hold visitors' personal data, so this needs at least the `editor` role on the site. A `viewer` gets `403` `forbidden_role`.","parameters":[{"name":"form","in":"query","required":true,"description":"Form id, as wired on the page, for example `contact`. Omit to list every form's submissions newest first.","schema":{"type":"string","minLength":1},"example":"contact"},{"name":"fromMs","in":"query","required":false,"description":"Only submissions made at or after this time, Unix ms.","schema":{"type":"integer"}},{"name":"toMs","in":"query","required":false,"description":"Only submissions made at or before this time, Unix ms.","schema":{"type":"integer"}},{"name":"q","in":"query","required":false,"description":"Search text, case-insensitive. Matches the reply-to email and any field value (substring). On graduated sites the SQL list path applies when `q` is omitted; with `q` set, search scans up to 5,000 rows.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The CSV file.","headers":{"x-form-export-truncated":{"description":"Present and `true` when the export hit the 2,000-submission limit and more exist.","schema":{"type":"string","enum":["true"]}}},"content":{"text/csv":{"schema":{"type":"string","example":"id,formId,pagePath,replyTo,createdAt,email,message,name\r\n5b1c0f6e-3c1a-4a52-9f4e-0d7a2d9b8e31,contact,/contact,ada@example.com,1790000000000,ada@example.com,Do you take custom orders?,Ada Lovelace"}}}},"400":{"description":"`form` is missing (`missing_form`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The caller's role can't read submissions (`forbidden_role`), or the site's storage can't hold them (`backend_required`, which has `code` and `message` instead of `error`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiError"},{"$ref":"#/components/schemas/FormBackendRequired"}]}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/form-submissions":{"get":{"operationId":"listFormSubmissions","tags":["Forms"],"summary":"List form submissions, newest first","description":"Returns submissions newest first, a page at a time. Pass `form` to scope to one form; omit it to list every form together. Narrow the list with `fromMs` and `toMs` (a time range) or `q` (email or any field value, case-insensitive).\n\nEach item carries the full `fields`, so treat the response as personal data. To page, send `nextCursor` back as `cursor` until it comes back `null`. Submissions hold visitors' personal data, so this needs at least the `editor` role on the site. A `viewer` gets `403` `forbidden_role`.","parameters":[{"name":"form","in":"query","required":false,"description":"Form id, as wired on the page, for example `contact`. Omit to list every form's submissions newest first.","schema":{"type":"string","minLength":1},"example":"contact"},{"name":"limit","in":"query","required":false,"description":"Page size. Defaults to 50, so a request without `limit` returns the first 50 submissions and a `nextCursor`. A value over the site's own limit (100 or 200) is lowered to it.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page's `nextCursor`. Send it with `limit` to get the next page.","schema":{"type":"string"}},{"name":"fromMs","in":"query","required":false,"description":"Only submissions made at or after this time, Unix ms.","schema":{"type":"integer"}},{"name":"toMs","in":"query","required":false,"description":"Only submissions made at or before this time, Unix ms.","schema":{"type":"integer"}},{"name":"q","in":"query","required":false,"description":"Search text, case-insensitive. Matches the reply-to email and any field value (substring). On graduated sites the SQL list path applies when `q` is omitted; with `q` set, search scans up to 5,000 rows.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"One page of submissions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormSubmissionList"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The caller's role can't read submissions (`forbidden_role`), or the site's storage can't hold them (`backend_required`, which has `code` and `message` instead of `error`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiError"},{"$ref":"#/components/schemas/FormBackendRequired"}]}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/form-submissions/{id}":{"get":{"operationId":"getFormSubmission","tags":["Forms"],"summary":"Get one form submission with every field","description":"Returns one submission by id, with the full `fields` payload. Submissions hold visitors' personal data, so this needs at least the `editor` role on the site. A `viewer` gets `403` `forbidden_role`.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"5b1c0f6e-3c1a-4a52-9f4e-0d7a2d9b8e31"},"required":true,"description":"Submission id, from the `id` of a listed submission.","name":"id","in":"path"}],"responses":{"200":{"description":"The submission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormSubmission"}}}},"400":{"description":"The id is blank (`missing_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The caller's role can't read submissions (`forbidden_role`), or the site's storage can't hold them (`backend_required`, which has `code` and `message` instead of `error`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ApiError"},{"$ref":"#/components/schemas/FormBackendRequired"}]}}}},"404":{"description":"No such site, or no submission with this id (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/activity":{"get":{"operationId":"listActivity","tags":["Draft & publish"],"summary":"List what changed on the site, and who changed it","description":"The site's activity log, newest first: each edit, publish and revert with who made it (a person or an AI agent, and the tool), the agent's own note, and whether it still waits for a person to publish. Anyone who can see the site can read it.\n\nSend no filter to get the latest events, however old (up to the plan's retention). Send any of `who`, `state`, `actor`, `since`, `cursor` or `summary` to page through a window instead: `since` then defaults to 7 days and is never older than the plan keeps. Page with `limit` (1–50, default 25) and `cursor`; ask for `summary=1` on the first page for the header counts.","parameters":[{"name":"who","in":"query","required":false,"description":"Whose changes. `agents` = MCP and API-key callers; `people` = a signed-in person in the dashboard or editor. Default `all`.","schema":{"type":"string","enum":["all","people","agents"]}},{"name":"state","in":"query","required":false,"description":"`needs_review`: agent changes still waiting for a person to publish. `reverted`: changes that were undone.","schema":{"type":"string","enum":["needs_review","reverted"]}},{"name":"actor","in":"query","required":false,"description":"Only this actor's changes: an `actorId`, or `me` for the caller.","schema":{"type":"string"}},{"name":"since","in":"query","required":false,"description":"ISO date. Default 7 days ago when any filter is sent; never older than the plan's retention.","schema":{"type":"string","format":"date-time"}},{"name":"action","in":"query","required":false,"description":"Only this action, such as `publish` or `content.edit`.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size. 25 by default in a window (above); a plain read keeps its old default of 50 and allows up to 200.","schema":{"type":"integer","minimum":1,"maximum":50}},{"name":"cursor","in":"query","required":false,"description":"The `nextCursor` of the previous page.","schema":{"type":"string"}},{"name":"summary","in":"query","required":false,"description":"Add capped counts for the whole range: changes, actors, needs-review and the actor list.","schema":{"type":"string","enum":["1"]}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"One page of events.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityList"}}}},"400":{"description":"A filter is not valid (`invalid_request`, with `issues`), or `cursor` is not one this API issued (`invalid_cursor`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/activity/{id}/diff":{"get":{"operationId":"getActivityDiff","tags":["Draft & publish"],"summary":"Show what one change did to a page","description":"The before and after of one file in one change, as a line diff with 3 lines of context. Pass `key` to pick the file in a change that touched several; it defaults to the first. A page that was created or deleted is compared with an empty body. Each side is read from storage at most 512 KB deep. The diff exists only while the old version is still kept: after that it answers 410.","parameters":[{"name":"key","in":"query","required":false,"description":"The content key to diff. Default: the first key of the change.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"7c0e5d3a-1f4b-4b0a-9a55-2f6d1e8b3c90"},"required":true,"description":"An event id from the activity list.","name":"id","in":"path"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"responses":{"200":{"description":"The diff, plus every key of the change and whether it can be undone.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityDiff"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, no such event (`activity_not_found`), or `key` is not part of the change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"410":{"description":"The old or the new version is no longer kept (`activity_version_gone`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"This event has no diff (`activity_no_diff`): it changed no page, or it touched too many files to keep one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/activity/{id}/revert":{"post":{"operationId":"revertActivityChange","tags":["Draft & publish"],"summary":"Undo one change","description":"Puts back the version each file had before this change, as a draft — publish to make it live. It works on a change that is still a draft and on one that is already published, and it never overwrites a later edit: a page edited since is skipped (`changed_since`) and the rest are still undone. Send `keys` to undo only some files of a change; omit it to undo all.\n\nSome changes cannot be undone here: a page that was created and is live now (delete it instead), a delete that also removed routes, the site brief, and a change that touched too many files. The undo is recorded as a `revert` event. An agent on \"Drafts only\" AI access may use this; one on \"Read only\" may not.","x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X POST \"https://api.dotsy.ai/api/v1/sites/acme-studio/activity/$EVENT_ID/revert\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"reason\":\"Wrong price\"}'"}],"security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"7c0e5d3a-1f4b-4b0a-9a55-2f6d1e8b3c90"},"required":true,"description":"An event id from the activity list.","name":"id","in":"path"}],"requestBody":{"required":false,"description":"Optional: which files, and why.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityRevertRequest"},"examples":{"all":{"summary":"Undo everything in the change","value":{}}}}}},"responses":{"200":{"description":"At least one file was put back (staged as a draft).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityRevertResult"}}}},"400":{"description":"The body is not valid (`invalid_body`), or `keys` names a file that is not part of this change (`invalid_keys`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, or AI access set to read only).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or no such event (`activity_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"Nothing was undone: every file was edited since (`activity_change_conflict`, with `conflicts`), the draft is busy (`draft_busy`) or not readable yet (`draft_not_visible_yet`) — retry — the site backend changed (`placement_version_conflict`), or this storage arm cannot undo on the projection draft lane (`activity_revert_unsupported`, do-sqlite sites).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ActivityChangeConflictError"},{"$ref":"#/components/schemas/ApiError"}]}}}},"410":{"description":"The old version of a file is no longer kept (`activity_version_gone`). Nothing was changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"This change cannot be undone here (`activity_revert_blocked`, with `reason`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityRevertBlockedError"}}}}}}},"/api/v1/sites/{slug}/shared-sections/analyze":{"post":{"operationId":"analyzeSharedSection","tags":["Content"],"summary":"Find where an element repeats before sharing it","description":"Reads the element from the page's stored HTML and looks it up in the site's section index: the pages with an exact copy, the pages with a similar copy (same structure, different words), whether it can go on every page, and a free name. With `selectors`, the same for a run of elements next to each other (no similar copies). Writes nothing. One stored-page read; the counts come from the index.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeSharedSectionRequest"}}}},"responses":{"200":{"description":"Where it repeats.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeSharedSectionResult"}}}},"400":{"description":"The body or selector is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or the page (`content_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The page changed since `ifMatch` (`shared_section_conflict`), or `index_warming`: on a site whose route list isn't cached, reading it now would not leave room for the rest of the call — retry in a minute.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionConflictError"},{"$ref":"#/components/schemas/ApiError"}]}}}},"422":{"description":"The element or page can't take part (`not_eligible`, with `reason`), the selector matched 0 or 2+ elements (`unexpected_match_count`), or `selectors` don't sit next to each other in one parent, in page order (`not_siblings`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionNotEligibleError"},{"$ref":"#/components/schemas/ApiError"}]}}}}}}},"/api/v1/sites/{slug}/shared-sections/compare":{"post":{"operationId":"compareSharedSection","tags":["Content"],"summary":"Show the words a similar copy would lose","description":"For one page from analyze's `similar.keys`: each text, link or image that differs from the shared version. Writes nothing.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompareSharedSectionRequest"}}}},"responses":{"200":{"description":"What differs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompareSharedSectionResult"}}}},"400":{"description":"The body or selector is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or the page (`content_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"The element or page can't take part (`not_eligible`, with `reason`), the selector matched 0 or 2+ elements (`unexpected_match_count`), or `selectors` don't sit next to each other in one parent, in page order (`not_siblings`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionNotEligibleError"},{"$ref":"#/components/schemas/ApiError"}]}}}}}}},"/api/v1/sites/{slug}/shared-sections/suggestions":{"get":{"operationId":"listSharedSectionSuggestions","tags":["Content"],"summary":"List blocks that repeat on 3 or more pages","description":"Blocks with an exact copy on at least 3 pages that are not shared yet, best first, minus the ones dismissed on this site. Also runs of 2–6 blocks that sit next to each other and repeat together (`blocks` > 1): the longest wins, and a block that is part of a suggested run is not suggested on its own. Read from the site's index — no page is read.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":20},"required":false,"description":"How many, 1–20. Default 5.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1},"required":false,"description":"A page's content key: each suggestion on that page gets `here` (its selectors there).","name":"key","in":"query"}],"responses":{"200":{"description":"The suggestions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedSectionSuggestions"}}}},"400":{"description":"A query value is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/shared-sections/suggestions/dismiss":{"post":{"operationId":"dismissSharedSectionSuggestion","tags":["Content"],"summary":"Stop suggesting a block on this site","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DismissSharedSectionSuggestionRequest"}}}},"responses":{"200":{"description":"Dismissed.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"dismissed":{"type":"boolean","enum":[true]}},"required":["id","dismissed"]}}}},"400":{"description":"The body is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, or AI access set to read only).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/shared-sections":{"get":{"operationId":"listSharedSections","tags":["Content"],"summary":"List the site's shared sections","description":"Each shared section with where it shows and how many pages use it, by name. Built from the site's outline — no page is read.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"description":"Page size, 1–100. Default 50.","name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"description":"The `nextCursor` of the previous page.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of sections.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedSectionList"}}}},"400":{"description":"A query value or `cursor` is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"operationId":"shareSection","tags":["Content"],"summary":"Make an element a shared section","description":"Saves the element as `fragments/<name>.html` and replaces it with a reference on the source page and on the pages you include — or, with `every_page`, puts one reference in the layout instead. All pages change together or none do. Staged as a draft, recorded as one activity event (undo it from there). A retry of a share that already happened answers `alreadyApplied`. Sharing again under the name of a section that holds this same block adds the pages to it. With `selectors`, a run of elements next to each other becomes one section inside a wrapper (`wrapTag`, `keepLayout`), and every page with the same run gets the reference in its place.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSectionRequest"}}}},"responses":{"200":{"description":"Shared (staged as a draft).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSectionResult"}}}},"400":{"description":"The body or selector is not valid, or the change needs more pages than one call can change (`too_many_pages`, with `max`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, or AI access set to read only).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or the page (`content_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"A page changed (`shared_section_conflict`, nothing written), or the name is in use (`section_name_taken`, with `suggestion`). `index_warming`: the index is not ready yet — retry in a minute.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionConflictError"},{"$ref":"#/components/schemas/SectionNameTakenError"},{"$ref":"#/components/schemas/ApiError"}]}}}},"422":{"description":"The element or page can't take part (`not_eligible`, with `reason`), the selector matched 0 or 2+ elements (`unexpected_match_count`), or `selectors` don't sit next to each other in one parent, in page order (`not_siblings`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionNotEligibleError"},{"$ref":"#/components/schemas/ApiError"}]}}}}}}},"/api/v1/sites/{slug}/shared-sections/{name}/pages":{"get":{"operationId":"listSharedSectionPages","tags":["Content"],"summary":"List the pages that use a shared section","description":"The section's row, then its pages: how each one gets it (page, layout or another section) and whether it hides it.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"booking-banner"},"required":true,"description":"The section name.","name":"name","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"description":"Page size, 1–100. Default 50.","name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"description":"The `nextCursor` of the previous page.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of pages.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedSectionPages"}}}},"400":{"description":"A query value or `cursor` is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or no such section (`section_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/shared-sections/{name}/placement":{"post":{"operationId":"placeSharedSection","tags":["Content"],"summary":"Change where a shared section shows","description":"Move it between the pages where it appears and every page (one slot in the layout), or hide it on some pages of an every-page section and show it there again. The hide marker lives in each page's `<dotsy-config>`, so it moves with the page. All pages of a call change together or none do. Over the per-call cap, move to every page in slices with `keys`: the first call also places it in the layout and takes the inline copy of every page it doesn't hide, a page not moved yet keeps its own reference (and shows it once). Pages kept out are hidden with the first move, or before it (`hide` without `scope`, in as many calls as needed). Staged as a draft; `dryRun` reports without writing.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"booking-banner"},"required":true,"description":"The section name.","name":"name","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedSectionPlacementRequest"}}}},"responses":{"200":{"description":"Placed (staged as a draft), or what would change on a dry run.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedSectionPlacementResult"}}}},"400":{"description":"The body is not valid, or the change needs more pages than one call can change (`too_many_pages`, with `max`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, or AI access set to read only).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or no such section (`section_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"A page changed (`shared_section_conflict`, nothing written), or a later every-page slice hid a page whose own copy the first move already took (`hide_after_move`). `index_warming`: the index is not ready yet — retry in a minute.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionConflictError"},{"$ref":"#/components/schemas/SectionNameTakenError"},{"$ref":"#/components/schemas/ApiError"}]}}}},"422":{"description":"The element or page can't take part (`not_eligible`, with `reason`), the selector matched 0 or 2+ elements (`unexpected_match_count`), or `selectors` don't sit next to each other in one parent, in page order (`not_siblings`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionNotEligibleError"},{"$ref":"#/components/schemas/ApiError"}]}}}}}}},"/api/v1/sites/{slug}/shared-sections/{name}/detach":{"post":{"operationId":"detachSharedSection","tags":["Content"],"summary":"Give one page its own copy of a shared section","description":"Replaces the page's reference with a copy of the section's HTML, so later edits to the section skip this page. Only for a page that places the section itself; a section from the layout is hidden on the page instead (placement `hide`). Staged as a draft.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1,"example":"booking-banner"},"required":true,"description":"The section name.","name":"name","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetachSharedSectionRequest"}}}},"responses":{"200":{"description":"Detached (staged as a draft), or what would change on a dry run.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetachSharedSectionResult"}}}},"400":{"description":"The body is not valid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site (viewer role, or AI access set to read only).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, page or section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The page changed since `ifMatch` (`shared_section_conflict`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedSectionConflictError"}}}},"422":{"description":"The element or page can't take part (`not_eligible`, with `reason`), the selector matched 0 or 2+ elements (`unexpected_match_count`), or `selectors` don't sit next to each other in one parent, in page order (`not_siblings`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/SharedSectionNotEligibleError"},{"$ref":"#/components/schemas/ApiError"}]}}}}}}},"/api/v1/sites/{slug}/members":{"get":{"operationId":"listSiteMembers","tags":["Members"],"summary":"List a site's collaborators and pending invites","description":"The people who were given access to this one site, plus invites nobody has accepted yet. This is the guest list. To see the workspace's own teammates, use `listSiteTeammates`. Needs the site Owner role.\n\n`limit` and `cursor` page `members` only, oldest first. `pendingInvites` always comes back complete.","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size (1–1000). Omit it to get the whole list in one response, without `nextCursor`.","schema":{"type":"integer","minimum":1,"maximum":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page's `nextCursor`. Send it with `limit` to get the next page.","schema":{"type":"string"}},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"The collaborators and pending invites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteMemberList"}}}},"400":{"description":"`invalid_cursor`: the `cursor` is not one this API returned. Restart the listing without it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: managing a site's people needs the site Owner role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`: no such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/members/teammates":{"get":{"operationId":"listSiteTeammates","tags":["Members"],"summary":"List the workspace teammates who can reach a site","description":"The members of the workspace that owns the site. They already have access to it through the workspace, so they never appear in `listSiteMembers`. Needs the site Owner role.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"responses":{"200":{"description":"The workspace roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteTeammateList"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: managing a site's people needs the site Owner role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`: no such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/members/invites":{"post":{"operationId":"createSiteInvite","tags":["Members"],"summary":"Invite someone to a site by email","description":"Gives one person access to this one site as an `editor` or `viewer`. They get an email with a link that is good for 7 days; accepting needs a signed-in account on the invited address (`acceptInvite`). Inviting an address that already has a pending invite re-sends it as a fresh one instead of adding a second.\n\nAdding collaborators needs the site's team add-on (402 `upgrade_required` without it), and a site holds at most 20 collaborators and pending invites together. Needs the site Owner role.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"description":"Who to invite and as what.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSiteInviteRequest"},"example":{"email":"grace@example.com","role":"editor"}}}},"responses":{"201":{"description":"The invite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSiteInviteResponse"}}}},"400":{"description":"`invalid_body`: send `{ email, role: 'editor' | 'viewer', isInternal? }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"`upgrade_required`: this site's plan does not include collaborators.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: managing a site's people needs the site Owner role. `guest_only`: this account can't invite people.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`: no such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`collaborator_cap_reached`: the site already has 20 collaborators and pending invites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`rate_limited`: too many invites sent recently. Wait and retry; `Retry-After` says how long.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/members/invites/{inviteId}":{"delete":{"operationId":"cancelSiteInvite","tags":["Members"],"summary":"Cancel a pending site invite","description":"Withdraws an invite that has not been accepted. Needs the site Owner role.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The invite's `id`, from `listSiteMembers` (`pendingInvites`) or `createSiteInvite`.","name":"inviteId","in":"path"}],"responses":{"200":{"description":"The invite was cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: managing a site's people needs the site Owner role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`, or `invite_not_found`: no such pending invite on this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/members/{userId}":{"delete":{"operationId":"removeSiteMember","tags":["Members"],"summary":"Remove a collaborator from a site","description":"Takes away one person's access to this site. It does not touch workspace teammates, whose access comes from the workspace. The last explicit site owner can't be removed. Needs the site Owner role.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The person's `userId`, from `listSiteMembers`.","name":"userId","in":"path"}],"responses":{"200":{"description":"Access was removed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: managing a site's people needs the site Owner role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`, or `member_not_found`: that person has no explicit access to this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`last_owner`: this is the site's only owner. Assign another owner first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/transfer":{"post":{"operationId":"createTransfer","tags":["Members"],"summary":"Offer a site to another workspace","description":"Offers the site to someone by email. They have 14 days to accept, and they pick which of their workspaces receives it (`acceptTransfer`). The site stays here until they do. A new offer replaces any pending one for the same site. Needs the site Owner role.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"description":"Who to offer the site to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTransferRequest"},"example":{"toEmail":"grace@example.com","keepCollaborators":true}}}},"responses":{"201":{"description":"The offer was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTransferResponse"}}}},"400":{"description":"`invalid_body`: send `{ toEmail, keepCollaborators: boolean }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: transferring a site needs the site Owner role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`: no such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/transfer/{transferId}":{"delete":{"operationId":"cancelTransfer","tags":["Members"],"summary":"Cancel a pending site transfer","description":"Withdraws an offer that has not been accepted. Needs the site Owner role.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The transfer's `id`, from `createTransfer`.","name":"transferId","in":"path"}],"responses":{"200":{"description":"The offer was cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_role`: transferring a site needs the site Owner role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`site_not_found`, or `transfer_not_found`: no such pending transfer on this site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/transfers/{token}":{"get":{"operationId":"getTransfer","tags":["Members"],"summary":"Preview a site transfer offer","description":"What a transfer link offers: which site, from which workspace, and whether it is still open. It needs no sign-in, so the page behind the link can show it before the person logs in. It never returns site content.","security":[],"parameters":[{"schema":{"type":"string","minLength":1},"required":true,"description":"The transfer token from the offer link (`/transfer/{token}`).","name":"token","in":"path"}],"responses":{"200":{"description":"The offer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransferPreview"}}}},"404":{"description":"`transfer_not_found`: the token matches no offer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/transfers/{token}/accept":{"post":{"operationId":"acceptTransfer","tags":["Members"],"summary":"Accept a site transfer into a workspace","description":"Moves the site into the chosen workspace. It needs a signed-in user (a dashboard session or an OAuth token), not a `pga_` key. Their verified email must match the address the offer was made to, and they must be an Owner or Admin of the destination workspace. A free site can't move into a workspace that is already at its free-site cap.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1},"required":true,"description":"The transfer token from the offer link (`/transfer/{token}`).","name":"token","in":"path"}],"requestBody":{"required":true,"description":"The destination workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptTransferRequest"}}}},"responses":{"200":{"description":"The site moved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptTransferResponse"}}}},"400":{"description":"`invalid_body`: send `{ destinationWorkspaceId }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`session_required`: sign in as a person, since an API key can't accept. `transfer_email_mismatch`: the signed-in email is not the one the offer was made to. `email_not_verified`: verify your email first. `not_destination_admin`: you must be an Owner or Admin of the destination workspace. `free_site_limit`: the destination is at its free-site cap.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`transfer_not_found`: the token matches no offer. `site_not_found`: the site no longer exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`transfer_cancelled`, `transfer_already_accepted`, or `slug_conflict`: the destination already has a site with this slug. Rename or remove that site and try again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"410":{"description":"`transfer_expired`: ask the owner for a new offer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/rename":{"post":{"operationId":"renameSite","tags":["Sites"],"summary":"Change a site's slug","description":"Gives the site a new slug, and with it a new address. Only the site's owner can do it, and a guest token cannot.\n\nMost sites finish inside the request: **200** with the new `slug`. A large site (more than 1,000 stored files) is copied in the background instead: **202** with `status: \"copying\"` and a `job`. Poll `GET /api/v1/sites/{slug}/jobs/{id}` with `job.id` until `status` is `succeeded` or `failed`. Once the copy finishes the old slug stops answering directly, so poll with the new slug if the old one redirects or returns 404.\n\nIf the site was ever published, the old slug stays reserved and redirects here (301 for reads, 308 for writes), so follow redirects; `aliasRedirect` says which case you are in. A site that was never published releases the old slug, which then 404s. Absolute URLs inside stored HTML are not rewritten. The rename is declined while an import, a structure or adapt job, an asset warm-up or a site transfer is running.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"description":"The new slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameSiteRequest"},"example":{"newSlug":"acme-studio-2"}}}},"responses":{"200":{"description":"Renamed. The site lives at `slug` now.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameSiteResponse"}}}},"202":{"description":"Accepted: a large site is being copied in the background. Poll the `job`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameSiteAccepted"}}}},"400":{"description":"The body is not `{ newSlug }` (`invalid_body`), the slug is not valid (`invalid_slug`) or is reserved (`reserved_slug`), or it is the site's current slug (`same_slug`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Only the site's owner can rename it (`forbidden_role`); a read-only key or a guest token cannot (`forbidden_read_only`, `guest_requires_account`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`site_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"Nothing changed. `slug_taken`, or the site is busy: `structure_active`, `adapt_active`, `import_active`, `warm_active`, `transfer_pending` or `rename_active`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Over the account's write budget (`rate_limited`). Retry after the `Retry-After` seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace-plan":{"get":{"operationId":"getWorkspacePlan","tags":["Account"],"summary":"Get the workspace plan and usage","description":"The active workspace's plan, how many free sites it can hold, how many Pro and password-protected sites it has, and how many free workspaces the signed-in person owns. Any member may read it. A `pga_` key has no person, so `freeWorkspaces.owned` is 0. Not polled — the workspace settings page loads it.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"responses":{"200":{"description":"The workspace plan and usage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePlanView"},"example":{"workspaceId":"org_01","plan":"free","freeSites":{"used":3,"max":10,"base":10,"perPaid":10},"paidSites":1,"protectedSites":0,"freeWorkspaces":{"owned":2,"limit":5}}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`forbidden_workspace`: you are not a member of the workspace named in `X-Dotsy-Workspace`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/keys":{"get":{"operationId":"listApiKeys","tags":["Account"],"summary":"List the workspace's API keys","description":"Lists the active workspace's API keys, with each key's secret, so a key can be copied again later. Needs an `admin` key, or a signed-in workspace Owner or Admin.\n\nWithout `limit`, every key comes back, newest first. With `limit`, keys come back oldest first so a page never shifts while you read it, and `nextCursor` says whether more remain.","parameters":[{"name":"limit","in":"query","required":false,"description":"Page size (1–1000). Omit it to get the whole list in one response, without `nextCursor`.","schema":{"type":"integer","minimum":1,"maximum":1000}},{"name":"cursor","in":"query","required":false,"description":"Opaque cursor from the previous page's `nextCursor`. Send it with `limit` to get the next page.","schema":{"type":"string"}},{"$ref":"#/components/parameters/WorkspaceHeader"}],"security":[{"BearerAuth":[]}],"responses":{"200":{"description":"The keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyList"}}}},"400":{"description":"`invalid_cursor`: the `cursor` is not one this API returned. Restart the listing without it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`admin_required`: the key's scope is not `admin`. `workspace_admin_required`: you are a workspace Member, and only an Owner or Admin can manage keys. `guest_only`: this account can't create keys. `guest_requires_account`: a guest token can't manage keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"description":"`auth_secret_missing`: the server has no signing secret configured, so stored secrets can't be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"operationId":"createApiKey","tags":["Account"],"summary":"Create an API key","description":"Creates a `pga_…` key for the active workspace and returns it, secret included. The secret stays readable through `listApiKeys`. Every field is optional: the default is a `write` key for all sites.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"requestBody":{"required":false,"description":"Key options. Every field is optional.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"},"examples":{"default":{"summary":"A write key for every site","value":{}},"scoped":{"summary":"A read-only key for one site","value":{"label":"Status page","scope":"read","siteSlugs":["acme-studio"]}}}}}},"responses":{"201":{"description":"The new key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKey"}}}},"400":{"description":"`invalid_body`, or `site_not_found`: a slug in `siteSlugs` is not a site in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`admin_required`: the key's scope is not `admin`. `workspace_admin_required`: you are a workspace Member, and only an Owner or Admin can manage keys. `guest_only`: this account can't create keys. `guest_requires_account`: a guest token can't manage keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"500":{"description":"`auth_secret_missing`: the server has no signing secret configured, so keys can't be created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/keys/{id}":{"patch":{"operationId":"updateApiKey","tags":["Account"],"summary":"Change an API key's name, scope or sites","description":"Changes only the fields you send. The secret can't be changed, and the change applies to the key's next request.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The key's `id`, from `listApiKeys` or `createApiKey`.","name":"id","in":"path"}],"requestBody":{"required":false,"description":"The fields to change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateApiKeyRequest"},"example":{"label":"CI deploys (staging)","scope":"write"}}}},"responses":{"200":{"description":"The key was updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"400":{"description":"`invalid_body`, or `site_not_found`: a slug in `siteSlugs` is not a site in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`admin_required`: the key's scope is not `admin`. `workspace_admin_required`: you are a workspace Member, and only an Owner or Admin can manage keys. `guest_only`: this account can't create keys. `guest_requires_account`: a guest token can't manage keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`key_not_found`: no such key in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"deleteApiKey","tags":["Account"],"summary":"Revoke an API key","description":"Revokes the key. It stops working right away.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The key's `id`, from `listApiKeys` or `createApiKey`.","name":"id","in":"path"}],"responses":{"200":{"description":"The key was revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`admin_required`: the key's scope is not `admin`. `workspace_admin_required`: you are a workspace Member, and only an Owner or Admin can manage keys. `guest_only`: this account can't create keys. `guest_requires_account`: a guest token can't manage keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`key_not_found`: no such key in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/invites/{token}":{"get":{"operationId":"getInvite","tags":["Members"],"summary":"Preview a site invite","description":"What an invite link offers: which site, which role and who invited. It needs no sign-in, so the page behind the link can show it before the person logs in. It never returns site content or the invited email address.","security":[],"parameters":[{"schema":{"type":"string","minLength":1},"required":true,"description":"The invite token from the invite link (`/invite/{token}`).","name":"token","in":"path"}],"responses":{"200":{"description":"The invite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitePreview"}}}},"404":{"description":"`invite_not_found`: the token matches no invite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/invites/{token}/accept":{"post":{"operationId":"acceptInvite","tags":["Members"],"summary":"Accept a site invite","description":"Gives the signed-in person access to the invited site. It needs a signed-in user (a dashboard session or an OAuth token), not a `pga_` key, and their verified email must match the address that was invited.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1},"required":true,"description":"The invite token from the invite link (`/invite/{token}`).","name":"token","in":"path"}],"responses":{"200":{"description":"Access was granted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptInviteResponse"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`session_required`: sign in as a person, since an API key can't accept. `invite_email_mismatch`: the signed-in email is not the invited one. `email_not_verified`: verify your email first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`invite_not_found`: the token matches no invite. `site_not_found`: the site no longer exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`invite_already_accepted`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"410":{"description":"`invite_expired`: ask the owner for a new invite.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/anon/sites":{"post":{"operationId":"startGuestSite","tags":["Guest"],"summary":"Start a draft site without an account","description":"Creates a draft-only site and a guest token, with no sign-in and no API key. This is the one call that needs no credentials.\n\nThe response carries a **guest token** (`pgg_…`). Send it as `Authorization: Bearer <guestToken>` and it authenticates as `GuestBearerAuth` for that one site: you can read and edit its content, routes and brief, but only the operations that list `GuestBearerAuth` in their security accept it. Publishing, renaming, deleting, custom domains, imports, asset writes, creating other sites and managing API keys answer `403` `guest_requires_account`, and any other site answers `403` `forbidden_site_scope`.\n\nThe site is deleted 72 hours after creation (`expiresAt`) unless you claim it with `POST /api/v1/anon/claim` from a signed-in account.\n\nGuest creation is rate limited across the whole platform. Over the limit you get `429` `rate_limited` with a `Retry-After` header.","security":[],"requestBody":{"required":false,"description":"Optional title and purpose for the new site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartGuestSiteRequest"},"examples":{"titled":{"summary":"With a title and a purpose","value":{"title":"Maple Street Bakery","purpose":"A one-page site for a neighbourhood bakery: opening hours, the menu and a contact form."}},"empty":{"summary":"Nothing","value":{}}}}}},"responses":{"201":{"description":"The guest site exists. Keep `guestToken`: it is not shown again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartGuestSiteResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`): `title` or `purpose` is too long or not a string.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Too many guest sites were created in the last hour (`rate_limited`). Retry after the delay in `Retry-After`.","headers":{"Retry-After":{"description":"Seconds to wait before trying again (3600).","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/anon/claim":{"post":{"operationId":"claimGuestSite","tags":["Guest"],"summary":"Move a guest site onto your account","description":"Moves the guest site behind a guest token into your own workspace as a Free site, then revokes the guest token. After this the site is an ordinary site: publish it, rename it and connect domains.\n\nCall it with a signed-in user's **OAuth access token** in `Authorization: Bearer …`. A `pga_` API key or a guest token in that header answers `401` `unauthorized`, because a claim belongs to a person. The guest token of the site you are claiming goes in the body. Claiming the same site again as the same user answers `200` with the same body.\n\nEach account can claim one guest site (`409` `guest_claim_limit`). A workspace that is already at its Free-site limit answers `403` `free_site_limit`.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"description":"The guest token of the site to claim.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimGuestSiteRequest"},"example":{"guestToken":"pgg_Zk3qR1m7vT0aXc9LwYb2NfHj5uPdE8sGiO4tAx6VnMo"}}}},"responses":{"200":{"description":"The site is now in your workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimGuestSiteResponse"}}}},"400":{"description":"The body is not valid (`invalid_body`): `guestToken` is missing or too short.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"The bearer is not an OAuth access token (`unauthorized`), or the guest token in the body is unknown, expired or revoked (`invalid_guest_token`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"error":{"type":"string","enum":["unauthorized","invalid_guest_token"],"description":"`unauthorized`: the bearer is not an OAuth access token. `invalid_guest_token`: the guest token is unknown, expired, revoked or already used."}}}]}}}},"403":{"description":"Your workspace is at its Free-site limit (`free_site_limit`). Nothing changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The site can't be claimed. Nothing changed.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"error":{"type":"string","enum":["anonymous_site_claimed","guest_claim_limit","site_not_found","slug_conflict"],"description":"`anonymous_site_claimed`: another account already claimed this site. `guest_claim_limit`: this account already claimed a guest site. `site_not_found` / `slug_conflict`: the site could not be moved into your workspace."}}}]}}}},"500":{"description":"Your workspace could not be provisioned (`org_provision_failed`). Retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace-invites":{"post":{"operationId":"createWorkspaceInvite","tags":["Account"],"summary":"Invite a teammate to the workspace","description":"Invites someone by email as an `admin` or a `member` of the active workspace. Inviting an address that already has a pending invite re-sends it and extends the expiry instead of creating a second one. Only a workspace Owner or Admin can invite. To share a single site instead, use `createSiteInvite`.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"requestBody":{"required":true,"description":"Who to invite and as what.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInviteRequest"},"example":{"email":"grace@example.com","role":"member"}}}},"responses":{"201":{"description":"The invitation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInviteResponse"}}}},"400":{"description":"`invalid_body`: send `{ email, role }` with role `admin` or `member`, or the invitation was refused with a specific code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`workspace_admin_required`: only a workspace Owner or Admin can do this. `guest_only`: this account can't invite teammates. `guest_requires_account`: a guest token can't invite teammates.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"operationId":"listWorkspaceInvites","tags":["Account"],"summary":"List pending workspace invitations","description":"The active workspace's invitations that nobody has accepted yet.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"responses":{"200":{"description":"The pending invitations.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInviteList"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace-invites/{id}/cancel":{"post":{"operationId":"cancelWorkspaceInvite","tags":["Account"],"summary":"Cancel a pending workspace invitation","description":"Withdraws an invitation that has not been accepted. Only a workspace Owner or Admin can cancel.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The invitation's `id`, from `listWorkspaceInvites`.","name":"id","in":"path"}],"responses":{"200":{"description":"The invitation was cancelled.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"400":{"description":"There is no such invitation, or it can't be cancelled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`workspace_admin_required`: only a workspace Owner or Admin can do this. `guest_only`: this account can't invite teammates. `guest_requires_account`: a guest token can't invite teammates.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace":{"patch":{"operationId":"updateWorkspace","tags":["Account"],"summary":"Rename the workspace","description":"Renames the active workspace. Only a workspace Owner or Admin can.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"requestBody":{"required":true,"description":"The new name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"},"example":{"name":"Acme Studio"}}}},"responses":{"200":{"description":"The workspace was renamed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"name":{"type":"string","description":"The saved name, trimmed."}},"required":["ok","name"]}}}},"400":{"description":"`invalid_body`: send a name of 1 to 120 characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`workspace_admin_required`: only a workspace Owner or Admin can rename the workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace-members":{"get":{"operationId":"listWorkspaceMembers","tags":["Account"],"summary":"List the workspace's members","description":"Everyone in the active workspace, up to 200 people, with their roles. Any member can list.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"}],"responses":{"200":{"description":"The members.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberList"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"You are not a member of this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace-members/{memberId}/role":{"post":{"operationId":"updateWorkspaceMemberRole","tags":["Account"],"summary":"Change a workspace member's role","description":"Sets the member to `owner`, `admin` or `member`. Only a workspace Owner or Admin can, and only an Owner can touch an owner's role.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The membership `id`, from `listWorkspaceMembers`.","name":"memberId","in":"path"}],"requestBody":{"required":true,"description":"The new role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceMemberRoleRequest"},"example":{"role":"admin"}}}},"responses":{"200":{"description":"The role was changed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"400":{"description":"`invalid_body`: send `{ role: 'owner' | 'admin' | 'member' }`, or the change was refused (for example, the last owner can't be demoted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`workspace_admin_required`: only a workspace Owner or Admin can change roles.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/account/workspace-members/{memberId}":{"delete":{"operationId":"removeWorkspaceMember","tags":["Account"],"summary":"Remove a member from the workspace","description":"Removes the person from the active workspace. Only a workspace Owner or Admin can. A workspace always keeps at least one owner.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"},{"schema":{"type":"string","minLength":1},"required":true,"description":"The membership `id`, from `listWorkspaceMembers`.","name":"memberId","in":"path"}],"responses":{"200":{"description":"The member was removed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"]}}}},"400":{"description":"The removal was refused (for example, the last owner can't be removed).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`workspace_admin_required`: only a workspace Owner or Admin can remove people.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}":{"get":{"operationId":"getSite","tags":["Sites"],"summary":"Get a site","description":"One site in full: settings, stats, a window of its pages, recent activity and, while an import is running, build progress. `pages` is a window: it holds up to `pagesLimit` HTML pages (500 by default); follow `nextPagesCursor` for the rest, or use `GET /api/v1/sites/{slug}/pages` for a paged list of every page. Add `brief=1` to include the head of the site brief.\n\nThe path accepts the site id (a UUID) as well as the slug. An old slug (after a rename) answers 301 for reads and 308 for writes, and a site id answers 307, both to the canonical slug path. Follow redirects.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":1000,"description":"Max HTML pages returned (default 500). Use with pagesCursor on big clones."},"required":false,"name":"pagesLimit","in":"query"},{"schema":{"type":"string","description":"Key cursor from a prior response's nextPagesCursor."},"required":false,"name":"pagesCursor","in":"query"},{"schema":{"type":"string","maxLength":200,"description":"Filter HTML pages whose content key contains this term (case-insensitive)."},"required":false,"name":"pagesQ","in":"query"},{"schema":{"type":"string","enum":["0","1"],"description":"When 1, include parsed `_intent/site.md` head in `brief` (~1 KB)."},"required":false,"name":"brief","in":"query"}],"responses":{"200":{"description":"The site.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Site"}}}},"400":{"description":"A query parameter is not valid (`invalid_request`): `pagesLimit` must be 1–1000, `pagesQ` at most 200 characters, `brief` 0 or 1.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token is limited to other sites (`forbidden_site_scope`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"operationId":"updateSite","tags":["Sites"],"summary":"Update a site's settings","description":"Changes the settings you send and leaves the rest alone. `title` is the display name. `keyConvention` is `flat` or `directory` (how content keys map to URLs). `editingMode` is `live` (every editor save goes public) or `staged` (saves stay drafts until you publish); API writes always stage. `signature` sets the \"Made with Dotsy\" badge and takes effect on the live site at once. `integrations` resolves pending clone integrations. `aiAccess` sets what AI agents and API keys may do on the site (`viewer`, `contributor`, `editor` or `owner`, the default). Only the site owner signed in to the dashboard can change it: an API key or MCP token answers 403 `ai_access_limited`.\n\nA guest token can change the title but not `editingMode` (it answers 403 `guest_requires_account`).","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"description":"The settings to change. Send at least one.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSiteRequest"}}}},"responses":{"200":{"description":"Updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSiteResponse"}}}},"400":{"description":"The body is not valid JSON or not a valid settings object, or it changes nothing (`invalid_body`, with `issues`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot edit this site: viewer role (`forbidden_role`), a read-only key (`forbidden_read_only`), a guest token changing `editingMode` (`guest_requires_account`), AI access set below what this change needs, or an API key or MCP token sending `aiAccess` (`ai_access_limited`). A signed-in non-owner sending `aiAccess` gets `forbidden_role`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The site is being renamed right now (`rename_active`). Retry when it finishes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"An `integrations` id does not match a pending integration on this site (`invalid_integrations`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Over the account's write budget (`rate_limited`). Retry after the `Retry-After` seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"deleteSite","tags":["Sites"],"summary":"Delete a site","description":"Permanently deletes the site: its content, asset files, addresses and directory entries. This cannot be undone. Only the site's owner can do it, and a guest token cannot. When `purge` is `queued`, the site is already gone and its stored files are being removed in the background.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteSiteResponse"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Only the site's owner can delete it (`forbidden_role`); a read-only key or a guest token cannot (`forbidden_read_only`, `guest_requires_account`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The site is being renamed right now (`rename_active`). Retry when it finishes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Over the account's write budget (`rate_limited`). Retry after the `Retry-After` seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/import-status":{"get":{"operationId":"getImportStatus","tags":["Import"],"summary":"Check the build progress of a site's latest import","description":"A light poll for the editor while a URL import runs: how many pages have landed and whether mirrored assets are still copying. A failed import stays visible here (`phase: failed`) after it stops, so an empty site with a failed import never looks like a site that was never imported. Progress is kept for an hour after its last update.\n\nFor the full result of one import, poll `GET /api/v1/sites/{slug}/jobs/{id}` with the `job.id` from the import's 202.","security":[{"BearerAuth":[]},{"GuestBearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"responses":{"200":{"description":"The latest import's progress, or `build: null`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportStatusResponse"},"examples":{"building":{"summary":"Pages are being captured","value":{"build":{"jobId":"9b2c7e1a-4f3d-4a6b-8c15-0d2e7f9a1b34","phase":"pages","pagesDone":4,"pagesTotal":10,"pagesLanded":4,"updatedAt":1759400000000}}},"none":{"summary":"Never imported","value":{"build":null}}}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token is scoped to other sites.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/publish":{"post":{"operationId":"publishSite","tags":["Draft & publish"],"summary":"Publish the staged draft to the live site","description":"Promotes the draft to the live site and refreshes the serving index. Everything is optional in the body: send `{}` (or nothing) to publish everything that is staged, or `keys` / `routes` to publish only part of it.\n\nBefore it goes live the publish checks that the site has a real purpose in its brief (first publish), that no `<dotsy-collection>` card template would render empty, that redirects do not loop, that the site's Rules pass (for agents, a blocking Rule answers 422; a person gets `ruleWarnings` on the 200), and that no import or adapt job is still running. A blocked publish answers an error and changes nothing.\n\n`feedback` and `handoff` are optional. Agents send them so Dotsy learns from the session and the next session inherits the decisions made in this one.","x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -X POST \"https://api.dotsy.ai/api/v1/sites/acme-studio/publish\" \\\n  -H \"Authorization: Bearer $DOTSY_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"feedback\":{\"category\":\"other\",\"impactPercent\":5,\"title\":\"No issues\",\"description\":\"Routine publish; no friction to report.\"},\"handoff\":\"Routine dashboard publish.\"}'"}],"security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":false,"description":"Publish options. Every field is optional.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishRequest"},"examples":{"everything":{"summary":"Publish everything staged","value":{"feedback":{"category":"other","impactPercent":5,"title":"No issues","description":"Routine publish; no friction to report."},"handoff":"Routine dashboard publish."}},"minimal":{"summary":"No feedback","value":{}},"selective":{"summary":"Publish one page","value":{"keys":["blog/launch.html"],"routes":["/blog/launch"]}}}}}},"responses":{"200":{"description":"Published. The site is live.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string","enum":["published"]},"publishedAt":{"type":"number","description":"Publish time, Unix ms."},"pages":{"type":"number","description":"Keys in the published manifest."},"feedbackStored":{"type":"boolean","description":"False when the request carried no `feedback` (or the feedback sink is not configured). The publish succeeds either way."},"feedbackQueued":{"type":"boolean","description":"True when feedback was queued to the outbox because PostgREST was unreachable (0.74 M4)."},"integrationsPending":{"type":"integer","exclusiveMinimum":0,"description":"Count of unresolved clone integrations (non-blocking; 0.27.1 M3.2)."},"publicUrl":{"type":"string","example":"https://acme-studio.dotsy.site/"},"liveUrl":{"type":"string"},"liveReady":{"type":"boolean","enum":[true]},"collectionDataPending":{"type":"array","items":{"type":"string"},"description":"Collections whose data file did not finish building (`partial build`). The publish is live; republish to retry."},"collectionSqlPending":{"type":"array","items":{"type":"string"},"description":"Collections whose SQL index did not finish building. The publish is live; republish to retry."},"deprecationWarnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking notices about deprecated markup the publish found."},"formWarnings":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","enum":["dead_form_config","form_recipient_unverified"]},"formId":{"type":"string"},"contentKey":{"type":"string"},"path":{"type":"string"},"message":{"type":"string"}},"required":["code","formId","contentKey","message"]},"description":"Non-blocking problems with forms wired on the published pages."},"fragmentWarnings":{"type":"array","items":{"type":"string"},"description":"Pages that went live with a `<dotsy-fragment>` that has no `fragments/<name>.html`, so visitors see the raw tag. One line each, starting `fragment_not_found:`, with the pages and a likely name when one is close. Create the fragment or fix the name, then publish again."},"redirectWarnings":{"type":"object","properties":{"paths":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"}},"required":["code"]}}},"required":["path","issues"]}},"site":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"}},"required":["code"]}},"partial":{"type":"boolean","enum":[true],"description":"The page index scan hit its cap, so some redirect targets may have been misjudged."}},"required":["paths","site"],"description":"Non-blocking redirect advisories (chains, targets that are not pages, redirect count)."},"materializedDraftJobs":{"type":"number","description":"Pending import/bulk draft fragments folded into this publish."},"templateScanPartial":{"type":"boolean","enum":[true],"description":"The empty-collection template gate hit its key cap — listing pages beyond the first 1,000 scanned keys were not checked."},"postCommitWarnings":{"type":"array","items":{"type":"string"},"description":"Post-commit enrichment steps that failed. The publish is live; republish to retry them."},"ruleWarnings":{"type":"array","items":{"$ref":"#/components/schemas/RuleIssue"},"maxItems":20,"description":"Rules that failed on checked pages (block first). Present for a person's publish when the brief has Rules; agents get 422 instead of publishing past a blocking Rule."},"rulesCheck":{"$ref":"#/components/schemas/RulesCheck"},"sharedSectionSuggestions":{"allOf":[{"$ref":"#/components/schemas/SharedSectionNudge"},{"description":"Blocks that still repeat across pages and could become shared sections. Never blocks; absent when there are none (or when the site's index can't answer cheaply right after the publish)."}]}},"required":["ok","status","publishedAt","pages","feedbackStored","publicUrl","liveUrl","liveReady"]}}}},"400":{"description":"The body is not valid (`invalid_body`), or a gate blocked the publish: `purpose_required`, `empty_collection_template`, `redirect_issues`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"templateScanPartial":{"type":"boolean","enum":[true],"description":"`empty_collection_template` only: the gate's key cap truncated its scan, so some listing pages were not checked."}}}]}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The key or token cannot publish this site (viewer role, guest token, a key scoped to other sites, or AI access below publish (`ai_access_limited`, with `aiAccess` and a `hint`)).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site, or it is not visible to this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"Nothing was published: `adapt_active`, `import_in_progress`, `publish_conflict` (a concurrent publish won — retry once), `placement_version_conflict`, `brief_head_corrupt`, or `drafts_changed` (the staged set moved since `reviewedSet`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"jobId":{"type":"string","description":"`adapt_active` / `import_in_progress`: the job that is blocking the publish."},"changed":{"type":"array","items":{},"description":"`drafts_changed`: reviewed items whose staged content moved."},"added":{"type":"array","items":{},"description":"`drafts_changed`: items staged since the review."},"current":{"type":"object","additionalProperties":{},"description":"`drafts_changed`: the current draft status, so the client can re-render the diff in place."}}}]}}}},"422":{"description":"An experiment blocks the publish, stored page bytes are missing (`content_bytes_missing`), an agent hit a blocking Rule (`rules_failed`), or blocking Rules could not all be checked within the budget (`rules_not_fully_checked` — publish in batches with `keys` / `routes`).","content":{"application/json":{"schema":{"anyOf":[{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"keys":{"type":"array","items":{"type":"string"},"description":"`content_bytes_missing`: the pages whose stored bytes are missing. Re-write them, then publish again."}}}]},{"$ref":"#/components/schemas/PublishRulesError"}]}}}},"500":{"description":"`tenant_write_failed`: the publish committed but the serving index could not be updated twice. Publish again.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"version":{"type":"number","description":"`tenant_write_failed`: the version that committed."},"reason":{"type":"string","enum":["slug_moved"],"description":"`tenant_write_failed`: the site's address changed (or the site was removed) while publishing."}}}]}}}},"502":{"description":"The pre-publish hand-off note could not be saved (`handoff_append_failed`). Nothing was published; retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"The draft index could not be read (`draft_index_unavailable`). Retry the publish.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/import/url/preflight":{"post":{"operationId":"preflightImport","tags":["Import"],"summary":"Inspect a website before importing it","description":"Fetches a few sample pages of the source site and recommends a capture mode for each part of it. It changes nothing and does not need `{slug}` to exist yet. Run it before an import when you are not sure whether the site builds its content with JavaScript.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportPreflightRequest"},"examples":{"default":{"summary":"Inspect a site","value":{"sourceUrl":"https://example.com"}}}}}},"responses":{"200":{"description":"The recommendation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportPreflightResult"}}}},"400":{"description":"`sourceUrl` is missing (`invalid_preflight`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The role or token cannot use imports (guest token, viewer, read-only key, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/v1/sites/{slug}/import/url":{"post":{"operationId":"importSiteFromUrl","tags":["Import"],"summary":"Import a website from its URL into a draft","description":"Starts an import job that copies a website into a draft site at `{slug}`, creating the site. When you already own `{slug}` the import is refused (409 `slug_taken_self`) unless you send `onSlugTaken: merge`, which adds or updates the pages on that site. The call answers `202` straight away. Poll `GET /api/v1/sites/{slug}/jobs/{id}` with `job.id` until `status` is `succeeded` or `failed`, or watch `GET /api/v1/sites/{slug}/import-status`. Use the `slug` in the response from then on: it can differ from the one you sent.\n\nSend only `sourceUrl` to import the whole site: Dotsy finds the pages from the sitemap and the home page links. Everything lands in the draft and nothing is live until you publish. Guest tokens cannot import; use an account key.","security":[{"BearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/WorkspaceHeader"},{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"description":"What to import.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportSiteRequest"},"examples":{"wholeSite":{"summary":"Import a whole site","value":{"sourceUrl":"https://example.com"}},"blogSegment":{"summary":"Home page plus a blog from its own sitemap","value":{"sourceUrl":"https://example.com","segments":[{"pathPrefix":"/blog","sitemap":"/blog/sitemap.xml","maxPages":200}],"keyConvention":"directory"}},"mergeIntoOwn":{"summary":"Add pages to a site you already own","value":{"sourceUrl":"https://example.com","pages":[{"url":"/pricing","key":"pricing.html"}],"onSlugTaken":"merge"}}}}}},"responses":{"202":{"description":"The import is queued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportAccepted"},"examples":{"queued":{"summary":"A whole-site import","value":{"id":"9b2c7e1a-4f3d-4a6b-8c15-0d2e7f9a1b34","status":"queued","slug":"acme-studio","requestedSlug":"acme-studio","slugAdjusted":false,"pages":12,"warnings":[],"publicUrl":"https://acme-studio.dotsy.site/","liveUrl":"https://acme-studio.dotsy.site/","liveReady":false,"job":{"id":"import.9b2c7e1a-4f3d-4a6b-8c15-0d2e7f9a1b34.Qm9vdHN0cmFwSm9iSWRTaWc","kind":"import","status":"queued"}}}}}}},"400":{"description":"`sourceUrl` is missing (`invalid_import`), or the segments expanded to zero pages (`empty_import`, with `warnings`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The role or token cannot import into this site: a guest token (`guest_requires_account`), a viewer (`forbidden_role`), a read-only key (`forbidden_read_only`), a key scoped to other sites, an account that may only keep its claimed site (`guest_only`), or the Free plan's site limit for a new site (`free_site_limit`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"The slug cannot be used: `slug_taken`, `slug_taken_self`, `invalid_slug`, `reserved_slug`. Or a structure job is running on the site (`structure_active`).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"suggestions":{"type":"array","items":{"type":"string"},"description":"`slug_taken` with `onSlugTaken: fail`: free slugs to try instead."},"jobId":{"type":"string","description":"`structure_active`: the job that is blocking the import."}}}]}}}},"413":{"description":"The existing site is at its storage limit (`storage_limit_reached`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Too many imports are running (`import_capacity`). Retry when one finishes.","headers":{"retry-after":{"schema":{"type":"string","description":"Seconds to wait before retrying.","example":"60"},"required":true}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"},{"type":"object","properties":{"reason":{"type":"string","enum":["account_limit","global_limit"],"description":"`account_limit`: this account has its maximum number of imports running. `global_limit`: the platform is saturated."}},"required":["reason"]}]}}}}}}},"/api/v1/sites/{slug}/import/warm":{"post":{"operationId":"warmImportAssets","tags":["Import"],"summary":"Re-run the background asset copy for an import","description":"Starts the background job that copies an import's mirrored assets (images, fonts, scripts from other hosts) into Dotsy. Use it when an import's `externalAssets` ended `partial` or `aborted`, or after your plan's mirror limits changed. It is safe to repeat: while a copy is running it answers 200 `alreadyRunning` and starts nothing. The site must have been imported with `assetMirror: full`.","security":[{"BearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"example":"acme-studio"},"required":true,"description":"Site slug or site id (UUID). An old slug answers 301/308 and a site id 307 to the canonical slug path.","name":"slug","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WarmImportRequest"},"examples":{"default":{"summary":"Copy again","value":{"sourceUrl":"https://example.com"}}}}}},"responses":{"200":{"description":"A copy is already running. Nothing was started.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WarmImportRunning"}}}},"202":{"description":"The copy job started.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WarmImportStarted"}}}},"400":{"description":"`sourceUrl` is missing (`source_url_required`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"Missing or invalid credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"The role or token cannot use imports (guest token, viewer, read-only key, or a key scoped to other sites).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"No such site (`not_found`), or it has no asset manifest to copy from (`no_warm_manifest`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"Nothing to copy: mirroring is off, the plan's cap is reached, or every asset is already copied (`nothing_to_warm`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"Background jobs are not available on this server (`workflows_unavailable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}},"webhooks":{}}