# MooverApp Developers > Complete documentation for Large Language Models --- ## Document: For agents How a program — not a person — discovers, reads and calls the MooverApp platform and the sites it renders. URL: /agents # For agents Two different surfaces are useful to software here, and they are worth separating. **This portal** describes the API you build a site with; it is machine-readable because the documents it renders are generated from the service itself. **Every site the platform renders** carries its own agent surface — a read API, Markdown twins of its pages, an MCP endpoint and the discovery documents that lead to them — so an agent can read a customer's site without being told anything about it beyond the hostname. ## This portal - **OpenAPI 3.1, per surface.** The [Tenant API](/tenant-api) and the [Sign-up API](/site-api) each render one generated document, and each page offers it for download. It is the same file the platform's own contract tests read, from the same build as the running service — so a generator pointed at it produces a client for what is deployed, not for what somebody documented. - **Markdown twins.** Every prose page here answers at its own URL with `.md` appended — [/guides/quickstart.md](/guides/quickstart.md), [/concepts/errors.md](/concepts/errors.md) — with the frontmatter stripped. The suffix, rather than an `Accept` header, because the cache in front of this site does not vary on `Accept`. - **[/llms.txt](/llms.txt)** lists those pages for a model that wants the map before the territory, and [/llms-full.txt](/llms-full.txt) is the whole site in one file. - **An executable example.** [quickstart.sh](/quickstart.sh) is the script the guides quote region by region; it runs end to end, so it is a working example rather than a plausible one. - **A crawl map and a stated policy.** [/sitemap.xml](/sitemap.xml) lists every page this site renders, and [/robots.txt](/robots.txt) carries the site's Content Signals: search, AI input and AI training are all answered `yes` here. This is documentation written so that software calls the platform correctly — a model that has read it is the point, not a side effect. - **`Link` headers.** Every page names, in its response headers, its own Markdown twin, the sitemap and `/llms.txt`; every page rendered from an API document also names that document (`rel="describedby"`, `rel="service-desc"`). A client learns what else exists before parsing anything, and on a `HEAD` request — which is the cost the header exists to avoid. ## Every site the platform renders These come with the site — a tenant switches them on or off in their settings, and they need no code: - **A read API per view**, at `/api/v1/…` on the site's own domain (and under each language's path prefix), with the site's own **`/openapi.json`** describing it and `/api/health` reporting whether it is up. - **Markdown twins of every page**, at the `.md` suffix or by asking for `text/markdown`, with front matter carrying the canonical URL, locale, dates and authorship — the fields a citation needs. - **`/llms.txt`** per view: the site's pages and posts, each as a title, a `.md` link and a line of context. - **[`/.well-known/api-catalog`](https://www.rfc-editor.org/rfc/rfc9727.html)** — the one address an agent can try knowing only the hostname. It links each view's API description and status, and the MCP endpoint. - **An MCP server** at `/mcp`, with a server card at `/mcp/server-card`, offering read-only tools over the site's pages, posts and search. The tool list follows what the site actually has: no blog tools without a blog, no search tool without search. - **In-page tools for a browsing agent** (WebMCP), registered by the page itself, so an agent already inside the browser can search or read without a second protocol. - **Agent Skills** under `/.well-known/agent-skills/`: two documents per site — how to read it, and what to cite from it, including the site's own AI policy in the same words its `robots.txt` uses. - **`robots.txt` with Content Signals**, where the tenant's answer to search, AI input and AI training is stated rather than implied. Everything on that list is generated per site from its content and settings, which is the same rule this portal follows: one description, produced by the thing it describes. --- ## Document: Quickstart From an empty account to a page answering on a live site, in ten steps you can paste into a terminal. URL: /guides/quickstart # Quickstart Ten steps: register, wait for the site to be provisioned, take a token, create a block and a page, read the collection back, change it safely, look at a refusal, and watch the page appear on the site's own domain. Nothing here is a sandbox — these are the production hosts, and what you build in the next ten minutes is a real site. You need `curl` and [`jq`](https://jqlang.org/), and the whole thing is one file: [quickstart.sh](/quickstart.sh). Every block below is a region of it, so what you read is what runs. ```sh curl -O https://dev.moover.app/quickstart.sh && chmod +x quickstart.sh # with an account you already have: EMAIL=you@example.com PASSWORD=… ./quickstart.sh ``` ## 1. Is that address still free? Sign-up is the platform's only unauthenticated surface, so every call on it is bot-checked — including this one. From a terminal that means a CAPTCHA proof in `recaptcha_token`; from the browser it means the sign-up form at [moover.app](https://moover.app) does it for you. If you registered that way, skip to step 4 with the address and password you chose. ```sh title="quickstart.sh" curl_json -X POST "$SITE_API/site-api/v1/users:check_email_availability" \ -d "$(jq -n --arg email "$EMAIL" --arg captcha "$RECAPTCHA_TOKEN" \ '{email: $email, recaptcha_token: $captcha}')" | jq '{available}' ``` It is a question, not a reservation: the answer can go stale, and step 2 is what actually decides. ## 2. An account, and a site being built Creating the user creates the account and starts provisioning the first website, so the response carries three things the user resource itself does not have: the `account_id`, a one-time `signup_token`, and the name of the `operation` to poll. ```sh title="quickstart.sh" signup=$(curl_json -X POST "$SITE_API/site-api/v1/users" \ -d "$(jq -n \ --arg email "$EMAIL" --arg password "$PASSWORD" --arg website "$WEBSITE_NAME" \ --arg captcha "$RECAPTCHA_TOKEN" --arg request_id "quickstart-$(date +%s)-$$" \ '{email: $email, password: $password, website_name: $website, default_language: "en", recaptcha_token: $captcha, request_id: $request_id}')") signup_token=$(jq -r '.signup_token' <<<"$signup") ``` The call answers `201` with the resource, not a receipt — see [the standard methods](/concepts/methods) — and `request_id` makes it replayable: a timeout you retry with the same value gives back the first attempt's result instead of provisioning a second site ([idempotency](/concepts/idempotency)). ## 3. Waiting for the site to be provisioned The account exists the moment step 2 returns. The site does not: a database, buckets, routing, a view and the seeded content are still being made, and that work outlives the request that started it. So the call names an [Operation](/concepts/operations) and hands the polling to you. ```sh title="quickstart.sh" state='{"done": false}' until [ "$(jq -r '.done' <<<"$state")" = true ]; do sleep 2 state=$(curl_json "$SITE_API/site-api/v1/operations/$signup_token") jq -r '" " + (.metadata.step // "?")' <<<"$state" >&2 done # Done means finished, not succeeded: exactly one of `response` and `error` is there. jq -e '.error | not' <<<"$state" >/dev/null || { jq '.error' <<<"$state" >&2; exit 1; } SITE_URL=$(jq -r '.response.website_url' <<<"$state") ``` `metadata.step` is the hop the work is on. There is no percentage, because nothing in the cascade counts anything — a progress bar here would be an invention. ## 4. A bearer token Everything a site is made of lives behind `Authorization: Bearer `. Two ways to get one, and this is the ordinary way: the password the account signs in with. ```sh title="quickstart.sh" token=$(curl_json -X POST "$TENANT_API/tenant-api/v1/users:authenticate" \ -d "$(jq -n --arg email "$EMAIL" --arg password "$PASSWORD" \ '{email: $email, password: $password}')" | jq -r '.token') auth=(-H "Authorization: Bearer $token") ``` Straight after a signup there is a second way, which is what the platform's own dashboard uses so nobody has to retype the password they just chose: trade the one-time `signup_token` at `users:exchange_handoff`. See [authentication](/concepts/authentication). ## 5. Finding the website Three lists down the hierarchy the [resource names](/concepts/resources) spell out: an account owns projects, a project owns websites, and everything after this hangs off the website. ```sh title="quickstart.sh" account=$(curl_json "${auth[@]}" "$TENANT_API/tenant-api/v1/accounts" | jq -r '.accounts[0].id') project=$(curl_json "${auth[@]}" \ "$TENANT_API/tenant-api/v1/accounts/$account/projects" | jq -r '.projects[0].id') website=$(curl_json "${auth[@]}" \ "$TENANT_API/tenant-api/v1/projects/$project/websites" | jq -r '.websites[0].id') site="$TENANT_API/tenant-api/v1/websites/$website" ``` Every list on the platform answers in the same envelope, with the array named exactly after the collection it came from — one reader handles all three, and there is no per-endpoint map to keep. ## 6. A block, then a page A block is a fragment with a handle: pages, themes and rich-text links pull it in by its `identifier`, and editing it once changes it everywhere it appears. A page is what a URL resolves to. ```sh title="quickstart.sh" curl_json "${auth[@]}" -X POST "$site/cms_blocks" \ -d '{"title": "Store hours", "identifier": "store-hours", "is_active": true, "content": "

Open Monday to Friday, 9:00–18:00.

"}' >/dev/null url_key="hello-api" page=$(curl_json "${auth[@]}" -X POST "$site/cms_pages" \ -d "$(jq -n --arg url_key "$url_key" \ '{title: "Hello, API", url_key: $url_key, is_active: true, content: "

This page was created with three lines of curl.

"}')") jq '{name, id, create_time, etag}' <<<"$page" ``` Create answers `201` with the created resource and a `Location` header, so the client that just wrote something never has to read it back to learn what the server made of it — including the `etag` step 8 needs. ## 7. Reading the collection back Four parameters, the same four on every list on the platform. ```sh title="quickstart.sh" curl_json -G "${auth[@]}" "$site/cms_pages" \ --data-urlencode 'page_size=5' \ --data-urlencode 'order_by=create_time desc' \ --data-urlencode 'filter=is_active = true AND title:"Hello"' | jq '{total_size, next_page_token, titles: [.cms_pages[].title]}' ``` A field is filterable or sortable only where the resource says so, and the reference page for each resource lists which — an unknown field is an [`INVALID_ARGUMENT`](/concepts/errors) naming it, never a silently ignored URL that returns a full unfiltered page. [Lists, filters and ordering](/concepts/lists) has the grammar. ## 8. Changing it without overwriting somebody else `PATCH` merges: the fields in the body are written, everything else is left alone. The `etag` a resource carries is its version — send it back in `If-Match` and the write is refused if anybody has changed the page since you read it. ```sh title="quickstart.sh" etag=$(jq -r '.etag' <<<"$page") curl_json "${auth[@]}" -X PATCH "$site/cms_pages/$page_id" \ -H "If-Match: $etag" \ -d '{"meta_description": "Created from the quickstart."}' >/dev/null # That write minted a new etag, so the one above is now stale. Sending it again is what a # second editor's Save looks like: 409 ABORTED, with the current resource in the body. stale=$(curl_json "${auth[@]}" -X PATCH "$site/cms_pages/$page_id" \ -H "If-Match: $etag" -d '{"title": "Hello again"}' || true) jq '.error | {code, status, message}' <<<"$stale" ``` The second write is what a second editor's Save looks like: `409`, `ABORTED`, and the current resource in the body so the client can show what it lost to. [Concurrency](/concepts/concurrency) is the whole rule. ## 9. What a refusal looks like One shape for every failure on every surface, so a client needs one error reader. ```sh title="quickstart.sh" refused=$(curl_json -G "${auth[@]}" "$site/cms_pages" \ --data-urlencode 'order_by=content desc' || true) jq '.error | {code, status, message, details}' <<<"$refused" ``` `status` is the canonical code to branch on, `message` is for the human reading the log, and `details` is where a form finds which field to attach the message to. [Errors](/concepts/errors) has the codes. ## 10. The page, on the site Publishing is asynchronous. The write above returned as soon as the page was stored; the rendered site follows a moment later, when the read model behind it has been rebuilt. Polling for it is the whole of the difference. ```sh title="quickstart.sh" deadline=$(( $(date +%s) + LIVE_TIMEOUT_SECONDS )) until curl -sSf -o /dev/null $CURL_OPTS "$SITE_URL/$url_key"; do [ "$(date +%s)" -lt "$deadline" ] || { echo "$SITE_URL/$url_key never appeared" >&2; exit 1; } sleep 3 done echo "live: $SITE_URL/$url_key" ``` ## Where to go from here - The [Tenant API](/tenant-api) reference — every resource a site is made of, with the fields, filters and examples generated from the service itself. - [Concepts](/concepts/overview) — the rules every one of those endpoints obeys, so you can predict an endpoint you have not read yet. - [For agents](/agents) — what the site you just made offers a program that reads it. --- ## Document: Versions and compatibility What can change under a client without warning, what cannot, and where the version lives. URL: /concepts/versioning # Versions and compatibility The version is in the path, and it is `v1`. There is no header to negotiate, no date to pin, and no minor version: a URL that worked keeps working, or the version number changes. What that promise is worth is the list below — the changes that are forbidden inside a version, and the ones you should expect at any time. The second list is the one to build against: **a new field can appear in any response, and a new value in any enum**, so a client that rejects what it does not recognise will break on a release that broke nothing. Ignore unknown fields, and treat an unknown enum value as a value you do not handle rather than as an error. Watching for a change is reading the reference: the documents this site renders are generated from the running service, in the same build that produces it, so a release moves them both. There is no changelog that can be out of date with respect to the API, because there is no step between them. ## 18. Versioning and compatibility - The version is a path segment: `v1`, `v2`. No dotted or dated versions, no `Accept` header negotiation — the CDN in front of this API does not vary on `Accept` (a fact already paid for once, in the markdown-twin design). - Within a version, these are **breaking** and forbidden: removing or renaming a field, path or parameter; narrowing a type; adding a REQUIRED request field; changing a default; changing a status code for an existing condition; changing an id's format. - These are **non-breaking** and allowed any time: adding an optional request field, adding a response field, adding an enum value (clients tolerate unknowns, [§11](/concepts/json)), adding an endpoint, adding a `details` entry. - Removing anything takes a new version running side by side. A version is removed only when its last consumer is gone — verified by access logs, not by belief. - A field being retired is marked deprecated in the OpenAPI document with the replacement named, and keeps working for the life of the version. --- ## Document: Resources and names Everything on the platform is a named resource, and the name is both its identity and its URL. URL: /concepts/resources # Resources and names An account, a project, a website, a page, a block, a menu item, a media file, a setting: a persisted thing with an identity, addressed by a name that spells out where it sits. The name is the handle to store — not the URL, which carries a host and a version that may change, and not the `display_name`, which is a label a person edits. Given a name, the URL it is served at is a prefix away, which is why a client that keeps names can walk the hierarchy without a table of endpoints: ```sh title="quickstart.sh" account=$(curl_json "${auth[@]}" "$TENANT_API/tenant-api/v1/accounts" | jq -r '.accounts[0].id') project=$(curl_json "${auth[@]}" \ "$TENANT_API/tenant-api/v1/accounts/$account/projects" | jq -r '.projects[0].id') website=$(curl_json "${auth[@]}" \ "$TENANT_API/tenant-api/v1/projects/$project/websites" | jq -r '.websites[0].id') site="$TENANT_API/tenant-api/v1/websites/$website" ``` ## 5. Resources and resource names A **resource** is a persisted thing with an identity. A **collection** is a set of resources of one type, addressed by a plural `snake_case` id. A **resource name** is the path from the root collection to the resource, with no leading slash and no version: ``` websites/6a5e4957db9aab43d50bfd35 websites/6a5e4957db9aab43d50bfd35/cms_pages/507f1f77bcf86cd799439011 websites/6a5e4957db9aab43d50bfd35/cms_pages/507f1f77bcf86cd799439011/views/68b0c1 websites/6a5e4957db9aab43d50bfd35/settings/general.locale.default_language accounts/64f0.../projects/64f1... ``` Rules: - Collection ids are **plural**, `snake_case`, and identical to the path segment, the `name` field, and the List-response array field. One spelling. - Names **alternate** collection id and resource id, always starting with a collection id and ending with a resource id (a singleton ends with its singular collection id — [§9](/concepts/methods)). - A resource id is opaque to clients. Ours are Mongo ObjectIds, except `settings`, whose id is its dotted path, and `views`, whose overlay id is the view it overlays. - Every resource MUST carry its own name in an output-only `name` field, and MUST also carry `id` — the final path segment, output-only. `id` is a documented convenience (D3); `name` is the identity. ## 6. URL mapping A resource name maps to a URL by prefixing the surface and the version: ``` {surface}/{version}/{resource_name} ``` so `websites/{w}/cms_pages/{p}` is served at `/tenant-api/v1/websites/{w}/cms_pages/{p}`, and a collection is the same URL truncated after the collection id. - Path parameters MUST be `snake_case` and MUST match the field they carry: `{website_id}`, never `{websiteId}`. - A URL MUST NOT contain a verb as a path segment. Verbs are custom methods and use the `:` separator ([§10](/concepts/methods)). - A URL MUST NOT contain a file extension. - No trailing slash. A request with one gets `301` to the canonical form. --- ## Document: What the standard guarantees The one paragraph that describes every endpoint on the platform, the six places this API deliberately departs from Google's AIPs, and the index of what it adopts. URL: /concepts/overview # What the standard guarantees The reference documents every endpoint. These pages document the rules *all* of them obey — which is the more useful half, because it lets you predict an endpoint you have not read yet: what its URL looks like, what a write returns, how its list is paged and filtered, what its failures look like, and what it does when the work outlives the call. They are the platform's own API standard, the normative document its code is reviewed against, transcluded here at build time. Not a summary of it, and not a second copy: the same file, minus the sections that concern only the people implementing it — which is why the numbering below has gaps in it. The transport is ordinary REST over JSON — no protobuf, no transcoding, nothing a `curl` user cannot read. The semantics come from [Google's API Improvement Proposals](https://google.aip.dev), which is why an endpoint you have never seen behaves the way you expect it to. Where this API departs from them it says so, in the table of deviations below; their labels, D1 to D6, are how the rest of these pages refer to them. ## 1. The one sentence Every endpoint is one of **five standard methods** or a **named custom method** on a **resource** that has a **hierarchical name**, requests and responses are **snake_case JSON**, lists are **paginated with `page_size`/`page_token` and filtered with `filter`**, and every failure is a **`google.rpc.Status` body** with a canonical code. ## 3. Deliberate deviations from AIP These are permanent and load-bearing. Everything not listed here follows the AIP as written; when in doubt, the AIP wins. | # | AIP says | We do | Why | |---|---|---|---| | D1 | Collection ids and JSON fields are `lowerCamelCase` (AIP-122/140, proto3 JSON mapping) | **`snake_case` everywhere** — path segments, fields, query params, custom-method verbs | AIP-140 defines field names as `lower_snake_case`; camelCase is purely an artifact of proto3→JSON transcoding, which we do not do. Our models, our Mongo documents and our existing JSON are already snake_case. One spelling, end to end. | | D2 | Resource names carry the **full** ancestry (`accounts/a/projects/p/websites/w/cms_pages/c`) | Names are rooted at the **nearest globally-addressable ancestor**: `websites/{website}/cms_pages/{cms_page}` | Every id is a Mongo ObjectId and therefore globally unique, so the extra ancestry adds no addressing power, only length. `website.parent` carries the link upward for anyone who needs it. | | D3 | Fields that reference another resource hold its **resource name** (AIP-122) | References stay **bare ids** in `_id` / `_ids` fields, each documenting its resource type | 26 distinct reference fields across 33 models, all resolvable within a scope the URL already fixes. | | D4 | Create returns `200 OK` | Create returns **`201 Created`** with a `Location` header | The transport is REST, not transcoded gRPC. 201 + Location is what REST clients and caches already understand. | | D5 | Output-only fields sent on a write are silently ignored (AIP-203) | Ignored **by default**; a resource MAY declare a field `IMMUTABLE` instead and reject it with `INVALID_ARGUMENT` | Silent-ignore is the AIP rule and the default. Where the team has already decided a silent drop is a footgun (`menu_item.sort_order`), `IMMUTABLE` is the sanctioned escape hatch — it is explicit, not ad-hoc. | | D6 | `error.details` entries are `Any`-typed with `@type` URLs | `details` entries are plain objects with a `"type"` **string** discriminator from a closed set ([§13](/concepts/errors)) | No type registry to publish or resolve. Same information, readable without tooling. | ## 23. AIP index Adopted as written: 121 (resource-oriented design), 122 (names, except D2/D3), 126 (enums), 131–135 (standard methods, except D4), 136 (custom methods), 140 (field names, per D1), 142 (time), 143 (standardized codes), 148 (standard fields), 151 (operations), 154 (etags), 155 (request ids), 156 (singletons), 157 (partial responses, optional), 158 (pagination), 160 (filtering, subset per [§8](/concepts/lists)), 163 (validate-only), 180 (compatibility), 185 (versioning), 193 (errors, except D6), 203 (field behavior, except D5), 216 (states), 231/234 (batch methods). Not adopted: everything proto/gRPC-specific (191–192 file layout, 213 common components, `google.protobuf` wrappers), 127 (HTTP transcoding annotations), and the `Any`-typed error details of 193 (see D6). --- ## Document: Long-running operations What the platform hands back when the work outlives the request, and how to poll it to an answer. URL: /concepts/operations # Long-running operations Some work cannot finish inside a request: provisioning a site builds a database, buckets, routing and seeded content; a search reindex walks everything the site has. Rather than answer `202` and leave you guessing, those calls name an **Operation** — a resource you can poll, and the only honest answer to "is it done yet". ```sh title="quickstart.sh" state='{"done": false}' until [ "$(jq -r '.done' <<<"$state")" = true ]; do sleep 2 state=$(curl_json "$SITE_API/site-api/v1/operations/$signup_token") jq -r '" " + (.metadata.step // "?")' <<<"$state" >&2 done # Done means finished, not succeeded: exactly one of `response` and `error` is there. jq -e '.error | not' <<<"$state" >/dev/null || { jq '.error' <<<"$state" >&2; exit 1; } SITE_URL=$(jq -r '.response.website_url' <<<"$state") ``` Three habits make polling one of these correct: - **`done` means finished, not succeeded.** Exactly one of `response` and `error` is present once it flips, and the error is the ordinary [Status](/concepts/errors) shape. - **Read `metadata.step`, not a percentage.** The step is the named hop the work is on; where nothing counts anything, no number is reported rather than a made-up one. - **Cancelling is a request, not an event.** `:cancel` records it and returns the operation still running; the work stops at its next checkpoint, if it has one left, and you learn the outcome from `done` — a `CANCELLED` error means it stopped, success means it finished anyway. ## 17. Long-running operations (AIP-151) Anything that returns before the work is done MUST return an **Operation**, not a bare `202` and a hope. ```json { "name": "websites/6a5e.../operations/64ff...", "done": false, "metadata": { "type": "PROVISION_WEBSITE", "step": "SEEDING_CONTENT", "progress_percent": 40 } } ``` When `done` is `true`, exactly one of `response` (the resulting resource) or `error` (a `Status` body, [§13](/concepts/errors)) is present. - `GET {operation_name}` polls. `POST {operation_name}:cancel` requests cancellation; the operation is not cancelled until `done` is `true` with a `CANCELLED` error. - The initiating call returns `202 Accepted` with the Operation and a `Location` header pointing at it. - Operations are retained at least 24 h after completion. **An Operation is server-minted.** It has Get and `:cancel` and nothing else: a client cannot create one, and a List of every operation a resource has ever run is an audit log, which is a different resource. It carries no `etag` either — [§14](/concepts/concurrency) wants one where there are concurrent editors, and this resource has no editors: `:cancel` raises a flag, it does not submit a version. **Cancellation is a request, honoured at a checkpoint.** `:cancel` records it and returns the operation, still running. A worker consults the flag where it can still stop; past its last checkpoint the work finishes and the operation reports success — which the `done`/`CANCELLED` rule above makes visible rather than letting the caller assume the cancel took. `:cancel` on an already finished operation is `FAILED_PRECONDITION`: finished work cannot be un-finished. **`metadata` is service-specific and honest.** Emit `step` — the named hop the work is on — rather than a `progress_percent` nothing measures. `progress_percent` belongs in the example above and in a service that genuinely counts something. **A kick-off that also creates an addressable resource keeps its `201`.** §17 covers calls whose whole result is asynchronous. Where the resource exists by the time the response is written — a website whose `provisioning_status` is `PENDING`, a user whose site is still being built — the answer stays `201` + `Location` + the resource, plus an `operation` field naming what to poll. Downgrading that to `202` would take the resource's name away from the caller and hand back only a receipt for work. **And there is no `operation` field when the resource is what you poll.** A resource created to *be* one run of work — `ai_executions` is the platform's one — already carries the whole lifecycle an Operation would: a `state` that ends, an `error` when it ends badly, a `:cancel` that raises the same flag honoured at the same kind of checkpoint, and, unlike an Operation, a List that is its history. Minting an Operation beside it would publish two names for one run and two places to read whether it finished. --- ## Document: The five standard methods List, Get, Create, Update, Delete — and the named custom methods for the few things that are none of them. URL: /concepts/methods # The five standard methods There are five methods, and almost every one of the platform's endpoints is one of them on some resource. Learn them once and the reference stops being a list of endpoints and becomes a list of resources: five known shapes each. ```sh title="quickstart.sh" curl_json "${auth[@]}" -X POST "$site/cms_blocks" \ -d '{"title": "Store hours", "identifier": "store-hours", "is_active": true, "content": "

Open Monday to Friday, 9:00–18:00.

"}' >/dev/null url_key="hello-api" page=$(curl_json "${auth[@]}" -X POST "$site/cms_pages" \ -d "$(jq -n --arg url_key "$url_key" \ '{title: "Hello, API", url_key: $url_key, is_active: true, content: "

This page was created with three lines of curl.

"}')") jq '{name, id, create_time, etag}' <<<"$page" ``` ## 7. Standard methods Five, and only five, have HTTP-shorthand status. Everything else is a custom method with a name. | Method | HTTP | Success | Body in | Body out | |---|---|---|---|---| | **List** (AIP-132) | `GET {collection}` | 200 | — | list response ([§8](/concepts/lists)) | | **Get** (AIP-131) | `GET {name}` | 200 | — | the resource | | **Create** (AIP-133) | `POST {collection}` | **201** + `Location` | the resource | the created resource | | **Update** (AIP-134) | `PATCH {name}` | 200 | partial resource | the updated resource | | **Delete** (AIP-135) | `DELETE {name}` | 204 (hard) / 200 (soft) | — | — / the resource | Non-negotiable consequences of that table: - **Mutations return the resource, not a receipt.** The client that just wrote something needs the server's view of it — including server-generated fields, `update_time` and the new `etag`. - **`PATCH`, not `PUT`.** `PATCH` carries merge semantics: only the fields present in the body are written. That is what AIP-134 specifies. - **Field-mask deletes.** Merge semantics alone cannot clear a field: omitting it means "leave it". A client that must clear one sends `?update_mask=` with the field listed and the field absent (or `null`) in the body. `update_mask` is a comma-separated list of top-level field paths. When `update_mask` is absent, every field present in the body is written and nothing else is touched. - **Create id assignment.** Ids are server-assigned. A client that needs to choose one (imports, seeds, idempotent provisioning) passes `?id=` and the server either honours it or fails `ALREADY_EXISTS`. - **Get on a missing resource is `NOT_FOUND`, always.** Including when the parent scope does not exist, and including when the caller may not see it — a tenant probing another tenant's website id gets `NOT_FOUND`, never `PERMISSION_DENIED`, because the alternative confirms existence. Optional, per-resource, when the cost is justified: - `?validate_only=true` on Create/Update — run every check, write nothing, return the resource that *would* result. The admin's form-level validation is the use case. - `?allow_missing=true` on Update — upsert (AIP-134). Only where a caller genuinely cannot know whether the resource exists. **The CORS preflight has to admit these methods.** `PATCH` is not a CORS-"simple" method and neither `If-Match` ([§14](/concepts/concurrency)) nor `Authorization` is a simple header, so a browser sends none of them unless `Access-Control-Allow-Methods` / `-Headers` say it may — and when the preflight refuses, the request never leaves the browser: no status, no server log, nothing to read but a save that silently does nothing. ## 9. Singleton resources (AIP-156) Some things exist at most once per parent and have no id of their own: `websites/{w}/custom_domain`, `users/{u}/profile`. - The name ends with the **singular** collection id. - They support **Get** and **Update** only. No List, no Create, no Delete — creation is implicit in the first Update, and removal is a field or a custom method (`:disconnect`), never `DELETE` on the singleton. - Get on a singleton that has never been written returns the resource with default values, not `NOT_FOUND`, unless the resource documents otherwise. - A singleton carries **no `id`**. [§12](/concepts/json)'s `id` is the final segment of `name`, and a singleton's name has none — publishing its storage id under that field would break the one invariant `id` has. Everything else in [§12](/concepts/json) still applies: a stored singleton has `create_time`, `update_time` and an `etag`, and a never-written one omits the timestamps because there is nothing to date. **Settings are not a singleton — they are a collection** keyed by their dotted path: ``` GET /tenant-api/v1/websites/{w}/settings/general.locale.default_language PATCH /tenant-api/v1/websites/{w}/settings/general.locale.default_language GET /tenant-api/v1/websites/{w}/settings?filter=path:"general.locale" POST /tenant-api/v1/websites/{w}/settings:batch_update ``` `:batch_update` is AIP-234: it takes a list of resources, and it is **atomic** — all writes land or none do. Partial success is not a shape this API returns. ## 10. Custom methods (AIP-136) When an operation is not one of the five, it is a custom method: ``` POST {name}:{verb} ; acts on one resource POST {collection}:{verb} ; acts on the collection ``` - The separator is a colon, never a path segment. `…/menus/{m}/items/{i}:move`, not `…/items/{i}/move`. - The verb is `snake_case` (D1) and is a **verb**: `:move`, `:verify`, `:reindex`, `:batch_update`, `:undelete`, `:authenticate`. - Custom methods are `POST` unless they are side-effect-free reads that cannot be expressed as a List filter, which are `GET`. - Request and response bodies are ordinary JSON objects. A custom method that mutates one resource SHOULD return that resource. --- ## Document: Lists, filters and ordering One envelope, one set of paging parameters, and a small filter language — the same on every collection the platform serves. URL: /concepts/lists # Lists, filters and ordering Every collection reads the same way. Four parameters — `page_size`, `page_token`, `order_by`, `filter` — and one envelope, whose array is named after the collection it came from. ```sh title="quickstart.sh" curl_json -G "${auth[@]}" "$site/cms_pages" \ --data-urlencode 'page_size=5' \ --data-urlencode 'order_by=create_time desc' \ --data-urlencode 'filter=is_active = true AND title:"Hello"' | jq '{total_size, next_page_token, titles: [.cms_pages[].title]}' ``` Two things worth knowing before you write the query. **Which fields you may filter and sort by is per resource**, and the reference page for each one lists them: they are the fields with an index behind them, so a query cannot quietly table-scan the platform. And **an unknown field is refused**, not ignored — a typo comes back as an [`INVALID_ARGUMENT`](/concepts/errors) naming the field, rather than as a full unfiltered page that looks like an answer. For paging, `page_token` is the path to take: it survives rows being inserted while you walk. `skip` exists for a grid that has to jump straight to page 40, and it is capped. ## 8. Lists ### Response shape ```json { "cms_pages": [ { "name": "websites/6a5e.../cms_pages/507f...", "...": "..." } ], "next_page_token": "eyJvIjoxMDB9", "total_size": 137 } ``` - The array field is named **exactly the collection id** (AIP-132). Clients derive it from the last path segment; they do not need a per-endpoint map. - `next_page_token` is an **opaque** string. Empty or absent means "no more". Clients MUST NOT parse it; servers MAY encode an offset, a sort key, or a Typesense cursor in it, and MAY change that encoding without a version bump. - `total_size` is the count of the **whole filtered collection**, not of this page. ### Pagination | Param | Type | Meaning | |---|---|---| | `page_size` | int | Max results. Default **50**, server max **100**; a resource MAY lower its max. Over the max is clamped, not rejected. | | `page_token` | string | Opaque token from a previous `next_page_token`. | | `skip` | int | Results to skip before the page (AIP-158). | `skip` exists because the admin grid offers direct page jumps and cannot walk tokens to reach page 40. It is bounded (`skip + page_size ≤ 10000`; beyond that is `OUT_OF_RANGE`) because a deep Mongo `skip` is a linear scan. `page_token` is the path a batch consumer MUST take. ### Ordering `order_by=create_time desc, title` - Comma-separated `field [asc|desc]`, default `asc`, at most **3** keys. - Only fields the resource declares **sortable** are accepted; anything else is `INVALID_ARGUMENT`. A field is sortable only if it is backed by an index — this rule exists so that an admin clicking a column header cannot table-scan production. - Default order is per resource and MUST be documented and indexed. It is `create_time desc` unless the resource says otherwise. ### Filtering `filter=is_active = true AND title:"launch"` A deliberately small subset of AIP-160: ``` expr := term (("AND" | "OR") term)* ; AND binds tighter than OR term := ["NOT"] comparison | "(" expr ")" comparison := field op value op := "=" | "!=" | "<" | "<=" | ">" | ">=" | ":" field := ident ("." ident)* value := string | number | true | false | null | rfc3339-timestamp ``` - `:` is **has**: case-insensitive substring on a string, membership on an array (`visible_view_ids:"68b0c1"`). - Only fields the resource declares **filterable** are accepted; anything else is `INVALID_ARGUMENT` naming the field. Functions, wildcards and traversals into unindexed subdocuments are not supported. - A parse failure returns `INVALID_ARGUMENT` with the byte offset in `details[].field_violations[].description`. Free text has no special parameter: the client composes it, e.g. `filter=title:"foo" OR content:"foo"`. Ranked full-text search is a different thing with a different backend (Typesense) and is a custom method, not a filter ([§10](/concepts/methods)). --- ## Document: JSON representation How a body is spelled — snake_case, RFC 3339 timestamps, string enums — and the five fields every resource on the platform carries. URL: /concepts/json # JSON representation Bodies are boring on purpose, and the two rules worth internalising are these: **every name is `snake_case`**, everywhere — path segments, fields, query parameters, enum values, custom-method verbs — and **an unset field is absent, never `null`**. A response you can predict is a response you can parse once. The other half of this page is what every resource carries whatever it is: its `name` and `id`, when it was made and last changed, and the `etag` that makes a safe write possible. Each field also declares its *behavior* — required, output-only, immutable — and that declaration is in the reference, on the field, not in a paragraph somebody has to remember to update. ## 11. JSON representation - **Naming.** `snake_case` for every field, query parameter and enum value (D1). No abbreviations that are not already in the domain vocabulary. - **Absent vs null.** In a response, an unset optional field is **omitted**; `null` is never emitted. In a request, `null` means "clear this field" and is only honoured together with `update_mask` ([§7](/concepts/methods)). - **Booleans** are `true`/`false`. Never `1`/`0`, never `"true"`. - **Numbers** are JSON numbers. An `int64` that can exceed 2^53 is a string — none exist today, and if one appears it MUST be documented. - **Enums** are `UPPER_SNAKE_CASE` strings, never integers. Every enum reserves a zero-value `_UNSPECIFIED` and clients MUST tolerate an unknown value rather than failing (AIP-126) — that is what lets us add one without a version bump. - **Timestamps are RFC 3339 UTC strings**: `"2026-08-24T10:15:30Z"`. Not epoch milliseconds. - **Durations** are strings with a unit suffix (`"3.5s"`), per AIP-142. - **Dates without a time** are `"2026-08-24"`. - **Money** takes the `google.type.Money` shape — `{ "currency_code": "BRL", "units": "12", "nanos": 500000000 }`. Never a float, on the wire or behind it: the AI platform's balances and charges are int64 nanos of a currency all the way down, and the one place a fraction of a cent would be rounded away is named where it happens. - **Empty arrays are emitted**, empty objects are omitted. `visible_view_ids: []` is meaningful (it means "all views") and must survive a round trip. ## 12. Standard fields Every resource MUST carry: | Field | Behavior | Notes | |---|---|---| | `name` | OUTPUT_ONLY | Full resource name ([§5](/concepts/resources)). | | `id` | OUTPUT_ONLY | Final segment of `name` (D3). Absent on a singleton, which has no such segment ([§9](/concepts/methods)). | | `create_time` | OUTPUT_ONLY | RFC 3339. Renamed from `created_at`. | | `update_time` | OUTPUT_ONLY | RFC 3339. Renamed from `updated_at`. | | `etag` | OUTPUT_ONLY | Required on any resource with concurrent editors ([§14](/concepts/concurrency)). | And SHOULD use these names when the concept applies (AIP-148): `display_name`, `title`, `description`, `parent`, `delete_time`, `expire_time`, `uid`, `labels`, `annotations`. A resource MUST NOT invent a synonym for one of them. ### Field behavior (AIP-203) Every field is exactly one of: - **REQUIRED** — Create fails `INVALID_ARGUMENT` without it. - **OPTIONAL** — the default. - **OUTPUT_ONLY** — server-set; ignored if sent (D5). - **IMMUTABLE** — settable on Create, rejected with `INVALID_ARGUMENT` on Update. - **INPUT_ONLY** — accepted, never echoed (passwords, one-time tokens). The behavior is part of the contract and MUST appear in the generated OpenAPI document. It is not a comment. --- ## Document: Idempotency How to retry a call that provisions or sends mail without doing it twice. URL: /concepts/idempotency # Idempotency A `POST` that timed out is the one call you cannot safely repeat: the connection dropped, and nothing in the response tells you whether the work happened. `request_id` — any string you choose, per attempt — turns that repeat into a replay. ```sh title="quickstart.sh" signup=$(curl_json -X POST "$SITE_API/site-api/v1/users" \ -d "$(jq -n \ --arg email "$EMAIL" --arg password "$PASSWORD" --arg website "$WEBSITE_NAME" \ --arg captcha "$RECAPTCHA_TOKEN" --arg request_id "quickstart-$(date +%s)-$$" \ '{email: $email, password: $password, website_name: $website, default_language: "en", recaptcha_token: $captcha, request_id: $request_id}')") signup_token=$(jq -r '.signup_token' <<<"$signup") ``` Send the same value again within the retention window and you get **the first attempt's result**, not a second site. Generate it before the first try, keep it with whatever you are retrying, and change it only when you genuinely mean "do this again". Which calls accept one is in the reference, on the request body — it is the calls where doing the work twice costs somebody something: provisioning, invitations, mail. ## 15. Idempotency (AIP-155) Create and any mutating custom method MAY accept `request_id` (a client-chosen UUID). A repeat within the retention window (24 h) returns the **original** result instead of acting again. Required for anything that provisions, sends mail, or charges — signup, invitations, provisioning kick-off. **The key lives on the record the work is tracked by** — on the Operation ([§17](/concepts/operations)) where the work has no resource of its own, and on that resource where it has one (`ai_execution.request_id`). Most kick-offs that need idempotency hand work off and already mint an Operation, so keying replay off that record is one mechanism instead of one bespoke field per entry point; where the call instead creates the resource the work *is* — one that carries its own `state`, is cancelled and retried by its own custom methods and is listed as history — that resource is minted before any of the work, and a second Operation beside it would be a second description of one lifecycle. Either way the key is written **before** the work it protects, so a call that dies half-way still leaves the retry something to find. The column is unique, so a second call that arrives while the first is still in flight loses the insert and is answered `ABORTED`: retry, and the retry takes the replay path. `request_id` is INPUT_ONLY on the kick-off and appears in no response. --- ## Document: Errors, and how to read them Every failure on every surface is one shape — a canonical status to branch on, a message for the log, and typed details a form can attach to a field. URL: /concepts/errors # Errors, and how to read them Write the error reader once. Every non-2xx on every surface of the platform is the same object, so a client needs one function to read a refusal, and it works against endpoints nobody has written yet. ```sh title="quickstart.sh" refused=$(curl_json -G "${auth[@]}" "$site/cms_pages" \ --data-urlencode 'order_by=content desc' || true) jq '.error | {code, status, message, details}' <<<"$refused" ``` Three parts, three audiences. **`status`** is the canonical code and the only part to branch on — it is stable, while the HTTP status alone cannot tell an `ALREADY_EXISTS` from a lost race. **`message`** is written for the person reading the log, in English, and never carries internal detail; do not parse it and do not show it to an end user. **`details`** is where a form finds what to underline: a `BAD_REQUEST` entry lists field violations by their path in the request body you sent, and a `REQUEST_INFO` entry carries the reference id to quote when you report a `500`. Two codes surprise people, and both are deliberate. A resource you may not see is `NOT_FOUND`, never `PERMISSION_DENIED` — the alternative would confirm that it exists. And a `409` is two different things: `ALREADY_EXISTS` if the id or unique key is taken, `ABORTED` if somebody wrote first and your [`If-Match`](/concepts/concurrency) is stale. ## 13. Errors One shape, for every non-2xx, on every surface: ```json { "error": { "code": 400, "status": "INVALID_ARGUMENT", "message": "url_key 'search' is reserved for the search page.", "details": [ { "type": "BAD_REQUEST", "field_violations": [ { "field": "url_key", "description": "reserved for the search page" } ] }, { "type": "REQUEST_INFO", "request_id": "0f3a…" } ] } } ``` - `code` is the HTTP status, `status` is the canonical code, and they MUST agree with the table below. - `message` is for a developer reading a log — English, no trailing period required, and **never** raw internal detail: an exception `what()`, a repository error, a mail-send failure or a storage path never reaches a client. - `details` is an array of typed objects, `type` drawn from a closed set (D6): `BAD_REQUEST` (with `field_violations`), `PRECONDITION_FAILURE`, `QUOTA_FAILURE`, `RESOURCE_INFO`, `RETRY_INFO`, `REQUEST_INFO`, `LOCALIZED_MESSAGE`. Adding a type is a standard change, not a local one. - `field_violations[].field` is a **field path in the request body** (`items[2].url_key`), so a form can attach the message to an input without parsing prose. | Canonical status | HTTP | Use for | |---|---|---| | `INVALID_ARGUMENT` | 400 | Malformed request, bad filter, unknown sortable field | | `FAILED_PRECONDITION` | 400 | Request valid, system state forbids it | | `OUT_OF_RANGE` | 400 | `skip` past the cap, `page_size` negative | | `UNAUTHENTICATED` | 401 | Missing/expired/invalid token | | `PERMISSION_DENIED` | 403 | Authenticated, not allowed — and existence is already known to the caller | | `NOT_FOUND` | 404 | Absent, **or** present but invisible to this caller | | `ALREADY_EXISTS` | 409 | Create with a taken id or unique key | | `ABORTED` | 409 | `etag` mismatch ([§14](/concepts/concurrency)) | | `RESOURCE_EXHAUSTED` | 429 | Rate limit, quota. MUST carry `RETRY_INFO` | | `CANCELLED` | 499 | Client disconnected | | `INTERNAL` | 500 | Bug. MUST carry `REQUEST_INFO` | | `NOT_IMPLEMENTED` | 501 | Declared but unbuilt | | `UNAVAILABLE` | 503 | Dependency down, shedding load | | `DEADLINE_EXCEEDED` | 504 | Upstream timeout | --- ## Document: Concurrency, etags and caching How to write without silently overwriting another editor, and how to re-read something without paying for the body twice. URL: /concepts/concurrency # Concurrency, etags and caching A site has more than one editor — a person in the admin, your integration, a scheduled job — and the default outcome when two of them save the same page is that the later write wins and nobody is told. The `etag` every resource carries is how you opt out of that. Read it with the resource, send it back in `If-Match`, and a write that lost the race is refused instead of applied: ```sh title="quickstart.sh" etag=$(jq -r '.etag' <<<"$page") curl_json "${auth[@]}" -X PATCH "$site/cms_pages/$page_id" \ -H "If-Match: $etag" \ -d '{"meta_description": "Created from the quickstart."}' >/dev/null # That write minted a new etag, so the one above is now stale. Sending it again is what a # second editor's Save looks like: 409 ABORTED, with the current resource in the body. stale=$(curl_json "${auth[@]}" -X PATCH "$site/cms_pages/$page_id" \ -H "If-Match: $etag" -d '{"title": "Hello again"}' || true) jq '.error | {code, status, message}' <<<"$stale" ``` The refusal is a `409` with `ABORTED` and **the current resource in the body**, so a client has everything it needs to show the conflict — or to merge and retry — without a second request. Without `If-Match` the write is unconditional, which is fine for a field only you own and wrong for anything a person also edits. The same value works on the way in: a `GET` with `If-None-Match` answers `304` when nothing changed, so a poller pays for headers rather than for a body. ## 14. Concurrency and etags - A resource with concurrent editors MUST expose an `etag`, computed over its persisted state. - A client MAY send `If-Match: ""`. If the stored etag differs, the write fails `ABORTED` (409) and the response body carries the current resource. - A resource MAY require `If-Match` on Update and Delete. It MUST document which. - `etag` MAY also be echoed as an HTTP `ETag` header. The field is the contract; the header is a convenience. ## 16. Caching and conditional reads - A `GET` with `If-None-Match` matching MUST return `304` with no body. - Mutating responses are `Cache-Control: no-store`. --- ## Document: Authentication One bearer token, where to get it, and what the platform answers when it is missing, expired or not enough. URL: /concepts/authentication # Authentication There is one credential: a JSON Web Token in an `Authorization` header. No API keys, no tokens in query strings, no cookies — an API call carries its own credential, and nothing about it depends on a browser session. ```sh title="quickstart.sh" token=$(curl_json -X POST "$TENANT_API/tenant-api/v1/users:authenticate" \ -d "$(jq -n --arg email "$EMAIL" --arg password "$PASSWORD" \ '{email: $email, password: $password}')" | jq -r '.token') auth=(-H "Authorization: Bearer $token") ``` The token is a user's, and it reaches the accounts that user is a member of. A website id in an account you do not belong to answers `NOT_FOUND`, not `PERMISSION_DENIED`, because refusing by name would confirm that the site exists; inside your own account, a section your role does not cover answers `PERMISSION_DENIED`. The rules — membership, role, section, and the account's owner — are below. **Straight after a signup there is a second way in.** The registration response carries a one-time `signup_token`; trading it at `users:exchange_handoff` returns a session token, which is how the platform's own dashboard opens already signed in without asking for the password that was just chosen. It works exactly once. **Sign-up is the one surface that needs no token at all**, and that is why every call on it is rate-limited and bot-checked at the edge — a CAPTCHA proof travels with it. Nothing behind that surface can read another tenant's data. Two operational notes for a long-lived integration. A token expires: treat a `401` (`UNAUTHENTICATED`) as "re-authenticate and retry once", not as a failure to report. And in a browser, the preflight matters — `PATCH`, `Authorization` and `If-Match` are none of them "simple", so a client that sees no response at all is usually looking at a refused preflight rather than a refused request. ## 19. Authentication - `Authorization: Bearer ` on every authenticated surface. No API keys, no tokens in query strings, no session cookies for API calls. - Missing or unparsable credentials → `UNAUTHENTICATED` (401). Valid credentials without the right → `PERMISSION_DENIED` (403), **except** where 403 would reveal existence, which is `NOT_FOUND` ([§7](/concepts/methods)). - Every tenant-scoped route MUST verify the caller against the account that owns the scope in the path — `accounts/{account}`, `projects/{project}` or `websites/{website}`. Scoping by path alone is not authorization. - `/site-api` is unauthenticated by design and MUST be rate-limited and bot-checked at the edge; nothing behind it may read another tenant's data. ### Authorization on the tenant API A token names a **user**, and a user reaches an account by being a **member** of it. What a member may do there is decided in three steps, in this order, on every request: 1. **Membership.** The route's scope — an account, a project or a website — is resolved to the account that owns it, and the caller must be a member of that account. A caller who is not gets `NOT_FOUND` on the scope's name: the same answer a scope that does not exist gets, so an id belonging to someone else's account confirms nothing ([§7](/concepts/methods)). Routes without a scope — the caller's own `profile`, the list of their `accounts`, the catalogues — need only a valid token. 2. **Role.** Each membership holds exactly one role: `roles/admin` (every section, to change), `roles/editor` (every section except managing who has access), `roles/sections` (only the grants written on the membership itself), or one of the account's own roles, `accounts/{account}/roles/{role}` — a named set of grants. A **grant** is `{permission, level}`: a section of the permission catalogue (`GET permissions`) and `READ` or `WRITE`, where `WRITE` includes `READ` and a grant on a section covers every section below it. A section may declare `implies` — grants that come with it, so that holding blog posts on `WRITE` also lets the editor upload the post's image. 3. **Section.** Every scoped route belongs to one section, or is declared open to any member (the scope's long-running operations) or to the owner alone. Reading (`GET`) takes `READ` on the section, any other method `WRITE`; a custom method that only reads is declared as a read. A section marked `readable_by_all_members` — everything under a website, the account's settings, its projects — is readable by every member whatever the role, so pickers of media, pages or categories work for everyone; a grant on it decides what the member may change. Access management, the account's AI and its billing are read only with a grant. A member whose role does not reach the route gets **`PERMISSION_DENIED`** (403, [§13](/concepts/errors)): they belong to the account and already know it exists, so refusing by name gives nothing away. `NOT_FOUND` is kept for the caller who is not a member at all. What a member actually holds — the role's grants with `implies` followed — is `effective_grants` on their own membership, `accounts/{account}/members/{their user id}`, which every member may read. Rights are read afresh on every request and are not carried in the token: a changed role or a removed member takes effect on the very next call, and no token has to be revoked. **The owner.** An account that has members has exactly one owner, named by the account's `owner` field (`users/{user}`); the first member of an account nobody belongs to becomes its owner. Accepting an invitation never makes anybody the owner: the new membership holds the role the invitation names, no more. The owner holds every section whatever the membership's role says, and two things are the owner's alone: deleting the account and handing it to another member (`accounts/{account}:transfer_ownership`). The owner's membership can be neither removed nor changed — `FAILED_PRECONDITION` — until the account has been handed over; the previous owner then stays a member with `roles/admin`. **Nobody gives more than they hold.** Assigning a role or grants — to a member, an invitation or an account role — that reach further than the caller's own rights is `PERMISSION_DENIED`, naming the first grant too many. The owner and `roles/admin` are never refused. ### Customers of a website A website's **customers** — the visitors who register and sign in on the tenant's site, `websites/{website}/customers` — are not callers of this API. Their session lives between their browser and the site: HttpOnly cookies the site sets on its own domain when they sign in (`Secure`, `SameSite=Lax`), renews while they browse and clears when they sign out. The API itself stays stateless and bearer-only. The tenant API manages customers and their groups under the tenant's token like any other resource, and never accepts a customer's session in place of that token: a customer's credential presented to it is `UNAUTHENTICATED`. A session belongs to one website. It opens nothing on another website — not even one of the same account — and the same address on two websites is two customers, each with a password of its own. Anything a site renders for a signed-in customer is answered `Cache-Control: private, no-store`, so no shared cache keeps it; a visitor without a session is served exactly what every other anonymous visitor is. --- ## Document: Build your site on the MooverApp API MooverApp is an API-first platform — accounts, websites, pages, blog, media, views and settings are all resources you create and change from your own code. URL: / # Build your site on the MooverApp API import { LandingPage } from "zudoku/components"; import { BotIcon, LayoutTemplateIcon, TerminalIcon } from "zudoku/icons"; {/* `mvr-landing` is what src/portal.css hides the frame's own

by: the hero carries the page's headline, and the frontmatter title above it is the same page announcing itself twice. The title itself stays — it is the browser tab, the .md twin's heading and the llms.txt entry. */} Your store, assembled by API calls} description="Accounts, websites, pages and blocks, blog, media, themes, views and settings — every one of them a resource with the same five methods. The admin interface is written against exactly this surface, so there is nothing it can do that your code cannot." actions={[ { label: "Quickstart", href: "/guides/quickstart" }, { label: "Tenant API", href: "/tenant-api", variant: "outline" }, ]} features={[ { icon: , title: "First call in five minutes", description: "Ten steps from an empty account to a page on a live domain, as one script you can run.", href: "/guides/quickstart", }, { icon: , title: "Build a storefront", description: "Pages, blocks, menus, blog, media, themes and per-language views — the whole reference, generated from the running service.", href: "/tenant-api", }, { icon: , title: "Connect an agent", description: "Every site the platform renders ships a read API, Markdown twins, an MCP endpoint and a discovery catalog.", href: "/agents", }, ]} /> ## What you can rely on Every surface follows one standard: five standard methods on named resources, `snake_case` bodies, the same paging, filtering and ordering parameters on every list, one error shape, `etag`/`If-Match` for concurrency, and an Operation to poll whenever the work outlives the call. Learn it once on one resource and you can predict the next one — which is what [Concepts](/concepts/overview) is for. ## Why this page cannot go stale This documentation is generated from the declarations the running service is built from, in the same build that produces the running service, and shipped by the same release. There is no synchronisation step to forget: the description of an endpoint and the code that answers it are one commit. Rename a field and the page describing it changes with it — or the build fails. The requests in the guides are the same thing in the other direction: they are regions of [one script](/quickstart.sh) that is executed, not an illustration typed into a page.