# CloudForge API v1 - Operator Guide

Purpose: everything a person or an AI operator needs to drive CloudForge (the multi-tenant hosting
and website platform) through its HTTP API on behalf of a signed-in user, without reading the
source. Verified against **production 2.6.12** (commit 9826152) on 2026-09-21 with a 463-check
read-scope pass and a schema validation of live responses (scripts in section 9); the module
platform polish of 2026-09-24 (BL-035 - BL-041) is verified against the repository's own test
suite on that day. Last verified against the repository (source and tests) on **2026-10-12**
for the edge request-body caps (§4 "Bodies", §8) and the forward destination policy (security
audit M13, §8); before that on 2026-09-25
for the core API / single sign-on / analytics change (below and §6, §8), for the
custom-domain reachability verification (BL-092, below and §7 "Domains", §8), for the hub
packages declaring themselves (BL-087, §7 "Modules", §8) and for the page-status rule (BL-117,
§7 rule 3, §8), and on **2026-09-26** for the module webhook events (BL-140, §7 "Webhooks") and
the cancelled-order rule (BL-144, §8) and the tenant sitemap (BL-150, §7 "SEO"), and on **2026-10-03** for the site-version
publish state (`publishState` on page-publish and menu-write responses, `activate: true` on
`POST …/website/versions`; §7 rule 3, §8), nested page slugs (`services/security`, §7 "Create a page"), the
update-job `version` rule (`POST /updates/jobs`, §7 "Platform", §11), security batch A (audit needs `tenant.audit.view`,
`PUT /settings` follows the platform settings registry, `POST …/members` no longer re-roles an existing member it may not
touch, token-minted tokens are capped by their parent; §7, §8), and the forward-target checks and attachment-only website
file downloads (security batch B, §8), and on **2026-10-05** for the primary-domain rule (BL-160: the system
domain is never primary and an unverified domain never becomes primary, §7 "Domains", §8, §11) and for the
`orbital-hero` and `process-steps` page blocks (§7 "Block shapes"), and on **2026-10-06** for the domain
connect guidance (`connect.cname.target` = the tenant's platform address, `connect.apex` /
`connect.recommended`, and the www / apex `sibling` in the verify answer; §7 "Domains", §8, §11), and on **2026-10-12** for
the 7-day draft-preview token (security batch E: `previewToken` expires and is re-minted by `GET …/pages/{id}`; §7 rule 3,
§8). This revision describes
**2.6.16 (unreleased)** - `main` after v2.6.13, changed 2026-09-24 by the tenancy hardening PR (BL-021: a tenant you are not a member of is
`404 tenant_not_found`, never `403 not_a_member`) - which fixes the defects that pass found
(see §8); until it ships, production still shows the pre-fix behaviour noted there.
The 12 **Analytics** reads (§7, §11; Website Traffic Analytics v2 B2, 2026-09-21), the seven
**Analytics** configuration operations (B3, 2026-09-22: `GET|POST …/analytics/segments`,
`PUT|DELETE …/segments/{id}`, `POST …/funnels`, `PUT|DELETE …/funnels/{id}`, behind the new
`analytics.manage` capability), the
**module licence fields** on `/tenants/{id}/modules` (the Catalogue, 2026-09-21), the two
**Routing** operations (`GET|PUT …/website/routing`, website platform v3 wave V3-1, 2026-09-21)
the 10 **Backups** operations (tenant export / restore / GitHub backup, 2026-09-21) and the 4
**Templates** operations (`GET /templates`, `…/{slug}`, `…/{slug}/previews/{file}`,
`POST /tenants/{t}/website/templates/{slug}/install`; approved site templates, 2026-09-22) are
documented from the source and unit tests and are on the dev instance first - re-run the §9
scripts against production once they are promoted. The seven **Webhooks** operations plus the
public event catalogue (`GET /v1/webhooks/events`; outbound webhooks, `docs/design/api-webhooks.md`,
2026-09-22) are likewise documented from the source and tests and land on dev first, as is the
**module platform v2** change to `/tenants/{id}/modules` (the price, licence status and pending
request on every row) and the new `POST|DELETE …/modules/{moduleId}/requests` (2026-09-23,
`docs/design/module-platform-v2.md`; review fixes 2026-09-24 - the API Management gate on
`…/requests`, locale-formatted `cost.display`). The 15
**Single sign-on** operations (`/tenants/{t}/sso/**`: the tenant's OpenID Connect provider,
connected apps, portal apps, keys and the provisioning user list; the `sso` module, M3,
2026-09-23) and the four provisioning webhook events are documented from the source and tests
and land on dev first.

**Website status** operations (`GET /status`, `/tenants/{t}/status`, `…/status/check`,
`…/status/history`, `/system/status/tenants`; the uptime / IsOnline checker, 2026-09-24) and the
`tenant.status.changed` webhook event are documented from the source and tests and land on dev
first, as are the two **spoke settings** operations (`GET|PUT /tenants/{t}/modules/{m}/settings`,
BL-027, 2026-09-24 - the second caller of the `GET` is the spoke itself with its edge secret; §7
"Modules"). The wave-A follow-ups of the same day (BL-042 - BL-054): every verified domain is checked
when the tenant asks (`domains[]` on the status, `isPrimary` on every row and on the webhook, a
`domain=` filter on the history, `checks[]` on the manual check), an offline flip also e-mails
the owners and tells platform staff, and module requests are webhook events
(`module.request.created` / `.decided`).

**2026-09-25 - the API, single sign-on and analytics are part of the core.** Every tenant has
them; none is a module any more (no licence, price, request or switch; `GET /tenants/{t}/modules`
lists them as core, always on). What changed for a client: there is **no API Management gate** -
`403 api_disabled_for_tenant` is gone, and a token reaches a tenant as far as the person's
membership and capabilities go (§5, §6); `apiEnabled` on `/me` and `/tenants/{t}` is always
`true` (kept for existing clients); the Analytics, Webhooks and Single sign-on routes no longer
answer `module_disabled` for their own module. Single sign-on is still switched **per tenant**,
by the owner: `PUT /tenants/{t}/sso {"enabled": true|false}` (`tenant.settings.edit`; off by
default) - while it is off the tenant's `/oidc` endpoints, its discovery document, the portal
"Systems" list and a connected app's client-credential token answer `404`; the configuration
routes work either way. Documented from the source and tests; lands on dev first.

**Custom-domain verification is by reachability** (BL-092, 2026-09-25; §7 "Domains", §8): there is
no TXT record any more. `…/domains/{d}/verify` returns `connect` (the CNAME / A / AAAA to point
the domain at) and `checks[]` per step (`dns`, `http`, `https`); the platform proves the domain's
traffic reaches it by fetching `http://<domain>/.well-known/cloudforge-verify/<token>`. Documented
from the source and tests; lands on dev first.

Building or changing the **website itself** (a contact form, sign-in, a members area)
is a different document: the **Site Extensions guide**,
`https://www.cloudforge.co.za/cloudforge-site-extensions-guide.md` - the `<cf-…>` web components
every site can use, how paths are protected, and how the block builder exposes the same
extensions. This guide covers the API that manages tenants, pages, files and settings.

The API's own documents remain authoritative for shapes and grow with the platform:
`GET <base>/openapi.json` (OpenAPI 3.0.3, 150 operations) and `GET <base>/claude.md` (behaviour
and safety). Prefer them over this file when they disagree, except for the places in §8 where
the live API is known to differ from those documents.

---

## 1. Instances

| Instance    | Base URL                                                                           | Notes                                                                |
| ----------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Production  | `https://api.cloudforge.co.za/v1`                                                  | Live customers. Every write reaches a real website.                  |
| Development | `https://api.dev.cloudforge.co.za/v1` (also `https://dev.cloudforge.co.za/api/v1`) | Disposable database. Test here first when the tenant exists on both. |

Separate systems, separate tokens, separate ids. Never assume an id, slug or token from one
exists on the other. Always say which instance you are on when you report.

## 2. Token

- The person creates a **personal access token** in the CloudForge UI: Portal → Profile → API
  tokens (name, scope `read`/`write`, expiry, optional tenant restriction). It starts with
  `cfp_` and is shown once.
- Send it on every request: `Authorization: Bearer cfp_…`.
- The token **is the person**. Every call runs with their roles and is audited under their name
  with the token id.
- Take the token from the person or from the environment variable `CF_TOKEN`. Never print it,
  never put it in a URL, never write it into a file, never send it to any host but the two above.
  If it was pasted into the conversation, tell the person to revoke it when the work is done.
- `read` scope cannot write (`403 token_read_only`). Ask for a `write` token when the task needs
  one; do not work around it.
- A token can also be restricted to certain tenants (`403 tenant_not_allowed_for_token`
  elsewhere). `GET /me` shows `token.scope` and `token.tenantIds`.
- A tenant the person is **not a member of** does not exist as far as the API is concerned:
  `/tenants/{id}/…` answers `404 tenant_not_found` for it, the same answer an unknown id gets, so
  a tenant id can never be confirmed from outside (Standard S2). Work from `GET /me` and
  `GET /tenants` - they list the tenants the person can reach - rather than probing ids.

## 3. Start every session the same way

1. `GET /me` → the person (`id`, `platformRole`, `isPlatformAdmin`), the token (`scope`,
   `tenantIds`), and `tenants[]` (only the tenants the person is a **member** of, each with
   `role`, `capabilities`; `apiEnabled` is always `true` since 2026-09-25). A platform admin is usually a member of very few
   tenants; find the rest with `GET /tenants?limit=100` (paginate on `page.total`).
2. `GET /openapi.json` when you need exact request or response shapes.
3. Confirm **which tenant** you are working on, by name and slug, before any tenant call. Ids are
   opaque; slugs are the human names.
4. Check the person's `capabilities` on that tenant (from `/me`) cover what the task needs.
   There is nothing to switch on first: the API is part of every tenant (§6).

## 4. Conventions

- Success: `{"data": …}`. Paginated lists: `{"data": [...], "page": {"page", "limit", "total"}}`
  (tenants, users, pages, media, members, audit, module records).
- Failure: `{"error": {"code", "message", "details"?}}`. Show `message` to the person.
- Pagination `?page=1&limit=50`; default 50, maximum 200. Out-of-range values are **clamped**,
  not rejected (`limit=500` → 200, `page=0` → 1). Sorting where offered: `?sort=field&order=asc|desc`.
- Times are ISO-8601 UTC. Ids are strings of mixed formats (cuid, uuid, `sd_…`, `dom_…`);
  treat them as opaque.
- `Idempotency-Key: <uuid>` on a `POST` makes a retry safe: same key + same body returns the first
  result; same key + different body is `409`.
- Rate limit: **600 requests per minute per token**, counted the moment the token is recognised.
  Every response charged to the token - success, handler error, and every pipeline refusal from
  `token_read_only` onward (403, 404, 429, 500) - carries `X-RateLimit-Limit`,
  `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds when the window resets). A `401
unauthenticated` is sent before any token is known and carries none, as do the discovery
  endpoints (`/v1`, `/openapi.json`, `/claude.md`). A `429` adds `Retry-After`. Wait once; if it
  repeats, stop and say so. (Before 2.6.14 only handler responses carried the headers; see §8.)
- Session access (2.6.14): the Admin → Website screens in the CloudForge UI call this same API
  with the browser session - a request with **no** `Authorization` header from a signed-in
  browser is authenticated as that user, with the same capability checks, a rate limit keyed
  per user (`user:<id>`, same 600/min, same `X-RateLimit-*` headers), audit rows under the user
  (`via: "session"`) - the same rules a token gets. Tokens
  are for everything outside the browser: scripts, AI operators, other systems. A request that
  carries an invalid or revoked bearer is `401` even in a signed-in browser - it never falls
  back to the cookie; the API host (`api.<hub>`) never reads a session at all; and a session
  `POST`/`PUT`/`PATCH`/`DELETE` must be same-origin (`Sec-Fetch-Site`/`Origin` checked; a
  cookie-bearing request with neither header is `403 capability_required`
  `{"reason":"cross_origin"}`). A request with no bearer **and no cookie** is simply
  `401 unauthenticated` `{"reason":"missing"}` - the usual "forgot the Authorization header".
  A session signed in on a **tenant's own site** is that tenant only (Standard S11): it acts as
  a token restricted to that tenant - another tenant's routes are `404 not_found`, the platform
  routes (`POST /v1/tenants`, `/v1/users`, `/v1/settings`, …) are `404`, `GET /v1/tenants` and
  `GET /v1/me` list that tenant alone, and `POST /v1/tokens` from it refuses `allTenants`
  (`403 tenants_exceed_session`). A tenant-restricted token is kept off the platform routes the
  same way (`404`). The hub's session and an unrestricted token are unchanged.
- Bodies: JSON up to 1 MB. Multipart for media and ZIP deploys. Raw `text/*` for website files.
  The edge caps every body at 10 MB except on the upload routes (520 MB) - §8, 2026-10-12.
- On Windows Git Bash, `curl -d` mangles non-ASCII (`é`, ` - `, `·`). Use `--data-binary @file`
  with a UTF-8 file, or PowerShell `Invoke-RestMethod`, for any body with non-ASCII text.

## 5. Permission model

Two axes, plus the module switch for module data:

- **Platform role** `USER < SUPPORT < GLOBAL_ADMIN < SUPER_ADMIN` governs platform-wide calls
  (list all tenants, create tenants/users, module registry, global settings, updates).
- **Tenant role** per membership `USER, DESIGNER, ADMIN, OWNER` (OWNER includes ADMIN and
  DESIGNER) governs `/tenants/{id}/…`, refined by named **capabilities** (`tenant.settings.edit`,
  `tenant.design.edit`, `tenant.design.publish`, `tenant.members.manage`, `tenant.admins.manage`,
  `tenant.domains.manage`, `pages.view/create/edit/delete/publish`, `menus.view/edit`,
  `settings.view`, …) that a tenant's Access Policy can override. GLOBAL_ADMIN and above hold
  every capability on every tenant they may enter: every tenant by default, or only the tenants
  they are assigned to when the platform setting `security.global_admin_tenant_access` is
  `assigned`. Every state-changing call a Global admin makes is recorded in the global audit log
  (`staff.action`).
- **No API licence** (since 2026-09-25): the API is part of the core, so a token reaches every
  tenant its person is a member of (platform admins: every tenant), as far as the capabilities
  go. Module data (blog, forms, booking, shop, …) still needs that module on for the tenant
  (`403 module_disabled`); the API itself, webhooks, analytics and single sign-on are never
  module-gated. Single sign-on's provider is switched per tenant by `PUT /tenants/{t}/sso`.

Error codes you will meet (`error.code`):

| HTTP | code                              | Meaning                                                                                                                 | What to do                                                                         |
| ---- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| 400  | `validation_failed`               | body or query rejected; `details` names the fields                                                                      | fix the request                                                                    |
| 401  | `unauthenticated`                 | token missing, wrong, revoked, expired, or the user's API access is off                                                 | stop; tell the person; nothing to retry                                            |
| 403  | `token_read_only`                 | read token on a write                                                                                                   | ask for a write token                                                              |
| 403  | `tenant_not_allowed_for_token`    | token restricted to other tenants                                                                                       | ask for a token that includes this tenant                                          |
| 403  | `capability_required`             | tenant role lacks the named capability                                                                                  | say which, and that the OWNER can grant it via Access Policy                       |
| 403  | `platform_role_required`          | platform-only call                                                                                                      | say which role is needed                                                           |
| 403  | `module_disabled`                 | that module (blog, forms, …) is off                                                                                     | owner enables under Admin → Modules; never enable unasked                          |
| 404  | `not_found`, `tenant_not_found`   | no such resource / tenant - or a tenant the person is not a member of, or not ACTIVE (platform admins see every tenant) | check id and instance; a tenant missing from `GET /tenants` is not yours to act on |
| 405  | - | method not offered (e.g. `DELETE /me`, read-only record types)                                                          | - |
| 409  | `conflict`, `mode_not_file_based` | idempotency body mismatch; slug clash; tenant not `static`/`template`                                                   | see §7 recipes                                                                     |
| 413  | `payload_too_large`               | body over the limit (1 MB JSON, 2 MB text file, 25 MB file read, 100 MB ZIP)                                            | - |
| 415  | `unsupported_media_type`          | extension or content type not allowed                                                                                   | - |
| 429  | `rate_limited`                    | 600/min per token                                                                                                       | wait `Retry-After` once                                                            |
| 503  | `busy`                            | archive builder busy (two at a time)                                                                                    | wait `Retry-After`                                                                 |
| 507  | `storage_quota_exceeded`          | tenant storage full                                                                                                     | say so; deleting is the person's call                                              |

Never "try another way" around a 403. The UI would have refused the same person the same thing.

## 6. The API is on for every tenant (no more `api_disabled_for_tenant`)

Until 2026-09-25 a tenant's data was reachable through the API only while its `api-management`
module was on, and a token got `403 api_disabled_for_tenant` otherwise. The API is now part of
the core: that code is never returned, `api-management` is not a module (it cannot be switched,
licensed or requested), and a tenant's owner controls API access the way they control everything
else - through membership, roles and the Access Policy (Admin → Settings → Access Policy). A
`403` on a tenant route is now `capability_required` (or `module_disabled` for module data): say
which capability the call needs.

## 7. Safety rules and recipes

Rules:

1. **Read before you write.** `GET` the resource, show what will change, then write.
2. **`PATCH` over `PUT`.** `PUT` replaces the whole resource (the settings endpoints, which
   merge, are the exception). Use `PUT` only with a full value you just read.
3. **Publishing is separate.** Page edits change the draft; the site changes on
   `POST …/pages/{id}/publish` or a site-version activation (a `status` sent on `PATCH` / `PUT` /
   create publishes too, and needs `pages.publish` like the publish route). **A builder site that
   serves an active site version shows that version's snapshot:** there a page publish and every
   menu edit are *saved* but visitors keep seeing the active version until a new one goes live -
   `POST …/website/versions {"mode":"BLOCKS","label":"…","activate":true}` (needs
   `tenant.design.publish`; visitor-visible, so confirm first). Without an active version a publish
   and a menu edit are live at once. Every page-publish and menu-write response says which:
   `publishState.live`, `publishState.message` ("Saved · live at once" / "Saved - activate a new
   version to make it live") and `publishState.publishAsNewVersion` (that exact call, or `null`).
   Website **files**, **deploys** and **theme** changes are live at once. To show a person a draft before publishing, `GET
…/pages/{id}` returns `previewToken` (only when you hold `pages.edit`; `null` for a reader -
   the token is a bearer secret, anyone with it sees the draft);
   `https://<tenant domain>/<slug>?preview=<previewToken>` renders the draft as visitors would
   see it. A token is valid for **7 days** from when it was minted; once it has expired the
   draft URL stops rendering and the next `GET …/pages/{id}` returns a fresh token, so re-read
   the page for a new preview link.
4. **Destructive calls need an explicit yes in this conversation**: any `DELETE`; `PUT …/website`
   that switches mode; `PUT …/website/routing`; `PUT …/theme`; `PATCH …/seo {"robotsIndex": false}`; rollback/activate of
   site versions; module disable; cancelling bookings/orders; registering, pausing, rotating or
   deleting a webhook endpoint; anything on production touching more than one tenant. Quote what
   you are about to change and wait.
5. **Never enable a module, or switch single sign-on on or off, unasked.**
6. **Production is different.** Prefer dev first when the tenant exists on both.
7. Do not paginate the whole platform unless asked.

Recipes (`{t}` = tenant id, `{p}` page id, `{m}` media id, `{d}` domain id, `{u}` user id;
paths below are under `/tenants/{t}` unless they start with `/`):

- **Website mode** `GET /tenants/{t}/website` → `mode` is `blocks` (pages built in CloudForge),
  `static` (uploaded files), `template` (files with Liquid), `forward` (proxy/redirect: needs
  `forwardType`, `forwardUrl`, `preservePath`, `authMode`), `custom`. Change with `PUT` and the
  full object. Mode switches can replace the whole site: confirm. This is the routing table's
  default handler by its old name: a proxy `forwardUrl` not yet approved for the tenant is held
  for platform review (`routing.defaultPending: true`, previous mode keeps serving) - see Routing.
- **Routing (owner-only)** `GET /website/routing` → `defaultHandler` (`builder` = `blocks` /
  `custom` / `template` sites, `static`, `forward`), `defaultForward`, `defaultAccess` and the
  ordered `rules[]`, each `{pathPrefix, handler, forward, access, status}`; `reservedPaths` can
  never be claimed. A rule serves its prefix and everything below it - longest prefix wins -
  through `builder`, `static` or `forward` (`{type: redirect|proxy, target, redirectStatus,
preservePath}`), with `access.level` `public` | `signed_in` | `members` | `groups`
  (`groupIds` are the tenant's group ids, Admin → People → Groups; no v1 groups endpoint yet). Change with `PUT /website/routing` and the **whole table**
  (rules not in the body are removed): it is validated as one (`details[]` names the rule
  `index` and `field`; `groupIds` must be the tenant's own active groups), written in one
  transaction and live at once - confirm first. **Proxy targets need platform approval**, rules
  and the default handler alike: a `forward`/`proxy` rule with a new target comes back
  `pending_approval` and is not served until a platform administrator approves it on System →
  Tenants (`rejected` carries `rejectedReason`); a `defaultForward` proxy whose target is not yet
  approved for the tenant is NOT applied - the previous default keeps serving and the response
  carries `defaultApproval: {target, status, rejectedReason}` (null otherwise; a target already
  live, or used by an approved proxy rule, applies at once). Redirects and hub-served rules need
  no review. Needs `tenant.routing.manage` (OWNER). Typical: a static
  site with `/blog` from the builder and `/members/*` for signed-in users →
  `{"defaultHandler":"static","defaultAccess":{"level":"public"},"rules":[{"pathPrefix":"/blog","handler":"builder"},{"pathPrefix":"/members","handler":"static","access":{"level":"signed_in"}}]}`.
- **Edit a block page and publish** `GET /pages` → pick by slug → `GET /pages/{p}` → `content[]`
  is the block array `{id,type,props,children?}` → `PATCH /pages/{p} {"content":[…full array…]}`
  → show the diff → on the person's word `POST /pages/{p}/publish`. `DELETE …/publish` unpublishes.
  If the answer's `publishState.live` is `false` the site serves a version: tell the person the
  page is saved, not live, and on their word send `publishState.publishAsNewVersion` (one call:
  snapshot + activate). Batch several page and menu changes first, then publish once.
- **Create a page** `POST /pages {"slug","title","content":[…]}` → draft → publish when asked.
  A slug may be **nested** - lowercase segments of letters, digits and single hyphens joined by
  single `/` (`services/security`, served at `/services/security`; site limits 2026-10-03). No
  leading / trailing / double slash, at most 8 segments and 120 characters, unique per tenant
  (409 `conflict`). The FIRST segment may not be a platform path (`admin`, `portal`, `partner`,
  `system`, `global`, `api`, `signin`, `register`, `m`, `vendor`, `oidc`, `access`, `invite`, …) -
  400 `validation_failed` "… is reserved by the platform"; deeper segments are free
  (`services/admin`), and so are look-alikes (`partners`). Menus link a page item to `/<slug>`.
- **Block shapes** - every block is `{id, type, props, children?}`; props are validated per type
  (unknown props refused, 400 `validation_failed` with the failing path). Two core blocks worth
  knowing by shape (no extension needed):
  `orbital-hero` (a dark hero with a slowly turning orbital illustration; still on phones and for
  visitors who prefer reduced motion) - `{"eyebrow?":"≤80","title":"≤140","subtitle?":"≤400",
  "buttons":[{"label":"1-80","href","variant":"primary|secondary|outline|ghost"}] (0-3),
  "nodes":[{"label":"1-40","href?","icon?":"≤40"}] (0-4),"theme":"navy|graphite|custom",
  "background?":"#hex","accent?":"#hex","ring?":"#hex","align":"left|center",
  "height":"medium|large|full","illustration":"orbits|none"}` - navy / graphite take the site's
  primary colour as the accent; the hex colours (`#rgb` or `#rrggbb`, `""` = not set) apply only
  with `custom`. Every `href` on the block must be a site path (`/…`, not `//host`), an anchor
  (`#…`), `http(s)://…`, `mailto:` or `tel:`; anything else (`javascript:`, `data:`, a bare word)
  is 400 `validation_failed`.
  `process-steps` - `{"title?":"≤300","subtitle?":"≤1000","steps":[{"title":"≤60",
  "description":"≤240","icon?":"≤40"}] (2-6),"variant":"line|cards","numbering":true|false}`.
  `icon` (both blocks) is free text: a line-icon name (the `features` list in claude.md) draws
  that icon, any other short text (an emoji, `24/7`) is shown as text.
- **Start a site from a template** (approved templates bundled with the platform;
  docs/design/website-platform-v3.md §4). `GET /templates` lists them (`slug`, `category`,
  `summary`, `pages[] {slug,title,sample}`, `requires[]` = module ids the pages need, `theme`,
  `previews[]` = image paths under `/templates/{slug}/previews/…`); `GET /templates/{slug}` is
  one. Install with `POST /tenants/{t}/website/templates/{slug}/install` (needs
  `tenant.design.edit`) and an options body, every default the safe one:
  `applyTheme` (default false - `true` writes the template's colours, fonts and CSS through the
  theme at once, never the logo/favicon), `sampleContent` (default true - `false` leaves out
  pages flagged `sample`), `pages: ["about","contact"]` (only those slugs; omit for all),
  `existingPages` (`keep` default - a slug the tenant already has is left alone and the template's
  page is saved as `about-2`; `replace` rewrites the working copy as a draft), `menus` (`fill`
  default - only empty menu locations; `merge` appends after existing items; `none`). Pages are
  written as **DRAFT** rows and a **DRAFT site version** "Template: <name>" is prepared (the
  published site plus the template's pages); nothing visitors see changes until the person
  publishes - `POST /pages/{p}/publish` + a version activation, or activate the returned
  `versionId` under Website → Publish. The response (201) carries `versionId`, `pages
{created,updated,renamed[],skipped[]}`, `menus`, `media`, `themeApplied`, `warnings[]` and
  `notes[]`. A required module that is off for the tenant is a **warning**, never a refusal -
  its blocks render nothing until it is enabled (Admin → Extensions); do not enable modules
  unasked (rule 5). Installing the same template twice never overwrites: the second copy lands
  suffixed, media already present is skipped by hash, occupied menus are kept.
- **Menus** `GET /menus` (locations) or `?menu=main` → `POST /menus {"menu","label","url"|"pageId"}`
  → `PATCH /menus/{itemId}` → `POST /menus/reorder {"items":[{id,order,parentId}]}` (all-or-nothing).
- **Static/template site files** (mode must be `static` or `template`):
  `GET /website/files` → `files[] {path,size,modifiedAt}` (dot-directories hidden, 5000 cap) →
  `GET /website/files/{path}` raw file, save byte-exact → edit locally → show diff →
  `PUT /website/files/{path}` raw body, `Content-Type: text/plain`, ≤ 2 MB, text extensions only
  (html htm liquid css js mjs json txt xml svg md webmanifest). The current site is snapshotted
  first and the change is **live at once**; response has `versionId`, `siteVersionId`,
  `snapshotVersionId`. `DELETE /website/files/{path}` likewise. Binary or bulk changes go as a
  ZIP through `POST /website/deploy` (multipart `file`, `index.html` or `index.liquid` at root or
  one folder down, ≤ 100 MB; template tenants stay template if the ZIP has a `.liquid`).
  Whole site: `GET /website/archive` (ZIP). Undo: `POST /website/versions/rollback {"mode":"STATIC"}`;
  history: `GET /website/versions?mode=STATIC`; pick any: `POST /website/versions/{id}/activate`.
  A hand-placed site is moved into the versioned store on its first API edit; after that,
  editing files on the server directly no longer takes effect. Verify with a `GET` on the
  tenant's public domain.
- **Tenant settings** `GET /settings` → `{category:{key:value}}`; `PUT /settings` **merges**
  (e.g. `{"analytics":{"website_tracking_enabled":true}}`). The `website` routing keys (`mode`,
  `forward_*`, `auth_mode`, `require_membership`, `default_access`, `default_group_ids`) are
  refused here with 400 - they belong to `PUT /website/routing`. This is what Admin → Settings →
  General (`general.*`: time zone, locale, date format, currency; the site name itself is
  `PATCH /tenants/{t}` `{"name"}`) writes, plus `seo.*`, `analytics.*`, `api.allowed_origins`,
  `status.check_all_domains` and `website.verification_files`; Admin → Settings → All settings
  lists every tenant setting and where it is edited. Platform-only tenant settings (e.g.
  `website.platform_credit`, the "Powered by Cloudforge" credit, on by default) are refused with 403
  `platform_role_required` unless the caller is platform staff (admin model v2 removed the `email.*` and
  `integrations.*` fields nothing read - writing them has no effect). A credential key is stored
  encrypted and read back as `••••••••` when set; send the marker back to keep a value, `""` to
  clear it. Platform policy (e.g. whether Global admins may enter any tenant) is not a tenant
  setting - a platform super administrator sets it under Global → Settings → Security.
- **Theme** `GET /theme` → `PATCH` only the keys asked (`null` removes; colours hex, fonts are
  Google Fonts family names). Live at once. Needs `tenant.settings.edit`.
- **SEO** site: `GET|PATCH /seo` (`titleTemplate` with `%s`, `defaultDescription`, `socialImage`,
  `robotsIndex`, `robotsFollow`, `sitemapEnabled`). Page: `GET|PATCH /pages/{p}/seo` (`title`,
  `description`, `keywords`, `canonicalUrl`, `noIndex`, `og.image`) - a draft change, publish after.
  What they drive on the site's own host (2026-09-26): `https://<domain>/sitemap.xml` lists what
  an anonymous visitor can open there - published public pages without required roles, `noIndex`
  or a canonical elsewhere, paths no routing rule puts behind sign-in, redirects or proxies, and
  the entries of modules on for the tenant (blog posts - each once, at the post address its Blog
  posts block sets, `/blog/<slug>` by default; shop products) - `404` when
  `sitemapEnabled` or `robotsIndex` is off; `robots.txt` names it (or disallows everything when
  `robotsIndex` is off). A static site's own `sitemap.xml` / `robots.txt` file wins
  (`docs/design/tenant-sitemap.md`). The site root's `<title>` is the home page's SEO `title`
  through `titleTemplate` (as is any page's; one that already names the site is used as it is),
  else "<site name> - <home description>" (2026-10-03). On a builder site a published blog post
  is a page of its own at `/blog/<slug>` and at the post address a Blog posts block sets
  (`postBasePath`, e.g. `/news/<slug>`), its SEO from the post (2026-10-03, blog 1.3.0).
- **Media** `GET /media?folder=&kind=` (page 1 also returns `folders`) → `POST /media` multipart
  `file` + optional `name`, `folder`, `alt`, `caption` → `url` under `/api/public/media/{t}/…`
  is permanent and served by every host → `PATCH /media/{m}` metadata only → `DELETE` breaks
  pages that embed it: confirm.
- **Domains** `GET /domains` (each row carries `reachability`: last check, `unreachableSince`,
  `flagged`) → `POST /domains {"domain"}` (GLOBAL_ADMIN) → `GET /domains/{d}/verify` gives
  `connect`: point the domain at the platform with ONE of `connect.cname` (CNAME → target: the
  tenant's own platform address `<guid>.cforge.co.za`) or `connect.a` / `connect.aaaa` (A / AAAA
  records; empty = not configured on this instance), directly or through a proxy such as
  Cloudflare. Offer `connect.recommended` first: `cname` for a subdomain (`www.`, `shop.`), `a`
  for an apex (`connect.apex: true` - the registrable domain, which usually cannot be a CNAME;
  the CNAME is then the alternative for providers with ALIAS / CNAME flattening). There is NO TXT
  record. The person changes DNS →
  `POST /domains/{d}/verify` (optional body `{"rotateToken":true}`; 6 per domain per minute) →
  the platform requests `http://<domain>/.well-known/cloudforge-verify/<token>` through the
  domain's own DNS (then `https://` if plain HTTP fails) and checks its own HMAC proof.
  `verified:true` → `isVerified`, and the edge issues the certificate. `false` → `checks[]` says
  where the chain breaks: `dns` (what it resolves to - informative), `http`, `https` (`error`,
  `statusCode`, `found` = every URL requested). Show it, do not loop - DNS takes minutes to hours.
  Every answer also carries `sibling` (null when fine): the www / apex twin of the same tenant
  (`www.x` ↔ `x`) when its row is unverified or it resolves elsewhere -
  `{domain, isVerified, addresses, pointsToPlatform}`. Tell the person: "`x` (without www) is not
  connected: it answers from 169.239.218.61, not CloudForge - visitors who type it will see the
  old site. Add an A record to `connect.a` and verify it as well." A verified `www.` does not mean
  the bare name is live.
- **Site verification files** (Google Search Console, Bing, Facebook, …): `PUT /settings
  {"website":{"verification_files":[{"name":"google1a2b3c.html","content":"google-site-verification: google1a2b3c.html"}]}}`
  (owner). Each file is answered at `https://<every domain>/<name>` in every website mode -
  forwarded sites too (the edge passes these exact paths to the platform). A root name ending
  in `.html` / `.txt` / `.xml`, at most 10 files of 4 KB; never `index.html`, `robots.txt`,
  `sitemap.xml`; anything else is 400 `validation_failed`. The list you send replaces the old one.
  A verified domain is re-checked daily and is never unverified by a failed check: after 72 h
  unreachable it is `reachability.flagged` and the owners are told.
  `PATCH /domains/{d} {"isPrimary","isVerified"}` (GLOBAL_ADMIN) remains the staff override.
  **Only a VERIFIED custom domain is ever primary** (BL-160): the system domain (`isSystem`,
  `<guid>.cforge.co.za`) never is, and neither is a domain that has not been verified - links
  must never point at a host that cannot serve them. `PATCH {"isPrimary":true}` on either is
  `400 validation_failed`; `POST /domains` adds a domain unverified and NOT primary
  (`isPrimary:true` there is 400), and a `<uuid>.<platform suffix>` host cannot be added. A domain
  becomes primary when it is VERIFIED (`POST …/verify` succeeding, or the staff `PATCH
  {"isVerified":true}`) if the tenant has no primary then. `DELETE` of the primary promotes the
  OLDEST remaining verified custom domain and answers it as `promoted: {id, domain}`; with none it
  answers `promoted: null` and the tenant has NO primary (links fall back to the system domain)
  until a custom domain is verified.
- **Module data** (blog, forms, booking, ecommerce) `GET /modules/{moduleId}/records` with
  no `type` lists the record types, their capabilities and filters → `GET …/records?type=posts`
  → `GET|PATCH|DELETE …/records/{id}?type=posts` → `POST …/records?type=posts` (400 names fields).
  Status changes via `PATCH` (`{"status":"PUBLISHED"}`, bookings `{"status":"CANCELLED","reason"}`,
  orders `{"status":"SHIPPED","trackingNumber"}` / `{"paymentStatus":"PAID"}`). Read-only types
  answer 405. Unknown type → 404 with the valid list.
- **Members** `GET /members` → `POST /members {"email","role"}` → `PATCH /members/{u} {"role"}`
  → `DELETE /members/{u}`. OWNER assignment needs `tenant.admins.manage`. `POST` on someone who is
  already a member changes their role only when you could assign their CURRENT role too (an ADMIN
  touching an OWNER or another ADMIN → 403), never reinstates a suspended member (409 - use
  `PATCH {"status":"ACTIVE"}`) and never demotes the last active OWNER (409). Platform staff are
  not tenant members: the list leaves them out, and to a tenant caller `PATCH` / `DELETE` on a
  staff membership (or `POST` with a staff e-mail) answers 404 (by design, admin-model v2).
- **Customers** `GET /customers?search=&type=&status=` → `POST /customers {"name","type":"COMPANY"|"INDIVIDUAL",
  "email","phone","company","city","province","tags","notes",…}` (2026-10-08). The core customer records
  Admin → Customers manages and every module shares; a portal sign-in is linked to one in the browser. No
  capability of their own: tenant OWNER or ADMIN (or platform GLOBAL_ADMIN+), a USER/DESIGNER member gets 403
  `capability_required`. `code` (CUS-001, …) is assigned on create; nothing is sent to the customer.
- **Access policy** `GET /access-policy` (overrides + effective matrix) → `PUT` with
  `ALLOW` / `DENY` / `null` per role and capability.
- **Create a tenant** (GLOBAL_ADMIN+) `POST /tenants {"name","slug","ownerEmail",…}` → returns
  id and system domain `<guid>.cforge.co.za`.
- **Users** (GLOBAL_ADMIN) `GET /users`, `POST /users`, `GET|PATCH /users/{u}` (`{"apiAccessEnabled":false}`
  switches a person's API access off; `{"revokeTokens":true}` revokes all). `/users/me` works.
- **Tokens** `GET /tokens` (never secrets) → `POST /tokens {"name","scope","expiresInDays"|"noExpiry",
  "allTenants":true | "tenantIds":[…]}` - the reach is required: `allTenants: true` (every tenant the
  person belongs to, now and later) or at least one tenant id; neither, or both, is `400
  validation_failed` with `details.reason` `tenants_required` / `tenants_conflict` (since
  2026-10-03; an empty `tenantIds` used to mean "all" silently) (scope cannot exceed the caller's; secret shown once).
  A token minting a token caps it by its own reach and lifetime: a tenant-restricted token cannot mint
  `allTenants` or another tenant (403), an expiring token cannot mint `noExpiry` (403), and a longer
  `expiresInDays` is cut to the calling token's expiry → `DELETE /tokens/{id}` (works with a
  read token: revocation only removes access). Admin view: `GET /users/{u}/tokens`,
  `DELETE /users/{u}/tokens/{tokenId}`.
- **Modules, prices and licences** `GET /tenants/{t}/modules` lists every module the platform
  offers - compiled into this build (`kind: "builtin"`: the core's own, `moduleKind: "builtin"`,
  `version` = the platform's; and the hub packages - finance, shop, booking, support, blog,
  forms - `moduleKind: "hub-package"`, `version` = the package's release, e.g. `1.1.0`) and
  installed modules from Global → Modules (`kind: "spoke"`, `moduleKind` = the manifest's
  `spoke` | `hub-package` | `external`, `version` = the installed release) - with the tenant's switch, what enabling it
  costs now (`cost`: `price` in **minor units** (cents) of `currency`, `billingCycle`
  `monthly|yearly|once|free`, `licenceTier`, `display` in the tenant's locale - Settings →
  General - e.g. `"R 49,00 / month"` for en-ZA), whether it is
  `paid`, the licence `status` (`active` | `pending_activation` | `expired` | `off`), a pending
  `request`, its `requires` (module ids, and `portal` = the tenant portal) and the tenant
  `features` it switches on, and the `licence` recorded when it was enabled (`licenceTier`,
  `cost`, `currency`, `billingCycle`, `activatedAt`, `activatedBy`, `expiresAt`, `notes`; all
  `null` until then). **Paid modules are switched on by platform staff**: the owner asks with
  `POST /tenants/{t}/modules/{m}/requests {"note":"…"}` (capability `tenant.modules.manage`; `400` for a free
  module - switch it on instead; `409` with `details.requestId` when one is
  already waiting; `DELETE …/requests` withdraws it); staff approve on Global → Modules →
  Requests. `PUT /tenants/{t}/modules/{m}
{"enabled":true,"licenceTier":"pro","expiresAt":"2027-01-01T00:00:00Z","notes":"annual"}`
  (platform GLOBAL_ADMIN) records the licence at the current price and approves a pending
  request; a module whose `requires` are not met answers `409` with `details.requirements`
  (`{"missingModules":[…],"portalOff":true}`) - add `"switchOnPortal":true` to switch the tenant
  portal on with it. A catalogue module answers `409 conflict` while it is not installed,
  unhealthy or disabled platform-wide. Installing modules from git sources, pricing them and the
  tenant install policy are browser tasks (Global → Modules, SUPER_ADMIN / GLOBAL_ADMIN); the
  session-only routes under `/api/global/modules/*` (alias `/api/global/catalogue/*`) are not
  part of `/v1`.
- **A spoke's settings** (an installed module from Global → Modules with a `settings` schema;
  2026-09-24): `GET /tenants/{t}/modules/{m}/settings` (capability `settings.view`) → `{ version,
  values, secrets }` - every declared key with defaults applied; a key listed in `secrets` comes
  back as `••••••••` when set and `""` when not, never the value. `PUT …/settings {"values":
  {"travelFreeKm": 45}}` (`settings.edit`; the module must be on - `403 module_disabled`) saves
  the given keys and leaves the rest: unknown keys `400 validation_failed` with the reason, types
  are coerced from strings, `enum` / `minimum` / `maximum` / `required` enforced; send `••••••••`
  for a secret to keep it, `""` to clear it. The spoke reads the same `GET` with its edge secret
  instead of a bearer and receives the secrets in the clear; it learns of a change from the
  `X-Remote-Settings-Version` header on its forward_auth answers. Built-in modules keep their
  `config` on `PUT …/modules/{m}` - this endpoint answers `404` for them.
- **Platform** `GET /modules` (registry with tenant counts), `GET|PUT /settings` (global, merge;
  registered platform settings only - an unknown key or a value that does not fit is 400, and the
  SUPER_ADMIN-class keys such as `security.*` are 403 `platform_role_required` below SUPER_ADMIN),
  `GET /updates` (running build, channel, GitHub releases), `GET|POST /updates/jobs`
  (`action: check|update|cutover|rollback|retire|backup|approve`, SUPER_ADMIN; all but `check` need
  an explicit yes; `version` is `X.Y.Z` for `approve` and a release tag/version for `update` -
  anything else is `400 validation_failed`).
- **Audit** `GET /tenants/{t}/audit?limit=` newest first (needs `tenant.audit.view`: ADMIN and
  OWNER by default, a tenant policy may open it wider); every API write appears as
  `api.<action>` under the person's name.
- **Backups** (owner-only `tenant.backups.manage`; docs/design/tenant-backup-github.md).
  `POST /tenants/{t}/backups {"target":"zip"}` queues an export → poll `GET /backups/{jobId}`
  (`status`, `progress`, `log[]`) → `GET /backups/{jobId}/download` is the ZIP (one tree:
  `cloudforge-tenant.json`, `settings.json`, `theme.json`, `routing.json`, `domains.json`,
  `menus.json`, `pages/*.json`, `media/`, `website/`, `modules/<id>/`; never tokens, sessions,
  audit rows, analytics or secret values - secrets are listed by name in the manifest).
  Housekeeping: `POST /backups/{jobId}/check` (or `POST /backups/check {"jobIds":[…]}`) re-opens
  the archive and verifies its sha256, manifest, sections and every media file - the result is
  stored on the job; `DELETE /backups/{jobId}` (or `POST /backups/delete {"jobIds":[…]}`, partial
  failures listed) removes the archive and the row, audited - a queued / running job is 409, a
  GitHub backup keeps its commit (`commitKept`). Retention `GET|PUT /backups/retention
  {"count","days"}` (default keep 5, none older than 90 days; the newest always stays) runs after
  every export and nightly with the orphan clean-up. GitHub: `PUT /backups/github {"owner","repo","branch",
"pathPrefix","schedule":"manual|daily|weekly","token"}` (fine-grained token, `contents: write`
  on that one repo; checked before saving; never returned) → `POST /backups {"target":"github"}`
  = one commit per backup (unchanged files reused by blob sha) → `GET /backups/github/commits`
  lists them. Restore: `POST /backups/restore` with `preview:true` first (multipart `file` = ZIP,
  or JSON `{"github":{"commit":"<sha>"}}`), read the counts / warnings / "needs re-entry"
  secrets, then the same call with `confirm:"RESTORE <slug>"`. Restored pages, menus, media, site
  files and theme become a **draft site version** (activate it under Website → Versions - nothing
  is live before that); settings are replaced (never the routing keys, the secret, or platform
  feature limits); domains re-added unverified; the Routing default handler is recorded only and
  the archive's rules are ADDED `disabled` beside the current ones (a proxy rule waits for
  platform approval; a prefix already in use keeps the current rule; groups that do not exist
  here become `members`); module data merged (`policy.modules.<id>.policy:"replace"` needs
  `confirm` again per module - replace keeps shop rows an order or cart still references).
  One job per tenant at a time (409 `conflict`). `POST /backups/{jobId}/cancel` stops a job at
  its next tick. Template export: `POST /backups {"template":{"name","category","summary"}}`
  writes `cloudforge-template.json` and only theme/menus/pages/media/website (the Catalogue
  installs it through the same restore path, "drafts, never delete").
- **Analytics** (Website Traffic; part of every tenant; needs `reports.view` - ADMIN/OWNER by
  default). All reads, all under
  `/tenants/{t}/analytics/`, all taking `from` / `to` (ISO instants, default the last 7 days),
  `class` (`humans` default | `suspected` | `bots`), `segment=<id>` (a saved segment; an id that
  is not this tenant's is `404 not_found`) and `compare=1` (adds the previous period of equal
  length): `overview` (KPIs, previous period, daily series, insights, `revenue` when the tenant
  has the shop - licence `shop`), `acquisition` (channels, source/medium, campaigns, referrers,
  search terms, `revenue`),
  `engagement` (pages, landing / exit pages, site search), `audience` (new vs returning,
  countries, languages, devices, browsers, OS, screens), `events` (every event name, conversions),
  `vitals` (p75 Web Vitals per metric and page, raw rows only), `ips` (every address with its
  classification - shows ALL classes by default, `ipClass=`, `country=`, `asn=`, `prefix=`,
  paged; `format=csv` needs `reports.export`), `bots` (crawlers, fake crawlers, datacenter
  sources, probe paths; `from`/`to` only), `paths?page=/x` (next / previous pages, entry pages,
  top journeys), `funnels` (the saved funnels, to pick an id) → `funnels/{funnelId}` (sessions
  per step, conversion, drop-off; ≤ 90 days, else 400), `cohorts` (12 weekly cohorts × 8 return
  weeks; `class` / `segment` only). `fromRollups:true` means the range came from the nightly
  rollups (visitors = sum of daily uniques); `rawOnly:true` means the report reaches back only
  as far as the tenant's raw retention (90 days by default).
- **Analytics configuration - funnels and segments** (B3; needs `analytics.manage`, ADMIN /
  OWNER by default, plus the module; a write token). Both are definitions only - no traffic
  data is touched by any of these. `GET /analytics/segments` lists the saved segments (id,
  name, `filters`); `POST /analytics/segments {"name","filters":{…}}` saves one with the
  filters of the Website Traffic toolbar - `country[]` (ISO codes), `channel[]` (GA channel
  group names, e.g. `Organic Search`), `device[]` (`desktop` | `mobile` | `tablet`),
  `landingPage` (path prefix), `trafficClass` (`human` | `suspected` | `bot`; replaces the
  `class` switch on reports), `referrerHost`, `utmCampaign`; an empty or omitted `filters`
  matches every visitor; values are canonicalised on save (upper-case codes, lower-case hosts,
  a leading slash on the prefix), unknown keys are refused, max 20 per tenant. Then use it:
  `GET /analytics/overview?segment=<id>`. `PUT /analytics/segments/{id}` replaces the WHOLE
  segment (a filter left out is removed - read it first, rule 1) and `DELETE …/segments/{id}`
  removes it (a report called with that id afterwards is `404 not_found`; rule 4 applies).
  `POST /analytics/funnels {"name","steps":[{"type":"page","match":"/cart"},{"type":"event","match":"purchase"}]}`
  saves a funnel (name ≤ 80 characters; 1-8 ordered steps; max 20 per tenant; a page match is a path with `*` as wildcard, an event
  match an event name; validation errors name the step); `PUT …/funnels/{id}` replaces name
  and steps, `DELETE …/funnels/{id}` removes it; evaluate with `GET …/funnels/{id}` as before.
  Another tenant's funnel or segment is `404`, never `403`. Every write lands in the audit
  trail as `api.analytics.funnel.*` / `api.analytics.segment.*`.
- **Webhooks** (outbound; part of every tenant; needs `tenant.webhooks.manage` - ADMIN/OWNER by
  default; all under
  `/tenants/{t}/webhooks`). `GET /webhooks/events` (public, no token) is the catalogue: every
  event id with the fields its `data` carries, the signature scheme and the retry schedule -
  `page.published`, `site.version.activated`, `form.submitted`,
  `booking.created|confirmed|cancelled`, `order.created|paid|shipped|cancelled`,
  `ticket.created|assigned|message_added|resolved` (each fires once per real change: confirming a
  confirmed booking, shipping a shipped order or re-assigning the same agent sends nothing; an
  internal ticket note never fires; `ticket.message_added` says only `fromRequester`, never the
  author or the text),
  `tenant.module.enabled|disabled`, `backup.completed`, `tenant.status.changed` (the website -
  or, with `isPrimary: false`, another verified domain - went offline or came back),
  `module.request.created|decided` (a member asked staff for a module; staff approved, declined
  or a takeover cancelled it - `{requestId, moduleId, kind, outcome, decisionNote, decidedAt}`),
  and with single sign-on on `user.joined|left`, `membership.changed`, `group.changed`. Payloads are ids plus a compact object,
  never form fields, guest details, ticket descriptions or email addresses - read those through
  the API. Register: `POST /webhooks {"name","url","events":[…],"description"?}` - the URL must
  be **https on a public host** (loopback, private, link-local and cloud-metadata addresses are
  refused, at save time and again before every delivery) - and the response carries **`secret`
  (`whsec_…`) exactly once**: store it, it signs every delivery. Then `GET /webhooks`,
  `PATCH /webhooks/{wid}` (`name`, `url`, `events`, `description`, or `{"status":"paused"}` /
  `{"status":"active"}` - the latter also re-enables an endpoint that was `disabled_by_failures`
  and resets its counter; an endpoint with no signing secret - `secretHint` null, a row from
  before the encrypted-secret migration - answers 400 until you rotate it), `DELETE
/webhooks/{wid}` (its log goes too: confirm),
  `POST /webhooks/{wid}/test` (sends a `ping` now, synchronously, and returns the delivery row
  with the response status and duration - the first thing to do after registering),
  `POST /webhooks/{wid}/rotate-secret` (new secret once; update the receiver first),
  `GET /webhooks/{wid}/deliveries?status=pending|delivered|failed&event=&page=&limit=` (30 days,
  newest first; `payloadHash` is the sha256 of the body sent, `payloadPreview` / `responsePreview`
  2 KB) and `POST …/deliveries/{did}/redeliver` (the same event again, a new row). Delivery: one
  `POST` per event per endpoint, JSON envelope `{id, type, createdAt, tenantId, attempt, data}`,
  headers `X-CloudForge-Signature: t=<unix seconds>,v1=<hex>`, `X-CloudForge-Event` (the type)
  and `X-CloudForge-Delivery` (the delivery id - use it for idempotency); **only a 2xx counts**
  (redirects are not followed); 10 s timeout; retried after 1, 5, 15 and 40 minutes (5 attempts
  over ~1 h); after **20 consecutive failures** the endpoint is paused (`disabled_by_failures`),
  the owners are notified and nothing more is sent until it is set `active` again. Verify the
  signature on every delivery - `v1 = HMAC-SHA256(secret, "<t>.<raw body>")`, compared in
  constant time, `t` within 5 minutes of now:

  ```js
  // Node (Express with express.raw so the body is the exact bytes sent)
  const crypto = require('node:crypto');
  function verify(secret, rawBody, header, toleranceSeconds = 300) {
    const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
    const t = Number(parts.t);
    if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
    const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
    const given = String(parts.v1 || '');
    return (
      given.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
    );
  }
  app.post('/hooks/cloudforge', express.raw({ type: 'application/json' }), (req, res) => {
    if (
      !verify(
        process.env.CF_WEBHOOK_SECRET,
        req.body.toString('utf8'),
        req.get('X-CloudForge-Signature') || ''
      )
    )
      return res.status(401).end();
    const event = JSON.parse(req.body); // { id, type, createdAt, tenantId, attempt, data }
    // ... handle event.type; return 2xx quickly and do slow work afterwards
    res.status(204).end();
  });
  ```

  ```python
  # Python (Flask); request.get_data() is the exact bytes sent
  import hmac, hashlib, time
  def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
      parts = dict(p.split('=', 1) for p in header.split(','))
      t = int(parts.get('t', '0') or 0)
      if not t or abs(time.time() - t) > tolerance:
          return False
      expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(parts.get('v1', ''), expected)
  ```

  Safety: registering, pausing, rotating and deleting endpoints are tenant changes - say what
  will be sent where before you `POST`, and never point an endpoint at a URL the person did not
  give you.

- **Connect a third-party site (OIDC - single sign-on, part of every tenant)**: a system on any
  server signs people in with the tenant's accounts, and the portal lists it under "Systems".
  The routes below need `sso.view` (reads) / `sso.manage` (changes) - ADMIN and OWNER by default
 - and work whether or not single sign-on is on, so everything can be set up first. The
  provider itself answers only while the tenant's switch is on: `GET /tenants/{t}/sso` →
  `enabled`; the owner switches it with `PUT /tenants/{t}/sso {"enabled": true}`
  (`tenant.settings.edit`, or Admin → Settings → Sign-in; off by default - ask first). Off →
  `/oidc/*`, the discovery document, the portal "Systems" list and client-credential tokens
  answer `404`, and every refresh grant ends; the apps and keys are kept.
  1. `GET /tenants/{t}/sso` → `issuer` (`https://<primary domain>/oidc`) and `discoveryUrl`
     (`<issuer>/.well-known/openid-configuration`, also served at the host root). The vendor
     configures only the discovery URL, the client id and (server-side apps) the secret.
  2. `POST /tenants/{t}/sso/clients` `{name, redirectUris:[https://…], clientType:
"confidential"|"public", scopes:["openid","profile","email","roles","groups","apps"],
grantTypes:["authorization_code","refresh_token"(,"client_credentials")],
postLogoutRedirectUris:[…], access:{roles:[], groupIds:[], allowUserIds:[], denyUserIds:[]}}`
     → `{client, clientSecret}` - the **secret (`cfs_…`) is returned exactly once**; hand it to
     the vendor and never store it anywhere else. Redirect URIs match **exactly** (https;
     `http://localhost` only on a development instance). A `public` client (browser/phone) has no
     secret and must use PKCE S256. `access` decides who may sign in: listed roles, groups or
     people; `denyUserIds` always wins; all empty = every member. Change with
     `PATCH /sso/clients/{c}` (`status:"disabled"` refuses new sign-ins and revokes every refresh
     token at once), new secret `POST /sso/clients/{c}/secret`, remove `DELETE /sso/clients/{c}`.
  3. Put it in the portal: `POST /tenants/{t}/sso/apps` `{name, kind:"oidc", url:"https://app…",
clientId:"<client row id>", icon:"Globe", visibility:{…same shape…}}` (`kind` `link` or
     `forward_auth` for plain links / hub-guarded paths). The sidebar lists it for the people
     `visibility` admits (an `oidc` app also needs its client's `access` to admit them); opening
     it starts the sign-in (the client's `initiateLoginUri` if set, else an IdP-initiated code flow).
  4. The provider (per tenant, on the tenant's domain): `authorization_code` + PKCE, tokens at
     `<issuer>/token` (`client_secret_basic` or `client_secret_post`; `none` + PKCE for public),
     `id_token` and access token are JWTs signed with the **tenant's own key** (RS256 by default,
     `kid` in the header; JWKS `<issuer>/jwks`), access tokens 10 min, refresh 30 days and
     **rotated on every use** (a reused refresh token revokes the whole grant), codes single-use
     60 s, `<issuer>/userinfo`, `<issuer>/revoke` (RFC 7009), RP-initiated logout `<issuer>/logout`
     (`post_logout_redirect_uri` must be registered). Claims: `sub` (stable per person **per
     tenant** - the same person on two tenants has two subjects), `email`, `email_verified`,
     `name`, `tenant` (slug), `roles`, `groups` (group slugs), `apps` (client ids the person may
     use). Everything is rate-limited per IP and per client and audited (`sso.signed_in`,
     `sso.token_issued`, `sso.refused` with the reason - never a token).
  5. Keep the vendor's user base in step (provisioning): subscribe a webhook to `user.joined`,
     `user.left`, `membership.changed`, `group.changed` (members named by the same `sub`), and
     let the vendor read who may use the app with **its own token**: `POST <issuer>/token`
     `grant_type=client_credentials&scope=cf:users.read` (confidential client with the
     `client_credentials` grant), then `GET /v1/tenants/{t}/sso/apps/{a}/users?page=&limit=`
     with `Authorization: Bearer <that token>` → `[{sub, email, name, emailVerified, role,
groups, joinedAt}]` (a CloudForge token with `sso.manage` works too). The app may only read
     the portal app bound to its own client. Sign-in pre-creates nothing on the vendor's side:
     create the account on first sign-in (JIT) and deactivate on `user.left`.
  6. Keys: `GET /sso/keys`; `POST /sso/keys {"alg":"RS256"|"ES256","graceHours":24}` rotates -
     the new key signs at once, the old one stays in the JWKS for the grace window.
     Vendor-facing guide with Node and Python examples:
     `https://www.cloudforge.co.za/sso-connecting.md`. Safety: connecting an app, changing who may
     sign in and rotating keys are tenant changes - confirm with the person; never paste a secret
     into a chat, a ticket or a log.

- **Is the website online? (uptime / IsOnline, 2026-09-24).** The platform probes every active
  tenant's primary domain every 5 minutes from the live colour (`GET https://<domain>/`, 10 s,
  redirects not followed): 2xx or 3xx = online - a redirect to a sign-in page is a working site
 - 4xx / 5xx / timeout / DNS failure = offline. `GET /v1/tenants/{t}/status` (needs
  `status.view`: DESIGNER, ADMIN, OWNER by default) → `{isOnline, domain, lastCheckedAt,
statusCode, latencyMs, error, source, uptime: {h24, d7, d30}, incidents: [...]}` - `isOnline:
null` means never checked yet (no domain, or the site went live under five minutes ago);
  `uptime` is the percentage of checks that answered, one decimal, `null` without checks;
  `incidents` are runs of two or more failed checks in the last 7 days, newest first, `endedAt:
null` = still down. It is served from a 60 s cache. `POST …/status/check` (`status.check`:
  ADMIN, OWNER; write token) probes now and records the row as `source: "manual"` - at most 6 a
  minute per site (429 `rate_limited`), `check: null` when there is no domain. `GET
…/status/history?days=7` (1-30) is every raw check, newest first, 10 000-row cap (`truncated`);
  every row names its `domain` and `isPrimary`, and `&domain=www.shop.example` narrows to one host.
  **Every domain, not only the primary (BL-042):** `domains[]` on the status lists each domain
  of the tenant with its newest check, primary first (`isOnline: null` = never probed). By
  default only the primary is probed; `PATCH /tenants/{t}/settings {"status":{"check_all_domains":true}}`
  (`tenant.settings.edit`) makes the checker probe every VERIFIED domain too (at most 10 hosts, primary first) - one row each, the
  website's own state still read from the primary - and a secondary domain that goes dark
  (its own two failed checks) notifies the owners as "Domain offline" and emits
  `tenant.status.changed` with `isPrimary: false`. `POST …/status/check` then returns `checks[]`
  (every host probed) beside `check` (the primary's). An unverified custom domain is never
  probed (no certificate yet). **Who hears about a flip (BL-044):** the owners get an in-app
  notification under the tenant and an e-mail through the tenant's mailer (its own Microsoft
  Graph connection, else the platform's; silently skipped when none is configured), platform
  administrators get a system notice linking to System → Tenants, and subscribed webhooks get
  the event.
  `GET /v1/status` (any token) is the platform line - `status`, `version`, `database.ok`, the
  checker's last run and how many sites were online / offline (no names); platform SUPPORT and
  up also get `deployment` (colour, environment, commit). `GET /v1/system/status/tenants`
  (SUPPORT+) lists every tenant offline first. Reporting: "shop.example is **online** (HTTP 200,
  90 ms, checked 2 min ago); 99.7 % over 30 days; one incident this week (Tue 02:10-02:25, HTTP
  502)". Being told about a change instead of polling: subscribe a webhook to
  `tenant.status.changed` (§7 webhooks) - emitted once when a site goes offline (two consecutive
  failed checks) and once when it is back, with `{domain, isOnline, statusCode, latencyMs,
error, changedAt}`; the tenant's owners get an in-app notification at the same moment.

## 8. Known quirks (verified 2026-09-21 on prod 2.6.12; fixed in 2.6.14 unless marked "by design")

Changed 2026-10-12 (security audit A1) - **request bodies are capped at the edge.** Any request
with a body over **10 MB** answers `413` from the edge (a plain Caddy error, not the JSON error
envelope - `payload_too_large` with the envelope still comes from the app for a body under the
edge cap but over the route's own limit). Only the upload routes take more, up to **520 MB** at
the edge and their own limit in the app: `POST …/website/deploy` (100 MB ZIP),
`POST …/backups/restore` (500 MB ZIP), `POST …/media` (the tenant's media limit, at most 100 MB),
and the staff routes `POST /api/admin/website/deploy`, `POST /api/system/tenants/{id}/website`,
`POST /api/system/tenants/{id}/backups/restore`. `PUT …/website/files/{path}` stays at 2 MB
(its own limit, under the default). The list is `src/lib/http/large-body-routes.ts`.

Changed 2026-10-12 - **draft-preview tokens expire** (by design, security batch E). A page's
`previewToken` is valid for 7 days from when it was minted; after that the
`?preview=<token>` URL renders nothing (a 404, like a wrong token) and the next
`GET …/pages/{id}` by an editor returns a fresh token. A preview link handed to a person is
therefore good for a week - re-read the page for a new one. Tokens minted before this change
carry no issue time and are treated as expired: the first read after the upgrade rotates them.

Changed 2026-10-06 - **domain connect guidance.** `connect.cname.target` is now the tenant's
platform address (`<guid>.cforge.co.za`, the system domain); it used to be the instance-wide
`CF_PUBLIC_CNAME_TARGET` (on production a DDNS name), which is now only the fallback for a tenant
without a system domain. A CNAME to either keeps working. New, additive: `connect.apex`,
`connect.recommended` (`a` for an apex, `cname` for a subdomain) and `sibling` - the www / apex
twin when it needs attention, computed by every check (`POST …/verify` and the daily re-check)
and stored with it, so `GET …/verify` answers the last check's. `sibling` is DNS only (the twin
is never fetched) and a twin whose own last check reached the platform (a proxy, say) is not
reported for its addresses alone.

Changed 2026-10-05 (BL-160) - **only a verified custom domain is ever primary.** Removing the
primary domain used to promote the next OLDEST domain, which on every tenant is the system
domain `<guid>.cforge.co.za`, and a new domain could be primary before it was verified (links
pointed at a host with no proof it reached the platform and no certificate). Now:
`DELETE …/domains/{d}` promotes the oldest remaining VERIFIED custom domain (`promoted: {id,
domain}` in the answer) or leaves the tenant without a primary (`promoted: null`);
`PATCH …/domains/{d} {"isPrimary":true}` on the system domain or an unverified domain is
`400 validation_failed` (un-verifying the primary is refused too); `POST …/domains` refuses a
`<uuid>.<platform suffix>` host and adds the domain unverified and NOT primary (`isPrimary:true`
is 400); a domain becomes primary when it is verified if the tenant has no primary then.
`POST /tenants` creates custom domains unverified, so a new tenant has no primary until one is
verified (it used to make the first one - or, with none, the system domain - primary).

Changed 2026-10-12 (security audit M13) - **forward destination policy.** One policy now decides
where a forward target may never point, for proxy AND absolute redirect targets, rules and the
default handler alike, on `PUT …/website` and `PUT …/website/routing` and again when platform
staff approve a pending target: loopback, link-local / cloud metadata, the unspecified / CGNAT
(100.64/10) / multicast / reserved ranges, a scheme other than http(s), credentials in the URL, a
reserved port on any host (2019, 5432, 5433, 6379, 9400, 3100, 9229), the platform's own service
and slot names (`caddy`, `db`, `conductor`, `app-blue` / `app-green`, their `cloudforge-*`
aliases) and the hub's own hostnames (a forward loop) - `400 validation_failed`, the reason in
`details` (`A proxy target must not …` / `A redirect target must not …`). Other containers on the
platform network and LAN hosts (`http://my-app:3000`, `http://10.11.5.199:3027`) stay allowed; a
site-relative redirect (`/new-page`) is not a destination. A target stored before this change is
no longer injected into the edge (the host falls through to the hub) and cannot be approved.

Changed 2026-10-08 - **customers.** `GET|POST /tenants/{t}/customers` (§7 "Customers", §11): the core customer
records, until now browser-only, can be listed and created with a token by a tenant OWNER/ADMIN.

Changed 2026-10-03 (security batch B) - **forward targets and website file downloads.**
`PUT …/website` and `PUT …/website/routing` refuse a forward target (redirect or proxy, rule or
default) that contains `{` or `}`, raw or as `%7B` / `%7D` - the edge would read them as
placeholders - and a proxy target on loopback, link-local / cloud metadata, the unspecified
address or the hub's own services (ports 2019, 9400, 5432, 6379): `400 validation_failed`, the
reason in `details`. `PUT …/website` used to skip these checks entirely. Proxy targets approved
before this change keep serving until they are saved again. `GET …/website/files/{path}` now
answers every file with `Content-Disposition: attachment` (it used to send `inline` for
everything but HTML/SVG); save the body byte-exact as before.

Changed 2026-10-03 (security batch A) - until production runs it, expect the old behaviour there:
`GET /tenants/{t}/audit` answered any ACTIVE member (now `403 capability_required`,
`details.capability: tenant.audit.view`, for USER and DESIGNER by default); `PUT /settings` wrote any
key with an inferred type (now registry-checked, SUPER_ADMIN-class keys refused to GLOBAL_ADMIN, and
nothing is written when one key is refused); `POST /tenants/{t}/members` overwrote an existing
member's role and status (now 403 / 409 as in §7 "Members"); `POST /tokens` from a token ignored that
token's tenants and expiry (now capped, §7 "Tokens").

Changed 2026-10-03 - **creating a token needs an explicit reach.** `POST /tokens` without
`allTenants: true` and without any `tenantIds` used to mint an all-tenant token; it is now `400
validation_failed` (`details.reason: tenants_required`). Send `"allTenants": true` for the old
meaning. Existing tokens are unchanged (an unrestricted token still shows `tenantIds: []`).

Changed 2026-09-25 (the API, single sign-on and analytics became core) - until production runs
it, expect the old behaviour there:

- **The API Management gate is gone.** Before, a token on a tenant whose `api-management` module
  was off got `403 api_disabled_for_tenant` on every tenant route (platform admins included), and
  webhook routes answered `403 module_disabled` for sessions too. Now no route returns
  `api_disabled_for_tenant`; the membership and the capability decide (§5, §6). `apiEnabled` on
  `GET /me` and `GET /tenants/{t}` is always `true`. `PUT …/modules/api-management` no longer
  opens anything: the API is on for every tenant.
- **Analytics and single sign-on are not modules.** `…/analytics/*` and `…/sso/**` no longer
  answer `403 module_disabled` for `analytics` / `sso`; `GET …/modules` lists `analytics`,
  `api-management` and `sso` as `kind: "core"`, always on, with no licence, cost or request.
  Single sign-on is switched per tenant with `PUT /tenants/{t}/sso {"enabled": …}` (new;
  `tenant.settings.edit`); `GET /tenants/{t}/sso` gained `enabled`; while it is off the
  provider answers `404` (it answered `404` while the old module was off, too). Tenants whose
  `sso` licence was in force were switched on by the release's migration. `PUT …/settings`
  refuses the `sso` category (`400 validation_failed` - use `PUT …/sso`).
- **`landing-pages` is deleted**, not incubating: its repository and catalogue entry are gone
  (it never had code or data).

Fixed in 2.6.16 (BL-021, 2026-09-24) - until production runs it, expect the old behaviour there:

- **`403 not_a_member` is gone.** Before 2.6.16 a member of tenant A calling
  `/tenants/{B}/…` got `403 not_a_member` when B existed and was ACTIVE, and `404
tenant_not_found` only when it did not - the pair confirmed which tenant ids were real. From
  2.6.16 every tenant the person may not see answers `404 tenant_not_found`, whether it does not
  exist, is not ACTIVE, or the person is simply not a member. `403 tenant_not_allowed_for_token`
  is unchanged: it describes the person's own token, not the tenant. Platform GLOBAL_ADMIN+ still
  see every tenant.

Changed in BL-092 (2026-09-25) - custom-domain verification, a breaking response change:

- **The TXT proof is gone.** `required.txt` and `_cloudforge-verify.<domain>` no longer exist and a
  TXT record verifies nothing; `method` is `http` (`already` for the system domain) instead of
  `txt | cname | none`; `checks[]` items are `{step, target, ok, found, statusCode, error}` instead
  of `{type, name, found, ok, error}`. `required.cname` is kept (= `connect.cname`) for old
  clients. `POST …/verify` on a VERIFIED custom domain now runs the check (it used to answer
  `already` without one) - it records reachability but never clears `isVerified`.
- **A proxy that forces HTTPS is fine; one that blocks `/.well-known/` is not.** The check follows
  at most 3 redirects, only to the same domain (http → https allowed, never another host or port),
  and tries `https://` itself when plain HTTP fails. Before the domain is verified it has no
  certificate here, so a proxy that talks HTTPS to the platform (Cloudflare "Full (strict)") fails
  the https step - use plain HTTP to the origin until verified, or "Full" mode.

Fixed in 2.6.14 - until production runs it, expect the old behaviour there:

- **Rate-limit headers on pipeline errors.** Before 2.6.14 a 401, a 403 (`token_read_only`,
  `api_disabled_for_tenant`, …) or a `tenant_not_found` 404 returned no `X-RateLimit-*` headers.
  From 2.6.14 every response charged to a token carries all three headers, `X-RateLimit-Reset`
  included (§4); only `401 unauthenticated` never does. Do not infer "unlimited" from their absence.
- **`GET /tenants/{id}/modules` omitted `config`** for modules with no tenant row (the core
  ones), although the spec marks it required and nullable. From 2.6.14 the key is always present
  (`null` when there is no row). Treat a missing key as `null` on older builds.
- **`GET /modules` returned an undocumented `config`** (group/blocks/enhances/requires, the
  registry's copy of the module definition). It is now in the spec; it was never secret.
- **Paginated lists carry `page` at the root**, which the spec's `additionalProperties:false`
  forbade. The list response schemas (tenants, users, pages, media, members, audit, module records)
  now declare `page` (and media's page-1 `folders`), so strict validation passes. On older builds,
  do not validate list responses strictly.
- **The spec documented only 401/403/429 (and some 404/413/503)** error responses. Every operation
  now documents 400, 401, 403, 404, 409, 413, 415, 429 and 500 with the error envelope and the codes
  each carries, plus its own specifics. The table in §5 stays the readable version.

- **Proxy targets are platform-approved** (2.6.14, website platform v3): `PUT …/website` with
  `forwardType: "proxy"` and `PUT …/website/routing` no longer point the edge at a new upstream
  at once - the target waits for a platform administrator (`defaultApproval` / a rule's
  `pending_approval`), and `PUT …/settings` refuses the `website` routing keys. On older builds the
  same calls applied immediately.
- **`GET /tenants/{id}/modules` grew `kind`, `version` and `licence`** (2026-09-21, the
  Catalogue). Older builds omit them; the rows are otherwise unchanged. `kind: "core"` marks the
  always-on plumbing that has no licence.
- **Module platform v2 (2026-09-23, dev first):** every `GET /tenants/{id}/modules` row also
  carries `moduleKind`, `cost`, `tier`, `paid`, `status`, `request`, `requires` and `features`,
  and `licence` gained `cost` / `currency` / `billingCycle` (the price recorded at activation, in
  minor units - a later re-price never changes it). Prices are in **minor units** everywhere
  (`4900` = R49.00). A paid module an owner tries to switch on through the browser answers
  `403 activation_required` with the terms; through `/v1` the owner files a request instead.
  Older builds omit the new fields and have no `…/requests` route (404). Review 2026-09-24:
  `…/requests` with a token now answers `403 api_disabled_for_tenant` while API Management is off
  for the tenant (it skipped the gate before); `cost.display` is formatted for the tenant's locale
  (en-ZA pinned to `R 49,00`, whatever the server's ICU); a free module an owner re-enables keeps
  the licence platform staff recorded.
- **An expired licence is off** (2026-09-24, module platform polish, BL-038): a row whose
  `licence.expiresAt` has passed now reports `isEnabled: false` with `status: "expired"` (before,
  `isEnabled` stayed `true` while `status` said `expired`), and every module route answers
  `403 module_disabled` for it - the same as a disabled module; the licence and the tenant's data
  stay. There is no grace period. Only platform staff renew it (`PUT …/modules/{m}` with a new
  `expiresAt`); an owner files `POST …/modules/{m}/requests` to ask for the renewal - accepted for
  an expired module whatever its price (`400 it is free` applies to modules that are merely off).
  Older builds let an expired licence through.
- **One id per licence** (2026-09-22): Finance and the shop are switched by the ids `finance`
  and `shop` - the ids their routes gate on and the nav reads. The split
  definitions inside those trees (`invoicing`, `bills`, `payments`, `banking`, `accounting`,
  `financial-reports`, `contacts`, `tax-rates`, `items`, `ecommerce`, `shipping`) are no longer
  listed by `GET …/modules` and `PUT …/modules/invoicing` answers `404 not_found`; licence the
  tree instead (`PUT …/modules/finance`). Older builds accepted the split ids and wrote rows no
  route ever read.
- **Modules that left the platform** (2026-09-23, wave M1): panic, qr-scanner, trust-advisor,
  field-service, social, workflows, crm, mailbox, knowledge-base, live-chat, email-marketing and
  the never-built inventory, seo, payment-gateways, calendar-sync and storage now live in their
  own repositories (incubating; landing-pages was among them until it was deleted on 2026-09-25). None was enabled platform-wide, so no tenant loses
  anything. `GET …/modules` no longer lists them, `PUT …/modules/crm` answers `404 not_found`,
  `…/modules/crm/records` answers `404`, and the webhook event `subscriber.confirmed` is gone
  from the catalogue. They come back through the Catalogue when they are ready.
- **`PATCH /tenants/{id}/pages/{pageId}` takes an optional `expectedUpdatedAt`** (2026-09-22, the
  page editor v2): the `updatedAt` you last read. When the page changed since, the write is
  refused with `409 conflict` and `details.updatedAt` (the current stamp) instead of overwriting
  someone else's draft; omit the field to write unconditionally (older builds ignore it). A body
  holding only the stamp is `400 validation_error` ("nothing to change"). The comparison happens
  on the server against the stored row, by instant - `2026-09-22T10:00:00Z` and
  `2026-09-22T10:00:00.000Z` are the same stamp - so send back the `updatedAt` you read, in any
  ISO 8601 form; nothing else in the body influences the check.

- **The six licensed modules report as hub packages** (2026-09-25, BL-087): in
  `GET /tenants/{t}/modules` the rows of `finance`, `shop`, `booking`, `support`, `blog` and
  `forms` keep `kind: "builtin"` (compiled into the build), their ids, switch, licence, cost and
  status, but `moduleKind` is now `"hub-package"` (was `"builtin"`) and `version` is the package's
  own release (`1.1.0`, was the platform version). Nothing else in the row changed; older builds
  show the previous values.
- **Changing a page's status needs `pages.publish`** (2026-09-25, BL-117). `PATCH` / `PUT
  /tenants/{t}/pages/{p}` with a `status` that differs from the stored one, and `POST
  /tenants/{t}/pages` with `status: "PUBLISHED"`, publish or unpublish the page, so they now need
  `pages.publish` on top of `pages.edit` / `pages.create` - `403 capability_required` with
  `details.capability: "pages.publish"` otherwise, and nothing is written. Before, `pages.edit`
  alone could publish through them. Sending the page's current status (or none) is a plain edit.
  No default role holds edit without publish (DESIGNER and OWNER hold both), so only a tenant
  whose Access Policy denies `pages.publish`, or a group granting `pages.edit` alone, sees the
  difference. Use `POST|DELETE …/pages/{p}/publish` to publish or unpublish.
- **A cancelled order stays cancelled** (2026-09-26, BL-144). `PATCH
  /tenants/{t}/modules/shop/records/{id}` (type `orders`) with `status: "CANCELLED"` was already
  the cancel on `/v1` (stock put back, a paid order `REFUNDED`); Admin → Orders' status change now
  is too - once, however often it is sent, and `409 conflict` for a shipped or delivered order.
  New: moving a CANCELLED order to any other status answers `409 conflict` and changes nothing
  (its stock is already back on the shelf; a reopened order cancelled again would have restocked
  twice). Place a new order instead.

- **Page publishes and menu edits say whether they are live** (2026-10-03). `POST|DELETE
  …/pages/{p}/publish`, `POST …/menus`, `PATCH|DELETE …/menus/{id}` and `POST …/menus/reorder`
  answer with a `publishState` object (`live`, `message`, `activeVersion`, `publishAsNewVersion`)
  beside their usual fields. `POST …/website/versions` takes `activate: true` to snapshot and make
  the version live in one call (`tenant.design.publish` as well, `403 capability_required` and
  nothing written otherwise; audited as `tenant.version.created` + `tenant.version.activated`).
  Older builds return neither: a publish or menu edit on a site with an active version silently
  waited for the next version.

By design (not changing):

- **An active site version is a snapshot** (BLUE-GREEN-DESIGN.md §3). On a builder site with an
  active version, visitors see that version's copy of the published pages and menus - so an
  edit in progress on a published page, a page publish and a menu change never leak into what is
  live, and a rollback really restores the earlier site. A page or menu change is live when a new
  version is activated (`publishState.publishAsNewVersion`). Activating an OLDER version shows
  that old snapshot until the next version is published. Website → Pages → Publish and the page
  editor's Publish do snapshot + activate in one step.

- **Discovery endpoints** (`/v1`, `/openapi.json`, `/claude.md`) need no token and carry no
  rate-limit headers.
- **`GET /tenants/{t}/modules/{m}/settings` has two callers and two answers** (2026-09-24). With
  a bearer or session, secrets are `••••••••` / `""`. With `X-CF-Spoke: <m>` + `X-Edge-Secret`
  (the spoke's own derived secret - only the hub and that spoke know it) the same URL needs no
  token and answers the secrets in the clear; a wrong or missing secret is a plain
  `401 unauthenticated`, so the header pair discloses nothing. There is no token that can read a
  spoke's secrets.
- **Website status lags a release by one sweep.** The checker runs on the LIVE colour only
  (the standby never probes, so a soak never double-counts), and right after a cut-over the
  conductor asks the new colour for one `source: "deploy"` sweep; until that sweep has landed
  (seconds) `GET /tenants/{t}/status` shows the previous build's rows. `GET /v1/status`'s
  `tenants` counts are the last sweep of THIS process - `checked: 0` right after a start means
  "not swept yet", not "nothing online".

## 9. Testing tools

Both scripts live in the repository under `docs/api/checks/` and need only a token in `CF_TOKEN`.

| Tool                              | What it does                                                                                                                                                                                                                                    |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cf-api-check.sh`                 | `CF_TOKEN=cfp_… bash cf-api-check.sh` - a 460-check read-scope pass: discovery, auth failure modes, platform reads, the tenant routes of every tenant, read-only enforcement, 404/405 handling. Writes `cf-api-results.md` beside itself. |
| `cf-validate.py`                  | `CF_TOKEN=cfp_… python cf-validate.py` - fetches every reachable GET and validates the body against the OpenAPI 200 schema (`pip install jsonschema`).                                                                                          |
| `scripts/api-guide-reference.mts` | `npx tsx scripts/api-guide-reference.mts --write --label "<build>"` - regenerates §11 of this guide from the OpenAPI document the repository builds (no server needed; `--from openapi.json` renders a saved copy instead).                     |
| `docs/UAT/api-v1.md`              | The dev write-scope UAT script and results: pages, menus, media, module data, theme, SEO, domains.                                                                                                                                              |

A write-scope pass on a tenant needs a write token of a member with the capabilities the pass
exercises (OWNER for the settings and routing writes).

## 10. Reporting back

Say which instance, which tenant (name and slug), what you read, what you changed (resource and
fields), whether it is published or still draft, and that the audit trail is under the person's
name. Keep the token out of the report.

---

## 11. Operation reference (generated from `openapi.json`, this build (2026-10-08), 156 operations)

Paths are relative to the base URL. "module on" = that module (blog, forms, …) must be on for the tenant. **write** = needs a
write token. Bold body fields are required.

### Self

- `GET /me` - Who am I and what can I reach  
  Permission: any valid token. Success: 200.
  The first call every client makes: the person behind the token, the token’s scope and restrictions, every tenant they belong to with their role and their effective capabilities there. `apiEnabled` is always `true` since 2026-09-25 (the API is part of the core; kept for existing clients).
- `GET /tokens` - List my API tokens  
  Permission: any valid token. Success: 200.
  Query: `includeRevoked`: true|false.
- `POST /tokens` - Create another token for myself · **write**  
  Permission: write token. Success: 201.
  Body: **name**: string, scope: read|write, tenantIds: string[], allTenants: boolean, expiresInDays: integer, noExpiry: boolean.
  Scope, tenants and lifetime can never exceed the calling token’s: a tenant-restricted token cannot create an all-tenant token or one for another tenant (403), an expiring token cannot create one without expiry (403), and a requested expiry past the calling token’s is cut to it. The secret is in the response once.
- `DELETE /tokens/{id}` - Revoke one of my tokens  
  Permission: any token (revocation only removes access). Success: 200.

### Tenants

- `GET /tenants` - List tenants  
  Permission: any valid token (scope depends on platform role). Success: 200.
  Query: `page`, `limit`: integer, `status`: ACTIVE|SUSPENDED|PENDING|ARCHIVED, `q`.
  Platform SUPPORT and up see every tenant; anyone else sees the tenants they are a member of (as in GET /me).
- `POST /tenants` - Create a tenant · **write**  
  Permission: platform GLOBAL_ADMIN. Success: 201.
  Body: **name**: string, slug: string, domains: string[], planId: string, primaryColor: string, secondaryColor: string, accentColor: string, ownerEmail: string, applyDefaultTemplate: boolean.
  Creates the tenant (ACTIVE), its system domain `<guid>.cforge.co.za`, any custom domains (unverified, so none is primary yet - the first one verified becomes primary; the system domain never is), the default website template, and - when `ownerEmail` names an existing user - an OWNER membership.
- `DELETE /tenants/{tenantId}` - Delete a tenant and everything in it · **write**  
  Permission: platform SUPER_ADMIN. Success: 200.
  Irreversible. Needs the person’s explicit confirmation naming the tenant.
- `GET /tenants/{tenantId}` - A tenant  
  Permission: member of the tenant, or platform SUPPORT. Success: 200.
  Members get the branding view (`tenantSummary`); platform SUPPORT and up get the full record with domains, plan and counts.
- `PATCH /tenants/{tenantId}` - Change a tenant’s name, branding, status or plan · **write**  
  Permission: 'tenant.settings.edit' for name/branding; platform GLOBAL_ADMIN for status/plan. Success: 200.
  Body: name: string, logo: string?, favicon: string?, primaryColor: string, secondaryColor: string, accentColor: string, status: ACTIVE|SUSPENDED|PENDING|ARCHIVED, planId: string?, planExpiresAt: string?.
  Name and branding: `tenant.settings.edit` (OWNER) - the name is what the site shows in its header, footer and title; Admin → Settings → General writes it here. Status and plan: platform GLOBAL_ADMIN.
- `GET /tenants/{tenantId}/domains` - A tenant's domains  
  Permission: any ACTIVE member, or platform SUPPORT. Success: 200.
- `POST /tenants/{tenantId}/domains` - Add a custom domain (unverified until checked) · **write**  
  Permission: platform GLOBAL_ADMIN. Success: 201.
  Body: **domain**: string, isPrimary: boolean.
  Point its DNS at the platform, then `POST …/domains/{domainId}/verify`; a certificate is issued once `isVerified` is set. A new domain is never primary while unverified (`isPrimary: true` is `400 validation_failed`); it becomes primary when it is verified if the tenant has no primary then.
- `DELETE /tenants/{tenantId}/domains/{domainId}` - Remove a domain · **write**  
  Permission: platform GLOBAL_ADMIN. Success: 200.
  The system domain cannot be removed; a tenant keeps at least one domain. Removing the PRIMARY promotes the oldest remaining VERIFIED custom domain - never the system domain or an unverified one; with none the tenant has no primary (`promoted: null`). Confirm with the person first.
- `PATCH /tenants/{tenantId}/domains/{domainId}` - Mark a domain primary and/or verified · **write**  
  Permission: platform GLOBAL_ADMIN. Success: 200.
  Body: isPrimary: boolean, isVerified: boolean.
  Only a VERIFIED custom domain can be primary: `isPrimary: true` on the system domain (`isSystem`, the platform `{guid}` address) or on an unverified domain is refused (400 `validation_failed`); `{"isPrimary":true,"isVerified":true}` verifies and promotes in one step. Making a domain primary un-marks the previous one; marking a domain verified makes it primary when the tenant has none; un-verifying the primary is refused.
- `GET /tenants/{tenantId}/domains/{domainId}/verify` - How to connect a domain, and its last reachability check (no lookup)  
  Permission: 'tenant.domains.manage' (OWNER). Success: 200.
  Proves that traffic for a custom domain reaches THIS platform (reachability, like ACME HTTP-01 - there is no TXT record). The owner points the domain at the platform (`connect`: a CNAME to `connect.cname.target` - the tenant's platform address `{guid}.cforge.co.za` - or A/AAAA records to `connect.a` / `connect.aaaa`, directly or through a proxy such as Cloudflare; `connect.recommended` says which to offer first: `a` for an apex (`connect.apex`, which usually cannot be a CNAME), `cname` for a subdomain such as `www.`); the check resolves the domain (informative) and requests `http://<domain>/.well-known/cloudforge-verify/<token>`, then https:// if plain HTTP fails. Verified ⇔ the platform answered its own HMAC proof. On the first success the domain becomes `isVerified`, which is what lets the edge issue its certificate. A check never unverifies a domain: a verified domain that stops reaching the platform for 72 hours is `reachability.flagged` (the daily re-check tells the owners). The system domain is always verified. `sibling` (null when fine) is the www / apex twin of the same tenant when it needs attention: its row is unverified, or it resolves (`addresses`) somewhere other than the platform edge (`pointsToPlatform: false`) - visitors who type that name do not reach this site.
- `POST /tenants/{tenantId}/domains/{domainId}/verify` - Check now that the domain reaches the platform; verify it on success · **write**  
  Permission: 'tenant.domains.manage' (OWNER). Success: 200.
  Body: rotateToken: boolean.
  Proves that traffic for a custom domain reaches THIS platform (reachability, like ACME HTTP-01 - there is no TXT record). The owner points the domain at the platform (`connect`: a CNAME to `connect.cname.target` - the tenant's platform address `{guid}.cforge.co.za` - or A/AAAA records to `connect.a` / `connect.aaaa`, directly or through a proxy such as Cloudflare; `connect.recommended` says which to offer first: `a` for an apex (`connect.apex`, which usually cannot be a CNAME), `cname` for a subdomain such as `www.`); the check resolves the domain (informative) and requests `http://<domain>/.well-known/cloudforge-verify/<token>`, then https:// if plain HTTP fails. Verified ⇔ the platform answered its own HMAC proof. On the first success the domain becomes `isVerified`, which is what lets the edge issue its certificate. A check never unverifies a domain: a verified domain that stops reaching the platform for 72 hours is `reachability.flagged` (the daily re-check tells the owners). The system domain is always verified. `sibling` (null when fine) is the www / apex twin of the same tenant when it needs attention: its row is unverified, or it resolves (`addresses`) somewhere other than the platform edge (`pointsToPlatform: false`) - visitors who type that name do not reach this site. DNS changes take minutes to hours to propagate; a failed check reports every step (`checks[]`: dns, http, https) so the person can see where the chain breaks. Body (optional): `{"rotateToken": true}` issues a new token first. Limited to 6 per domain per minute (429 `rate_limited`).

### Tenant settings

- `GET /tenants/{tenantId}/settings` - A tenant's settings, grouped by category  
  Permission: member with 'settings.view' (DESIGNER, ADMIN, OWNER). Success: 200.
  The tenant's own preferences - `general` (time zone, locale, date format, currency), `seo`, `analytics`, `api` (`allowed_origins`), `status` (`check_all_domains`), `website.verification_files`, module settings; Admin → Settings → All settings lists every one and where it is edited. A credential key comes back as `••••••••` when set and `""` when not - never the value. Platform-wide policy is not here: platform staff set it under Global → Settings.
- `PUT /tenants/{tenantId}/settings` - Merge settings into a tenant · **write**  
  Permission: 'tenant.settings.edit' (OWNER). Success: 200.
  Body: object.
  Sends `{ category: { key: value } }`; only the keys you send change. Value types are inferred (boolean, number, object → json, else string). Credential keys are stored encrypted; sending `••••••••` (what a read shows) keeps the stored value and `""` clears it. Changing anything under `website` re-syncs the proxy route. The routing keys under `website` (`mode`, `forward_*`, `auth_mode`, `require_membership`, `default_access`, `default_group_ids`) are refused here (400 `validation_failed`): change them through `PUT …/website/routing`, where proxy targets face platform approval. So is the `sso` category: single sign-on is switched with `PUT …/sso`. `website.verification_files` (site verification files for Google, Bing, …: a list of `{name, content}`, a root `.html` / `.txt` / `.xml` name, at most 10 × 4 KB) is validated and answered on every domain in every website mode. Platform-only settings (platform-staff keys in the settings registry, e.g. `website.platform_credit`, the Powered by Cloudforge credit, on by default) answer 403 `platform_role_required` for tenant callers. Audited as `tenant.settings.updated` with the key names only.
- `GET /tenants/{tenantId}/website` - What a tenant's domains serve (website mode)  
  Permission: member with 'settings.view'. Success: 200.
- `PUT /tenants/{tenantId}/website` - Switch the website mode or forward target · **write**  
  Permission: 'tenant.settings.edit' (OWNER). Success: 200.
  Body: **mode**: blocks|static|forward|custom|template, forwardUrl: string, forwardType: redirect|proxy, preservePath: boolean, authMode: none|always|portal.
  `blocks` = pages built in CloudForge · `static` = an uploaded site (must already exist) · `forward` = redirect or proxy to `forwardUrl` · `custom` · `template`. The default handler of the routing table (`PUT …/website/routing` is the full API): a proxy `forwardUrl` that is not yet approved for the tenant is recorded for platform review (`routing.defaultPending: true`) while the previous mode keeps serving; redirects apply at once. No forward target may contain `{` or `}` (raw or `%7B` / `%7D`); no forward target (proxy, or an absolute redirect) may name loopback, link-local / cloud metadata, the unspecified / CGNAT / multicast ranges, the platform's own services, slots or hostnames, or a reserved port on any host (2019, 5432, 5433, 6379, 9400, 3100, 9229; §8, 2026-10-12) - `400 validation_failed`. Re-syncs the proxy route. Switching mode changes what visitors see at once: confirm with the person first.

### Website

- `GET /templates` - The approved site templates (public metadata)  
  Permission: any token or session. Success: 200.
  Bundled with the platform (docs/design/website-platform-v3.md §4). Install one into a tenant with POST /tenants/{tenantId}/website/templates/{slug}/install; preview images are under /templates/{slug}/previews/{file}.
- `GET /templates/{slug}` - One site template: pages, required modules, previews  
  Permission: any token or session. Success: 200.
- `GET /templates/{slug}/previews/{file}` - A template preview image (SVG or PNG)  
  Permission: any token or session. Success: 200.
- `GET /tenants/{tenantId}/menus` - Menu items of a location, or the list of locations  
  Permission: 'menus.view' (DESIGNER, OWNER). Success: 200.
  Query: `menu`.
- `POST /tenants/{tenantId}/menus` - Add a menu item · **write**  
  Permission: 'menus.edit'. Success: 201.
  Body: **menu**: string, **label**: string, url: string, pageId: string, icon: string, parentId: string, order: integer, isVisible: boolean, requiresAuth: boolean, requiredRoles: string[], openInNewTab: boolean.
  On a site with an active site version the menu change is saved but visitors keep seeing that version's menus until a new version is made live - see `publishState`.
- `POST /tenants/{tenantId}/menus/reorder` - Reorder / re-parent menu items (all-or-nothing) · **write**  
  Permission: 'menus.edit'. Success: 200.
  Body: **items**: {id, order, parentId}[].
  On a site with an active site version the new order is saved but visitors keep seeing that version's menus until a new version is made live - see `publishState`.
- `DELETE /tenants/{tenantId}/menus/{itemId}` - Delete a menu item (and its children) · **write**  
  Permission: 'menus.edit'. Success: 200.
  On a site with an active site version the menu change is saved but visitors keep seeing that version's menus until a new version is made live - see `publishState`.
- `PATCH /tenants/{tenantId}/menus/{itemId}` - Change a menu item · **write**  
  Permission: 'menus.edit'. Success: 200.
  Body: label: string, url: string, pageId: string, icon: string, parentId: string, order: integer, isVisible: boolean, requiresAuth: boolean, requiredRoles: string[], openInNewTab: boolean.
  On a site with an active site version the menu change is saved but visitors keep seeing that version's menus until a new version is made live - see `publishState`.
- `GET /tenants/{tenantId}/pages` - A tenant's pages (block pages)  
  Permission: 'pages.view' (DESIGNER, OWNER). Success: 200.
  Query: `page`, `limit`: integer.
- `POST /tenants/{tenantId}/pages` - Create a page (draft unless status says otherwise) · **write**  
  Permission: 'pages.create' (DESIGNER, OWNER; + 'pages.publish' to create it published). Success: 201.
  Body: slug: string, **title**: string, content: {id, type, props, children}[], template: string, metaTitle: string, metaDescription: string, metaKeywords: string, ogImage: string, isPublic: boolean, status: DRAFT|PUBLISHED.
  `content` is the block array the editor writes (`{id,type,props,children?}`). Slug is derived from the title when omitted. A slug may be nested (`services/security`, served at `/services/security`); its first segment may not be a platform path (`admin`, `portal`, `api`, `signin`, …). Creating it with `status: "PUBLISHED"` needs `pages.publish` as well (403 `capability_required` otherwise; BL-117).
- `DELETE /tenants/{tenantId}/pages/{pageId}` - Delete a page · **write**  
  Permission: 'pages.delete'. Success: 200.
  Irreversible; confirm with the person first.
- `GET /tenants/{tenantId}/pages/{pageId}` - A page with its blocks  
  Permission: 'pages.view' (previewToken only with 'pages.edit'). Success: 200.
  Includes `previewToken` for callers who also hold `pages.edit` (null otherwise - the token lets anyone who has it see the draft): open `https://<tenant domain>/<slug>?preview=<previewToken>` to see the current draft as visitors would, whatever the page status or the active site version.
- `PATCH /tenants/{tenantId}/pages/{pageId}` - Change some fields of a page (draft change; publish separately) · **write**  
  Permission: 'pages.edit' (+ 'pages.publish' to change status). Success: 200.
  Body: slug: string, title: string, content: {id, type, props, children}[], template: string, metaTitle: string, metaDescription: string, metaKeywords: string, ogImage: string, isPublic: boolean, status: DRAFT|PUBLISHED, expectedUpdatedAt: string.
  A `status` that differs from the stored one publishes or unpublishes the page and needs `pages.publish` as well (403 `capability_required` otherwise; BL-117). Send `expectedUpdatedAt` (the `updatedAt` you last read) to refuse the write with 409 `conflict` when the page changed in between - the editor does this on every save. The server compares the stamp with the stored row by instant (any ISO 8601 precision), never trusting what the client says about the page.
- `PUT /tenants/{tenantId}/pages/{pageId}` - Replace a page (all fields) · **write**  
  Permission: 'pages.edit' (+ 'pages.publish' to change status). Success: 200.
  Body: slug: string, **title**: string, content: {id, type, props, children}[], template: string, metaTitle: string, metaDescription: string, metaKeywords: string, ogImage: string, isPublic: boolean, status: DRAFT|PUBLISHED.
  A `status` that differs from the stored one publishes or unpublishes the page and needs `pages.publish` as well (403 `capability_required` otherwise; BL-117).
- `DELETE /tenants/{tenantId}/pages/{pageId}/publish` - Unpublish a page (back to draft) · **write**  
  Permission: 'pages.publish'. Success: 200.
  On a site with an active site version the change is saved but visitors keep seeing that version until a new one is made live - `publishState.live` is false and `publishState.publishAsNewVersion` is the call that does it. Without an active version it is live at once.
- `POST /tenants/{tenantId}/pages/{pageId}/publish` - Publish a page · **write**  
  Permission: 'pages.publish' (DESIGNER, OWNER). Success: 200.
  On a site with an active site version the change is saved but visitors keep seeing that version until a new one is made live - `publishState.live` is false and `publishState.publishAsNewVersion` is the call that does it. Without an active version it is live at once.
- `GET /tenants/{tenantId}/website/archive` - Download a static or template site as a ZIP  
  Permission: member with 'settings.view'. Success: 200.
  The whole site (same files GET …/website/files lists; reserved dot-directories excluded) as one ZIP, `Content-Disposition: attachment; filename="<slug>-site.zip"`. The same archive re-uploads with POST …/website/deploy. 404 for tenants whose site is not files (`blocks`, `forward`, `custom`) or with nothing deployed; 413 above 100 MB uncompressed.
- `POST /tenants/{tenantId}/website/deploy` - Upload a site (ZIP) and serve it · **write**  
  Permission: 'tenant.design.publish' (DESIGNER, OWNER). Success: 201.
  Body: `multipart/form-data`: file.
  multipart/form-data with one `file` part (a .zip with index.html - or, for a template site, index.liquid - at the root or one folder down; 100 MB max). The new version goes live at once - confirm with the person first. A tenant in `template` mode stays in `template` mode when the ZIP holds at least one `.liquid` file at the root or one folder down; every other tenant switches to `static` mode. The response says which (`mode`).
- `GET /tenants/{tenantId}/website/files` - List a static or template site's files  
  Permission: member with 'settings.view'. Success: 200.
  Every file of the site a `static` or `template` tenant serves, recursively, as site-relative paths (`index.html`, `css/site.css`). Reserved dot-directories and dotfiles are never listed. Capped at 5000 entries (`truncated: true` past that). For `blocks`, `forward` and `custom` tenants the list is empty and `mode` says why - those sites are not files. Read one with GET …/website/files/{path}, the whole site with GET …/website/archive.
- `DELETE /tenants/{tenantId}/website/files/{path}` - Delete one file of a static or template site · **write**  
  Permission: 'tenant.design.publish' (DESIGNER, OWNER). Success: 200.
  Same rules as PUT (any file, not only text): file-based modes only (409 `mode_not_file_based`), a snapshot of the current site is taken first, the site without the file is published as a new version. 404 when the file is not there. Undo with POST …/website/versions/rollback `{"mode":"STATIC"}`.
- `GET /tenants/{tenantId}/website/files/{path}` - Download one file of a static or template site  
  Permission: member with 'settings.view'. Success: 200.
  The raw file, not JSON: text types as `text/*; charset=utf-8` (`.liquid` as `text/plain`), others by extension, unknown as `application/octet-stream`. Always `Content-Disposition: attachment` (an API, never a viewer); every file carries a sandbox `Content-Security-Policy` (markup, SVG and XML: `default-src 'none'; sandbox`). Files over 25 MB answer 413; paths that leave the site, name a dot-directory or a symbolic link answer 404.
- `PUT /tenants/{tenantId}/website/files/{path}` - Write one text file of a static or template site · **write**  
  Permission: 'tenant.design.publish' (DESIGNER, OWNER). Success: 200.
  Body: `text/plain`.
  Body = the file's new content, raw (`Content-Type: text/plain` or another `text/*`, `application/json`), at most 2 MB; only these extensions: html, htm, liquid, css, js, mjs, json, txt, xml, svg, md, webmanifest (415 otherwise). Only for tenants in `static` or `template` mode (409 `mode_not_file_based`). Before the change the current site is snapshotted as a site version, then the file is published as a new version - live at once. Undo with POST …/website/versions/rollback `{"mode":"STATIC"}`. Parent folders are created as needed.
- `GET /tenants/{tenantId}/website/routing` - The tenant's routing table: default handler, forward and access, ordered rules  
  Permission: 'tenant.routing.manage' (OWNER). Success: 200.
  Routing (docs/design/website-platform-v3.md §2): the default handler is what `GET …/website` calls `mode` (`builder` = blocks/custom/template, `static`, `forward`); rules map a path prefix to a handler and an access level, longest prefix wins. `reservedPaths` can never be matched by a rule.
- `PUT /tenants/{tenantId}/website/routing` - Replace the routing table (default handler + all rules) atomically · **write**  
  Permission: 'tenant.routing.manage' (OWNER). Success: 200.
  Body: **defaultHandler**: builder|static|forward, defaultForward: {type, target, redirectStatus, preservePath}?, defaultAccess: {level, groupIds}, **rules**: {id, pathPrefix, handler, forward, access, status}[].
  The whole table is validated and written in one transaction: rules not in the body are removed. Rules keep their id and approval when the prefix is unchanged. Proxy targets need platform approval (rules and the default handler alike): a `forward`/`proxy` rule with a new or changed target is stored as `pending_approval` and is not served until a platform administrator approves it (System → Tenants); a default handler pointing at a proxy target that is not yet approved for the tenant is recorded in `defaultApproval` while the previous default keeps serving. Redirects go live at once. No forward target may contain `{` or `}` (raw or `%7B` / `%7D`); no forward target (proxy, or an absolute redirect) may name loopback, link-local / cloud metadata, the unspecified / CGNAT / multicast ranges, the platform's own services, slots or hostnames, or a reserved port on any host (2019, 5432, 5433, 6379, 9400, 3100, 9229; §8, 2026-10-12) - `400 validation_failed`. `defaultHandler: "static"` needs a deployed site. `access.groupIds` must be active groups of the tenant. Visitors see the change at once: confirm with the person first.
- `POST /tenants/{tenantId}/website/templates/{slug}/install` - Install a site template into the tenant (drafts in a new version, never deletes) · **write**  
  Permission: 'tenant.design.edit'. Success: 201.
  Body: applyTheme: boolean, menus: fill|merge|none, sampleContent: boolean, pages: string[], existingPages: keep|replace.
  Pages are written as DRAFTs and a DRAFT site version is prepared - nothing visitors see changes until the pages are published (Website → Pages → Publish) or the version is activated (Website → Publish). Existing pages are kept by default (the template’s copy lands under a suffixed slug); menus fill empty locations only; the theme is applied only with applyTheme (live at once). A required module that is off for the tenant is a warning, not a refusal - its blocks render nothing until it is enabled under Extensions.
- `GET /tenants/{tenantId}/website/versions` - Site versions (snapshots) for a mode  
  Permission: 'tenant.design.edit'. Success: 200.
  Query: `mode` (required): BLOCKS|STATIC.
- `POST /tenants/{tenantId}/website/versions` - Snapshot the current site as a new (draft) version · **write**  
  Permission: 'tenant.design.edit'. Success: 201.
  Body: **mode**: BLOCKS|STATIC, **label**: string, activate: boolean.
  BLOCKS snapshots the published pages and the main/footer menus; STATIC snapshots the active static deploy. Activate it with POST …/versions/{versionId}/activate - or send `activate: true` to snapshot and make it live in one call (needs `tenant.design.publish` too; visitors see it at once - confirm with the person first). This is the "Publish as new version" call that page-publish and menu-write responses point to in `publishState.publishAsNewVersion`.
- `POST /tenants/{tenantId}/website/versions/rollback` - Roll back to the previously active version · **write**  
  Permission: 'tenant.design.publish'. Success: 200.
  Body: **mode**: BLOCKS|STATIC.
- `POST /tenants/{tenantId}/website/versions/{versionId}/activate` - Make a site version live · **write**  
  Permission: 'tenant.design.publish'. Success: 200.
  The current active version is retired (kept for rollback). Visitors see the change at once - confirm with the person first.

### Media

- `GET /tenants/{tenantId}/media` - A tenant's media library (files), newest first  
  Permission: 'media.view' (DESIGNER, OWNER). Success: 200.
  Query: `page`, `limit`: integer, `folder`, `kind`: image|video|audio|document|font|archive|other, `search`.
  Filter by `folder` (exact path; empty string = the root), `kind` (image, video, audio, document, font, archive, other) and `search` (name, original name, alt). The response also lists the folders in use under `folders` on page 1.
- `POST /tenants/{tenantId}/media` - Upload a file to the media library · **write**  
  Permission: 'media.upload' (DESIGNER, OWNER). Success: 201.
  Body: `multipart/form-data`: file.
  multipart/form-data with one `file` part and optional text parts `name`, `folder`, `alt`, `caption`. The type is taken from the file extension against the platform allow-list (Global → Settings → Storage: allowed file types, max size). The returned `url` is served by every CloudForge host and never changes.
- `DELETE /tenants/{tenantId}/media/{mediaId}` - Delete a file from the media library · **write**  
  Permission: 'media.delete'. Success: 200.
  Irreversible, and pages that embed its URL will show a broken image - confirm with the person first.
- `GET /tenants/{tenantId}/media/{mediaId}` - One media record (metadata and its URL)  
  Permission: 'media.view'. Success: 200.
- `PATCH /tenants/{tenantId}/media/{mediaId}` - Change a media record’s name, alt text, caption, folder or tags · **write**  
  Permission: 'media.upload'. Success: 200.
  Body: name: string, alt: string?, caption: string?, folder: string?, tags: string[].
  The file itself and its URL never change; upload a new file for new content.

### Design

- `GET /tenants/{tenantId}/pages/{pageId}/seo` - A page's SEO fields  
  Permission: 'pages.view'. Success: 200.
  `title` is the meta title (null = the page title), plus description, keywords, canonical URL, noindex and the Open Graph image. Site-level defaults (`GET …/seo`) fill in what a page leaves null.
- `PATCH /tenants/{tenantId}/pages/{pageId}/seo` - Change a page's SEO fields (draft change; publish separately) · **write**  
  Permission: 'pages.edit'. Success: 200.
  Body: title: string?, description: string?, keywords: string?, canonicalUrl: string?, noIndex: boolean, og: {image}.
  Like every page edit this changes the draft; the live site picks it up on the next `POST …/pages/{pageId}/publish`.
- `GET /tenants/{tenantId}/seo` - A tenant's site-level SEO settings  
  Permission: 'settings.view' (DESIGNER, ADMIN, OWNER). Success: 200.
  Title template (`%s` = page title), default description and social image (used when a page has none), robots index/follow (applied to every page) and the sitemap switch. Defaults are filled in.
- `PATCH /tenants/{tenantId}/seo` - Change site-level SEO settings (null restores a default) · **write**  
  Permission: 'tenant.settings.edit' (OWNER). Success: 200.
  Body: titleTemplate: string, defaultDescription: string?, socialImage: string?, robotsIndex: boolean, robotsFollow: boolean, sitemapEnabled: boolean.
  Takes effect on the live site at once (no publish step). `robotsIndex:false` de-indexes the whole site - confirm with the person first.
- `GET /tenants/{tenantId}/theme` - A tenant's theme (branding, fonts, colours, custom CSS)  
  Permission: 'settings.view' (DESIGNER, ADMIN, OWNER). Success: 200.
  One object over the two stores the website renderer reads: the tenant’s branding (logo, favicon, the three brand colours) and its `theme` settings (Google font names, surface colours, custom CSS, whether the header shows the tenant name). Fonts are Google Fonts family names; colours are hex (brand) or any CSS colour (surfaces). `customCss` is injected verbatim into the site.
- `PATCH /tenants/{tenantId}/theme` - Change some theme keys (null removes a key) · **write**  
  Permission: 'tenant.settings.edit' (OWNER). Success: 200.
  Body: logo: string?, favicon: string?, primaryColor: string, secondaryColor: string, accentColor: string, headingFont: string?, bodyFont: string?, backgroundColor: string?, foregroundColor: string?, cardColor: string?, cardForegroundColor: string?, borderColor: string?, mutedColor: string?, mutedForegroundColor: string?, customCss: string?, showTenantName: boolean.
- `PUT /tenants/{tenantId}/theme` - Replace the theme · **write**  
  Permission: 'tenant.settings.edit' (OWNER). Success: 200.
  Body: logo: string?, favicon: string?, **primaryColor**: string, **secondaryColor**: string, **accentColor**: string, headingFont: string?, bodyFont: string?, backgroundColor: string?, foregroundColor: string?, cardColor: string?, cardForegroundColor: string?, borderColor: string?, mutedColor: string?, mutedForegroundColor: string?, customCss: string?, showTenantName: boolean.
  Every key not sent is reset: brand colours to the platform defaults (the three are required), logo/favicon to none, fonts/surface colours/custom CSS removed, the tenant name shown. Read first; prefer PATCH.

### Module data

- `GET /tenants/{tenantId}/modules/{moduleId}/records` - A module's record types, or a list of records of one type  
  Permission: the record type's own capability (e.g. 'blog.view', 'orders.view') · module on. Success: 200.
  Query: `page`, `limit`: integer, `type`.
  Without `type`: the record types the module exposes, with the capability each operation needs and the filters each list accepts. With `type`: a paginated list, filtered by that type's query parameters (e.g. `?type=posts&status=PUBLISHED`). Record types - forms: forms, submissions · blog: posts, categories · booking: services, bookings · ecommerce: products, categories, orders. The module must be ON for the tenant (403 `module_disabled` otherwise).
- `POST /tenants/{tenantId}/modules/{moduleId}/records` - Create a record of a type (`?type=`) · **write**  
  Permission: the record type's manage/create capability · module on. Success: 201.
  Query: `type` (required).
  Body: object.
  The body shape is the record type’s create schema (see `GET …/records` for the types; validation errors name the fields). Types without create: bookings, orders, submissions.
- `DELETE /tenants/{tenantId}/modules/{moduleId}/records/{recordId}` - Delete a record (`?type=`) · **write**  
  Permission: the record type's manage/delete capability · module on. Success: 200.
  Query: `type` (required).
  Irreversible; confirm with the person first. Bookings and orders cannot be deleted (cancel them).
- `GET /tenants/{tenantId}/modules/{moduleId}/records/{recordId}` - One record (`?type=`)  
  Permission: the record type's view capability · module on. Success: 200.
  Query: `type` (required).
- `PATCH /tenants/{tenantId}/modules/{moduleId}/records/{recordId}` - Change a record (`?type=`); status changes go here too · **write**  
  Permission: the record type's manage/edit capability · module on. Success: 200.
  Query: `type` (required).
  Body: object.
  The body is the record type’s update schema. Status changes are `{ "status": … }` (bookings: `{ status, reason? }`; orders: `{ status?, paymentStatus?, trackingNumber?, … }`).

### Backups

- `GET /tenants/{tenantId}/backups` - Backup jobs (exports and restores), newest first  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
- `POST /tenants/{tenantId}/backups` - Start an export (ZIP to download, or push to the connected GitHub repository) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 202.
  Body: target: zip|github, template: {name, category, summary, previews}.
  Async: poll GET …/backups/{jobId} until `status` is done. `target: "github"` needs a target (PUT …/backups/github). `template` produces a Catalogue template tree (cloudforge-template.json, no tenant data) instead of a tenant backup. One job per tenant at a time (409).
- `POST /tenants/{tenantId}/backups/check` - Check several stored backups (see …/backups/{jobId}/check) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Body: **jobIds**: string[].
  Up to 100 ids. Each is checked in turn; an id that is not a stored backup of this tenant is reported in `failed`, the others still run.
- `POST /tenants/{tenantId}/backups/delete` - Delete several backups (archive + job row each, audited) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Body: **jobIds**: string[].
  Up to 100 ids, all looked up under this tenant. Queued or running jobs, and ids that are not this tenant’s, are reported in `failed`; the rest are deleted. GitHub-target backups keep their commit on the repository.
- `DELETE /tenants/{tenantId}/backups/github` - Disconnect the GitHub backup repository (the token is deleted; commits stay on GitHub) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
- `GET /tenants/{tenantId}/backups/github` - The connected GitHub backup repository (never the token)  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
- `PUT /tenants/{tenantId}/backups/github` - Connect or update the GitHub backup repository · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Body: **owner**: string, **repo**: string, branch: string, pathPrefix: string, schedule: manual|daily|weekly, token: string.
  A fine-grained personal access token with `contents: write` on that ONE repository. The repository is checked before saving (the token must see it and be allowed to push). The token is encrypted at rest and never returned, logged or exported; omit `token` on later calls to keep the stored one. `schedule`: manual | daily | weekly (daily/weekly run in the platform nightly window).
- `GET /tenants/{tenantId}/backups/github/commits` - Recent backup commits on the connected repository (pick one to restore from)  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
- `POST /tenants/{tenantId}/backups/restore` - Preview, then restore, a tenant export (ZIP upload or a GitHub commit) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Body: github: {commit}, preview: boolean, confirm: string, policy: {sections, modules}, restoreInto: string, restoreIntoName: string.
  Two forms: multipart/form-data with `file` (the ZIP) plus text fields `preview`, `confirm`, `policy` (JSON); or JSON `{ "github": { "commit": "<sha>" }, "preview": true }`. Call with `preview: true` first (counts per section, modules, secrets that need re-entry, warnings), then again with `confirm: "RESTORE <slug>"` to queue the job. Restored pages, menus, media, site files and theme become a DRAFT site version - nothing is live until it is activated. `policy.modules.<id>.policy = "replace"` needs `policy.modules.<id>.confirm` with the same phrase. `restoreInto` (clone into a new tenant) is a System-route feature.
- `GET /tenants/{tenantId}/backups/retention` - Backup retention, totals and the last nightly housekeeping report  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
- `PUT /tenants/{tenantId}/backups/retention` - Change backup retention (keep the last N archives, none older than D days) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Body: count: integer, days: integer.
  Defaults 5 / 90. Applied after every export and by the nightly housekeeping; the newest archive is never removed by age.
- `DELETE /tenants/{tenantId}/backups/{jobId}` - Delete a backup: its stored archive and its job row (audited) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Never a queued or running job (409 - cancel it first). A GitHub-target backup loses its row and local copy only: the commit stays on the repository (`commitKept`).
- `GET /tenants/{tenantId}/backups/{jobId}` - One backup job with progress and log lines (poll while running)  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
- `POST /tenants/{tenantId}/backups/{jobId}/cancel` - Cancel a queued or running backup job · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  A queued job is cancelled at once; a running one stops at its next progress tick.
- `POST /tenants/{tenantId}/backups/{jobId}/check` - Check a stored backup: re-open the archive and verify it (nothing is restored) · **write**  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  Verifies the archive sha256 recorded at export, that every entry inflates whole (size + CRC-32), that the manifest and every section parse, and that every media file matches its sha256 in the media index. The result is stored on the job (`checkedAt`, `checkStatus` ok | failed, `checkDetail`).
- `GET /tenants/{tenantId}/backups/{jobId}/download` - Download a finished export as a ZIP (the tenant export tree)  
  Permission: 'tenant.backups.manage' (OWNER). Success: 200.
  application/zip; 404 when the job is not a finished export or its archive was removed by retention (the last 5 exports per tenant are kept).

### Members

- `GET /tenants/{tenantId}/access-policy` - A tenant's Access Policy (role overrides) and the effective capability matrix  
  Permission: 'tenant.policy.manage' (ADMIN, OWNER). Success: 200.
- `PUT /tenants/{tenantId}/access-policy` - Apply policy changes (ALLOW / DENY / null to clear) · **write**  
  Permission: 'tenant.policy.manage'. Success: 200.
  Body: **changes**: {role, capability, effect}[].
  Owner-only capabilities can never be allowed to a lesser role; platform capabilities never to any tenant role.
- `GET /tenants/{tenantId}/members` - A tenant's members and roles  
  Permission: 'tenant.members.manage' (ADMIN, OWNER). Success: 200.
  Query: `page`, `limit`: integer.
  Platform staff (SUPPORT and above) are not tenant users and are never listed; the tenant sees the ones it may name under Admin → People → Users → Platform support access.
- `POST /tenants/{tenantId}/members` - Add an existing user to the tenant (or change their role) · **write**  
  Permission: 'tenant.members.manage'. Success: 201.
  Body: **email**: string, role: OWNER|ADMIN|DESIGNER|USER, override: boolean.
  ADMIN may grant USER and DESIGNER; OWNER may grant any role; assigning OWNER or ADMIN needs `tenant.admins.manage`. The user must already exist on the platform. For someone already in the tenant this changes their role only when you could assign their CURRENT role too (an ADMIN cannot touch an OWNER or another ADMIN, 403); a suspended member is reinstated with PATCH, not here (409); the last active OWNER cannot be demoted (409).
- `DELETE /tenants/{tenantId}/members/{userId}` - Remove a member · **write**  
  Permission: 'tenant.members.manage'. Success: 200.
  The last OWNER cannot be removed. Confirm with the person first.
- `PATCH /tenants/{tenantId}/members/{userId}` - Change a member's role or suspend them · **write**  
  Permission: 'tenant.members.manage'; OWNER/ADMIN assignment needs 'tenant.admins.manage'. Success: 200.
  Body: role: OWNER|ADMIN|DESIGNER|USER, status: ACTIVE|SUSPENDED.

### Users

- `GET /users` - List platform users  
  Permission: platform SUPPORT. Success: 200.
  Query: `page`, `limit`: integer, `q`, `active`: true|false.
- `POST /users` - Create a platform user record · **write**  
  Permission: platform GLOBAL_ADMIN. Success: 201.
  Body: **email**: string, name: string, platformRole: USER|SUPPORT|GLOBAL_ADMIN|SUPER_ADMIN.
  Creates the local account (no password: people sign in through the configured identity provider or an invitation). Platform role assignment obeys canAssignPlatformRole: GLOBAL_ADMIN may create USER/SUPPORT; SUPER_ADMIN any.
- `GET /users/{userId}` - A user with their memberships  
  Permission: self ('me'), or platform SUPPORT. Success: 200.
- `PATCH /users/{userId}` - Change a user (name, active, API access, platform role, revoke tokens) · **write**  
  Permission: self (name only), or platform GLOBAL_ADMIN. Success: 200.
  Body: name: string, isActive: boolean, apiAccessEnabled: boolean, platformRole: USER|SUPPORT|GLOBAL_ADMIN|SUPER_ADMIN, revokeTokens: boolean.
  Self may change only `name`. Platform role changes obey canAssignPlatformRole for both the old and the new role; nobody changes their own platform role.
- `GET /users/{userId}/tokens` - A user's API tokens (revoked ones included; never the secret)  
  Permission: self ('me'), or platform GLOBAL_ADMIN. Success: 200.
  Query: `includeRevoked`: true|false.
  The System → Users token panel over the API. To switch the user’s API access off altogether, `PATCH /users/{userId}` with `{"apiAccessEnabled": false}`.
- `DELETE /users/{userId}/tokens/{tokenId}` - Revoke one of a user's API tokens  
  Permission: self ('me'), or platform GLOBAL_ADMIN. Success: 200.
  Idempotent. Like `DELETE /tokens/{id}`, a read-only token may do this (revocation is a safety action, not a write). Confirm with the person first: the token stops working at once.

### Modules

- `GET /modules` - The module registry (what this build offers) with tenant counts  
  Permission: platform SUPPORT. Success: 200.
- `GET /tenants/{tenantId}/modules` - Which modules a tenant has on or off  
  Permission: any ACTIVE member, or platform SUPPORT. Success: 200.
  Every module the platform offers - built-in and installed modules from Global → Modules - with the tenant’s switch, the licence recorded when it was enabled (`licence`: tier, cost in minor units, currency, cycle, `activatedAt`, `activatedBy`, `expiresAt`, `notes`), what enabling costs now (`cost`), whether it is `paid` (staff activation), the licence `status` and a pending `request`. Paid modules are requested with `POST …/modules/{moduleId}/requests`.
- `PUT /tenants/{tenantId}/modules/{moduleId}` - Switch a module on or off for a tenant · **write**  
  Permission: platform GLOBAL_ADMIN. Success: 200.
  Body: **enabled**: boolean, config: object, switchOnPortal: boolean, licenceTier: free|standard|pro|enterprise, expiresAt: string?, notes: string?.
  A PLATFORM call. Core modules (the API, single sign-on, analytics, pages, …) are always on and are not switched here. Dependencies are enabled with it; core modules and modules others depend on cannot be disabled. Runs the module’s lifecycle hooks and events, like the browser. Works for built-in modules and installed Catalogue spokes alike (a spoke must be installed and healthy). Enabling records the licence: the cost at the Global admin’s current terms (minor units, currency, cycle), `licenceTier` (defaults to those terms’ tier), `expiresAt` (ISO 8601 or null) and `notes` - returned under `licence`; it also approves the tenant’s pending request for the module. A module whose `requires` are not met answers 409 with `details.requirements` (pass `switchOnPortal: true` to switch the tenant portal on with it). Only when the person asked for that tenant by name.
- `DELETE /tenants/{tenantId}/modules/{moduleId}/requests` - Withdraw the pending request for a module · **write**  
  Permission: tenant.modules.manage (the owner). Success: 200.
- `POST /tenants/{tenantId}/modules/{moduleId}/requests` - Request activation (a paid module) or install (from an approved source) · **write**  
  Permission: tenant.modules.manage (the owner). Success: 201.
  Body: kind: activation|install, note: string?.
  Paid modules are switched on by platform staff: this files the request with the current cost. `kind: "install"` asks for a module the platform has not installed, from a source approved for tenants or the tenant’s own source - only when the tenant install policy allows it. One pending request per module (409 with `details.requestId`). A free module answers 400: switch it on instead.
- `GET /tenants/{tenantId}/modules/{moduleId}/settings` - A spoke's per-tenant settings (the values the hub stores for it)  
  Permission: settings.view - or the spoke’s edge secret. Success: 200.
  The hub stores an installed spoke’s per-tenant settings, validated against the manifest’s `settings` schema (docs/modules/AUTHORING.md §2.2). Two callers: (1) a token or session with `settings.view` on the tenant - secrets come back redacted (`••••••••` when set, `""` when not); (2) **the spoke itself**, authenticated with `X-CF-Spoke: <id>` + `X-Edge-Secret: <its edge secret>` instead of a bearer - it receives the secrets in the clear and is expected to call this only when the `X-Remote-Settings-Version` it sees on a forward_auth answer differs from the `version` it holds. The module must be on for the tenant (403 `module_disabled`). Built-in modules have no settings here (404).
- `PUT /tenants/{tenantId}/modules/{moduleId}/settings` - Save a spoke's per-tenant settings · **write**  
  Permission: settings.edit. Success: 200.
  Body: **values**: object.
  Replaces the given keys (a PATCH in effect: keys you omit keep their value). Validated against the installed manifest’s `settings` schema - unknown keys are refused with 400, types are coerced from strings, `enum` / `minimum` / `maximum` / `required` are enforced. A secret given as `••••••••` keeps its stored value; `""` clears it. The spoke sees the new version on its next request. Audited with the keys, never the values.

### Settings

- `GET /settings` - Global (platform) settings, grouped by category  
  Permission: platform GLOBAL_ADMIN. Success: 200.
- `PUT /settings` - Merge global settings · **write**  
  Permission: platform GLOBAL_ADMIN; SUPER_ADMIN-class keys (security.*) need SUPER_ADMIN. Success: 200.
  Body: object.
  Only the keys you send change; every key must be a registered platform setting and its value must fit the setting (400 `validation_failed` otherwise, nothing written). SUPER_ADMIN-class settings (`security.*`) need platform SUPER_ADMIN (403 `platform_role_required`). Changing `instance.*` refreshes the instance banner/name cache.

### Updates

- `GET /updates` - Platform updates: the running build, the channel, what GitHub has published, the conductor state  
  Permission: platform GLOBAL_ADMIN. Success: 200.
- `GET /updates/jobs` - Conductor jobs (newest first)  
  Permission: platform GLOBAL_ADMIN. Success: 200.
- `POST /updates/jobs` - Start a conductor job · **write**  
  Permission: platform SUPER_ADMIN. Success: 202.
  Body: **action**: check|update|cutover|rollback|retire|backup|approve, version: string, to: blue|green.
  `check` refreshes what GitHub has; `update` installs a version (zero-downtime blue/green); `rollback`, `retire`, `backup`, `approve` (dev only: tags the live build for production). `version` is a release tag or version for `update` (`edge`, `vX.Y.Z`, `X.Y.Z-pre+build`) and `X.Y.Z` (optionally `v`-prefixed) for `approve`; anything else is `400 validation_failed`. Every action but `check` needs the person’s explicit yes. 202 with the job; 409 if another job runs; 503 if the conductor is not installed.

### Audit

- `GET /tenants/{tenantId}/audit` - A tenant's audit trail (newest first)  
  Permission: 'tenant.audit.view' (ADMIN, OWNER). Success: 200.
  Query: `page`, `limit`: integer, `action`, `entityType`, `entityId`, `userId`.

### Webhooks

- `GET /tenants/{tenantId}/webhooks` - The tenant's webhook endpoints  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
  Every endpoint with its subscribed events, status (`active` | `paused` | `disabled_by_failures`) and counters. Secrets are never returned; `secretHint` is the last four characters.
- `POST /tenants/{tenantId}/webhooks` - Register a webhook endpoint (secret shown once) · **write**  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 201.
  Body: **name**: string, description: string?, **url**: string, **events**: page.published|site.version.activated|form.submitted|booking.created|booking.confirmed|booking.cancelled|order.created|order.paid|order.shipped|order.cancelled|ticket.created|ticket.assigned|ticket.message_added|ticket.resolved|tenant.module.enabled|tenant.module.disabled|backup.completed|tenant.status.changed|module.request.created|module.request.decided|user.joined|user.left|membership.changed|group.changed[].
  The URL must be https on a public host (loopback, private, link-local and cloud-metadata addresses are refused, at save time and again at every delivery). `events` are catalogue ids from `GET /v1/webhooks/events`. The response carries `secret` (`whsec_…`) exactly once - store it; it signs every delivery (`X-CloudForge-Signature`). At most 20 endpoints per tenant.
- `DELETE /tenants/{tenantId}/webhooks/{webhookId}` - Delete an endpoint and its delivery log · **write**  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
- `GET /tenants/{tenantId}/webhooks/{webhookId}` - One webhook endpoint  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
- `PATCH /tenants/{tenantId}/webhooks/{webhookId}` - Change an endpoint: name, URL, events, pause or re-enable · **write**  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
  Body: name: string, description: string?, url: string, events: page.published|site.version.activated|form.submitted|booking.created|booking.confirmed|booking.cancelled|order.created|order.paid|order.shipped|order.cancelled|ticket.created|ticket.assigned|ticket.message_added|ticket.resolved|tenant.module.enabled|tenant.module.disabled|backup.completed|tenant.status.changed|module.request.created|module.request.decided|user.joined|user.left|membership.changed|group.changed[], status: active|paused.
  `{"status":"paused"}` stops new deliveries (queued ones wait, then expire after 24 h); `{"status":"active"}` resumes - and is how an endpoint that was `disabled_by_failures` is re-enabled once the receiver is fixed (its failure counter resets). An endpoint with no signing secret (a legacy row, `secretHint` null) must have its secret rotated before it can be set `active` (400). The URL is re-checked like on create.
- `GET /tenants/{tenantId}/webhooks/{webhookId}/deliveries` - The delivery log of an endpoint (newest first, 30 days)  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
  Query: `page`, `limit`: integer, `status`: pending|delivered|failed, `event`.
  One row per event × endpoint; `attempt` counts tries so far, `status` is `pending` (not yet sent, or waiting for the next retry at `nextAttemptAt`), `delivered` (2xx) or `failed` (last attempt failed). `payloadPreview` and `responsePreview` are capped at 2 KB; `payloadHash` is the sha256 of the exact body sent.
- `POST /tenants/{tenantId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver` - Send the same event to the endpoint again · **write**  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 202.
  Queues a fresh delivery of the original event (a new row with its own attempt chain; the original keeps its history). The endpoint must be `active`. Events older than the 30-day retention cannot be redelivered.
- `POST /tenants/{tenantId}/webhooks/{webhookId}/rotate-secret` - Replace the signing secret (new one shown once) · **write**  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
  The old secret stops signing at once - deliveries already in flight were signed with it, so update the receiver first, then rotate, and accept both for a few minutes if you cannot afford a missed delivery.
- `POST /tenants/{tenantId}/webhooks/{webhookId}/test` - Send a `ping` to the endpoint now and report the result · **write**  
  Permission: 'tenant.webhooks.manage' (ADMIN, OWNER). Success: 200.
  Delivers a `ping` event (`data: { message, endpointId, endpointName, sentAt }`) synchronously - signed and guarded exactly like a real delivery - and returns the delivery row with the response status, duration and any error. A ping is a single attempt (no retries) and does not count towards the automatic pause. Works on a paused endpoint. When another delivery to the endpoint is in flight the answer is 409 `busy` and the ping is discarded (it is never sent later).
- `GET /webhooks/events` - The webhook event catalogue (public)  
  Permission: public. Success: 200.
  Every event an endpoint can subscribe to, with the fields its `data` carries, plus the signature scheme, the headers of a delivery and the retry schedule. No authentication.

### Analytics

- `GET /tenants/{tenantId}/analytics/acquisition` - Acquisition: channels, source / medium, campaigns, referrers, search terms  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  GA4's default channel groups (Direct, Organic Search, Organic Social, Referral, Email, Paid Search, …) with sessions per day per channel, plus source / medium, UTM campaigns, referrer hosts and site-search terms - each row with sessions, visitors, engagement, conversions and, with `compare=1`, the previous period. `revenue` (orders and revenue per channel / source / campaign / landing page) is present when the tenant has the shop (licence `shop`), null otherwise.
- `GET /tenants/{tenantId}/analytics/audience` - Audience: new vs returning, countries, languages, devices, browsers, OS, screens  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  Sessions per visitor type (new / returning), country (ISO code), language, device category, browser, operating system and screen size, each with engagement figures; `unknown` counts the sessions with no resolvable location / language / screen. Retention cohorts are a separate call (`…/analytics/cohorts`).
- `GET /tenants/{tenantId}/analytics/bots` - Bot traffic: share over time, crawlers, fake crawlers, datacenter sources, probes  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`.
  Sessions and pageviews per traffic class (human / suspected / bot / excluded) for the range and the previous period, the per-day series, verified crawlers by vendor, fake crawlers (a crawler user agent from an unverified address), known bots, why sessions were suspected, datacenter sources by ASN, probe paths (/wp-login.php, /.env, …) and hits from the tenant's excluded addresses. `class` and `segment` do not apply - the report is about the whole stream.
- `GET /tenants/{tenantId}/analytics/cohorts` - Retention cohorts by first-seen week  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `class`: humans|suspected|bots, `segment`.
  The last 12 weekly cohorts (visitors first seen in that UTC week, Monday start) and, for each, the share with a session in weeks 1..8 after it (`share` null = that week has not started; `partial` = still running). `averages` is the weighted average per week across completed cohorts. Always anchored on now (`from` / `to` do not apply); `class` and `segment` do. Computed from the raw session rows, so it reaches back only `retentionDays`.
- `GET /tenants/{tenantId}/analytics/engagement` - Engagement: pages, landing pages, exit pages, site search  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  Pages (views, sessions, visitors, average engagement time, bounce and exit rate), landing pages and exit pages (sessions, engagement, conversions) and site-search terms, with engagement totals. Web Vitals are a separate call (`…/analytics/vitals`).
- `GET /tenants/{tenantId}/analytics/events` - Events and conversions  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  Every event name seen in the range (auto events such as file_download / form_submit / scroll_depth, custom `cf('event', …)` events and conversions) with count, sessions, visitors, events per session and the day it was last seen; `conversions` is the subset the tenant marked as conversions in Analytics → Settings.
- `GET /tenants/{tenantId}/analytics/funnels` - The tenant's saved funnels (to pick a funnelId)  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Funnels are configured under Analytics → Settings or with `POST` here: a name and up to 8 ordered steps, each a page path (`*` = wildcard) or an event name. Evaluate one with `GET …/analytics/funnels/{funnelId}`.
- `POST /tenants/{tenantId}/analytics/funnels` - Save a funnel · **write**  
  Permission: 'analytics.manage' (ADMIN, OWNER). Success: 201.
  Body: **name**: string, **steps**: {type, match}[].
  Same validation as Analytics → Settings: `name` ≤ 80 characters; 1-8 `steps` of `{type: page|event, match}` - a page match is a path (`/checkout`, `/products/*`; a leading slash is added), an event match is an event name (`[a-z0-9_]{1,40}`, lower-cased). At most 20 funnels per tenant (400). Audited as `api.analytics.funnel.created`.
- `DELETE /tenants/{tenantId}/analytics/funnels/{funnelId}` - Delete a saved funnel · **write**  
  Permission: 'analytics.manage' (ADMIN, OWNER). Success: 200.
  Removes the definition only - no traffic data changes. 404 when the funnel is not this tenant's. Audited as `api.analytics.funnel.deleted`.
- `GET /tenants/{tenantId}/analytics/funnels/{funnelId}` - Evaluate a saved funnel over the range  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  Sessions per step (a session counts for step N only if it counted for step N-1 and its first hit for N came later), conversion per step and overall, and drop-off. Raw rows only, at most 90 days: a wider range or more than 200 000 matching hits is a 400 that says what to narrow. 404 when the funnel is not this tenant's.
- `PUT /tenants/{tenantId}/analytics/funnels/{funnelId}` - Replace a saved funnel (name and steps) · **write**  
  Permission: 'analytics.manage' (ADMIN, OWNER). Success: 200.
  Body: **name**: string, **steps**: {type, match}[].
  The whole funnel - `name` and the full ordered `steps` - with the validation of `POST …/funnels`. 404 when the funnel is not this tenant's. Audited as `api.analytics.funnel.updated`.
- `GET /tenants/{tenantId}/analytics/ips` - IP addresses seen in the range, with classification  
  Permission: 'reports.view' (ADMIN, OWNER) · CSV: 'reports.export'. Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots|all, `segment`, `compare`: 1|true|0|false, `ipClass`: human|verified-bot|bot|suspected|datacenter|excluded, `country`, `asn`: integer, `prefix`, `page`, `pageSize`: integer, `sort`: pageviews|sessions|lastSeen, `format`: json|csv.
  Every address seen with sessions, pageviews, first / last seen, country / city, ASN and organisation, reverse DNS and its classification (human / verified-bot / bot / suspected / datacenter / excluded). Unlike the other reports this one shows ALL classes by default (its point is to show the bots); filter with `ipClass`, `country`, `asn`, `prefix` (e.g. `41.13.`). Paged (`page`, `pageSize` ≤ 200). `format=csv` streams the same rows as a file (≤ 10 000 rows) and needs `reports.export`. Addresses are shown as stored: masked or hashed when the tenant's IP handling says so, and nulled after the IP retention window.
- `GET /tenants/{tenantId}/analytics/overview` - Website traffic overview: KPIs, daily series, insights, revenue  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  The Overview tab of Website Traffic: visitors (new / returning), sessions, pageviews, engagement, conversions, the previous period of equal length for deltas, sessions and pageviews per day, the automatic insights (anomalies, new referrers / countries, bot spikes) and - when the tenant has the shop (licence `shop`) - revenue by source (`revenue` is null otherwise). Humans only by default; `class=suspected` adds suspected traffic, `class=bots` shows bots only. Ranges longer than 7 days are answered from the nightly rollups (`fromRollups`).
- `GET /tenants/{tenantId}/analytics/paths` - Paths: next and previous pages of a page, entry pages, top journeys  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false, `page`.
  For `page=<path>` (default: the session start, which makes `next` the landing-page view) the pages visitors went to next and came from before, with counts and share, the top entry pages, and the 10 most common first-three-page journeys. Next / previous are answered from the nightly rollups for ranges longer than 7 days; journeys need whole sessions and are raw-only, clamped to the last 90 days (`journeysClamped`).
- `GET /tenants/{tenantId}/analytics/segments` - The tenant's saved segments (to pick a segment id)  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  A segment is a named filter set applied to every report through `segment=<id>`: `country[]` (ISO codes), `channel[]` (GA channel groups), `device[]` (desktop | mobile | tablet), `landingPage` (path prefix), `trafficClass` (human | suspected | bot - replaces the `class` switch), `referrerHost`, `utmCampaign`. Saved from the Website Traffic toolbar or with `POST` here.
- `POST /tenants/{tenantId}/analytics/segments` - Save a segment · **write**  
  Permission: 'analytics.manage' (ADMIN, OWNER). Success: 201.
  Body: **name**: string, filters: {country, channel, device, landingPage, trafficClass, referrerHost, utmCampaign}.
  Same validation as the Website Traffic toolbar: `name` ≤ 80 characters; `filters` with any of `country[]`, `channel[]`, `device[]`, `landingPage`, `trafficClass`, `referrerHost`, `utmCampaign` (values are canonicalised - upper-case codes, lower-case hosts, a leading slash on the landing prefix); `{}` or omitted matches everyone. At most 20 segments per tenant (400). Audited as `api.analytics.segment.created`.
- `DELETE /tenants/{tenantId}/analytics/segments/{segmentId}` - Delete a saved segment · **write**  
  Permission: 'analytics.manage' (ADMIN, OWNER). Success: 200.
  Removes the definition only - no traffic data changes; a report called with the deleted id afterwards is 404. 404 when the segment is not this tenant's. Audited as `api.analytics.segment.deleted`.
- `PUT /tenants/{tenantId}/analytics/segments/{segmentId}` - Replace a saved segment (name and filters) · **write**  
  Permission: 'analytics.manage' (ADMIN, OWNER). Success: 200.
  Body: **name**: string, filters: {country, channel, device, landingPage, trafficClass, referrerHost, utmCampaign}.
  The whole segment - `name` and the full `filters` object (a filter left out is removed) - with the validation of `POST …/segments`. 404 when the segment is not this tenant's. Reports already running with `segment=<id>` pick the new filters up on their next call. Audited as `api.analytics.segment.updated`.
- `GET /tenants/{tenantId}/analytics/vitals` - Web Vitals: p75 per metric overall and per page  
  Permission: 'reports.view' (ADMIN, OWNER). Success: 200.
  Query: `from`, `to`, `class`: humans|suspected|bots, `segment`, `compare`: 1|true|0|false.
  LCP, CLS, INP, TTFB, DOMContentLoaded and load at the 75th percentile, rated against Google's thresholds, overall and for the busiest pages. Percentiles cannot be rolled up, so this reads the raw rows only (`rawOnly`): a range older than the tenant's raw retention (90 days by default) shows what is still held.

### Status

- `GET /status` - Platform status: is CloudForge up, is the website checker running, how many sites are online  
  Permission: any valid token · deployment block: platform SUPPORT. Success: 200.
  Any valid token (Standard S5: the figures name no tenant). The `deployment` block (colour, environment, commit) is for platform SUPPORT and up - operator detail, not customer detail.
- `GET /system/status/tenants` - Every tenant’s online status (staff): offline first, then unchecked, then online  
  Permission: platform SUPPORT. Success: 200.
  The newest check of every tenant - what System → Tenants and Global → Health show. Platform SUPPORT and up (read-only staff tier).
- `GET /tenants/{tenantId}/status` - Is the website online - newest check, rolling uptime (24 h / 7 d / 30 d), incidents  
  Permission: 'status.view' (DESIGNER, ADMIN, OWNER). Success: 200.
  The checker probes the primary domain every 5 minutes (2xx/3xx = online; a redirect to a sign-in page is a working site) - and every verified domain when the tenant setting `status.check_all_domains` is true, reported in `domains[]`. `isOnline: null` = never checked yet. Served from a 60 s cache; POST …/status/check for a fresh probe.
- `POST /tenants/{tenantId}/status/check` - Check the website now (on-demand probe, recorded as source "manual") · **write**  
  Permission: 'status.check' (ADMIN, OWNER). Success: 200.
  One probe of the primary domain (and of every verified domain when `status.check_all_domains` is on - `checks[]` lists them all, `check` is the primary's), recorded like a scheduled one, so it counts towards uptime and can flip the site's state. Limited to 6 per tenant per minute (429 `rate_limited` beyond that). `check: null` = the tenant has no domain to probe. Audited as `api.status.checked`.
- `GET /tenants/{tenantId}/status/history` - The raw uptime checks of the last N days (default 7, max 30), newest first  
  Permission: 'status.view' (DESIGNER, ADMIN, OWNER). Success: 200.
  Query: `days`: integer, `domain`.
  Every probe with its host, status, latency and source (every domain of the tenant; `domain=` narrows to one host). Rows are kept 30 days; more than 10 000 rows in the range sets `truncated` - narrow `days`.

### Single sign-on

- `GET /tenants/{tenantId}/sso` - The tenant's single sign-on provider  
  Permission: 'sso.view' (ADMIN, OWNER). Success: 200.
  Whether the provider is switched on (`enabled` - the tenant’s `sso.enabled` setting; while off, `/oidc`, the discovery document, the portal "Systems" list and client-credential tokens answer 404), the issuer (`https://<primary domain>/oidc`), the discovery URL a connected app configures, the signing keys (kid, algorithm, active / retiring) and how many connected apps and portal apps exist. Never a private key.
- `PUT /tenants/{tenantId}/sso` - Switch single sign-on on or off · **write**  
  Permission: 'tenant.settings.edit' (OWNER). Success: 200.
  Body: **enabled**: boolean.
  Single sign-on is part of every tenant; this is the per-tenant switch (off by default). On: the tenant’s subject secret and signing key are created if missing and its OpenID Connect provider starts answering on its domains. Off: the provider answers 404 and every refresh grant ends (connected apps sign people in again once it is back on); connected apps, portal apps, access lists and keys are kept. The `sso` settings category cannot be written through `PUT …/settings`. Audited as `sso.switched`.
- `GET /tenants/{tenantId}/sso/apps` - Portal apps (the "Systems" list)  
  Permission: 'sso.view' (ADMIN, OWNER). Success: 200.
  The systems listed in the portal sidebar under "Systems", in order, with who may see them. `kind`: `oidc` opens through the tenant’s provider (via `clientId`), `forward_auth` and `link` are links.
- `POST /tenants/{tenantId}/sso/apps` - Add a portal app · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 201.
  Body: **name**: string, icon: Box|Globe|Link|Server|Code|Mail|BookOpen|Calendar|ShoppingBag|DollarSign|LifeBuoy|MessageSquare|BarChart3|FileText|KeyRound|Wrench, **kind**: oidc|forward_auth|link, **url**: string, clientId: string?, visibility: {roles, groupIds, allowUserIds, denyUserIds, customerIds}, order: integer, status: active|hidden.
  `url` is an https address or a path on the site (`/m/booking`). An `oidc` app needs `clientId` (a connected app of this tenant). `visibility` has the access-list shape (roles, groupIds, allowUserIds, denyUserIds; empty = every member) plus `customerIds`: the people linked to those client-portal customers may open it too. At most 50 per tenant.
- `DELETE /tenants/{tenantId}/sso/apps/{appId}` - Remove a portal app · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 200.
  The app leaves the portal at once; its connected app (if any) is not touched.
- `GET /tenants/{tenantId}/sso/apps/{appId}` - One portal app  
  Permission: 'sso.view' (ADMIN, OWNER). Success: 200.
- `PATCH /tenants/{tenantId}/sso/apps/{appId}` - Change a portal app · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 200.
  Body: name: string, icon: Box|Globe|Link|Server|Code|Mail|BookOpen|Calendar|ShoppingBag|DollarSign|LifeBuoy|MessageSquare|BarChart3|FileText|KeyRound|Wrench, kind: oidc|forward_auth|link, url: string, clientId: string?, visibility: {roles, groupIds, allowUserIds, denyUserIds, customerIds}, order: integer, status: active|hidden.
  Any subset of the create fields; `visibility` replaces the whole list; `order` moves it.
- `GET /tenants/{tenantId}/sso/apps/{appId}/users` - The people allowed into an app (provisioning)  
  Permission: 'sso.manage' (ADMIN, OWNER), or the app's client-credentials token (scope cf:users.read); the client token only while single sign-on is on. Success: 200.
  Query: `page`, `limit`: integer.
  Every active member the app’s visibility list - and, for an `oidc` app, its connected app’s access list - admits, paginated. `sub` is the pairwise subject their tokens carry, so the third-party system can pre-create, update or deactivate accounts. Two ways in: a CloudForge token or session with `sso.manage`, OR the connected app’s own client-credentials token (`POST <issuer>/token` with `grant_type=client_credentials&scope=cf:users.read`) - which reads only the app bound to that connected app. Pair with the `user.joined`, `user.left`, `membership.changed` and `group.changed` webhooks.
- `GET /tenants/{tenantId}/sso/clients` - Connected apps (OpenID Connect clients)  
  Permission: 'sso.view' (ADMIN, OWNER). Success: 200.
  Every third-party system registered to sign people in with this tenant: redirect URIs, scopes, grant types, token lifetimes, access list and status. Secrets are never returned; `secretPrefix` identifies the current one.
- `POST /tenants/{tenantId}/sso/clients` - Connect an app (secret shown once) · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 201.
  Body: **name**: string, clientType: confidential|public, **redirectUris**: string[], postLogoutRedirectUris: string[], scopes: openid|profile|email|roles|groups|apps|offline_access[], grantTypes: authorization_code|refresh_token|client_credentials[], accessTokenTtl: integer, refreshTokenTtl: integer, initiateLoginUri: string?, access: {roles, groupIds, allowUserIds, denyUserIds}, status: active|disabled.
  Registers an OpenID Connect client. Redirect URIs match EXACTLY and must be https (http://localhost only on a development instance). `clientType: public` has no secret and must use PKCE (S256). `grantTypes` may add `client_credentials` (confidential only) so the app can read its user list. `access` limits who may sign in: roles, groups, named people, and people blocked outright (deny wins; empty = every member). The response carries `clientSecret` (`cfs_…`) exactly once. At most 50 per tenant.
- `DELETE /tenants/{tenantId}/sso/clients/{clientId}` - Remove a connected app · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 200.
  Its codes and refresh tokens are deleted with it; portal apps that opened through it lose the link (they become plain links until another app is chosen).
- `GET /tenants/{tenantId}/sso/clients/{clientId}` - One connected app  
  Permission: 'sso.view' (ADMIN, OWNER). Success: 200.
- `PATCH /tenants/{tenantId}/sso/clients/{clientId}` - Change a connected app · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 200.
  Body: name: string, redirectUris: string[], postLogoutRedirectUris: string[], scopes: openid|profile|email|roles|groups|apps|offline_access[], grantTypes: authorization_code|refresh_token|client_credentials[], accessTokenTtl: integer, refreshTokenTtl: integer, initiateLoginUri: string?, access: {roles, groupIds, allowUserIds, denyUserIds}, status: active|disabled.
  Any subset of the create fields except `clientType`. `status: disabled` refuses new sign-ins at once and revokes every refresh token the app holds. `access` replaces the whole access list.
- `POST /tenants/{tenantId}/sso/clients/{clientId}/secret` - Rotate a connected app’s secret (shown once) · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 200.
  A new `cfs_…` secret replaces the old one at once - update the app before its next token call. Refresh tokens already issued stay valid. Public apps have no secret (400).
- `GET /tenants/{tenantId}/sso/keys` - Signing keys  
  Permission: 'sso.view' (ADMIN, OWNER). Success: 200.
  The tenant’s own signing keys, newest first: `kid`, algorithm and state. `active` signs; `retiring` keys stay in the JWKS until `retiresAt`. Private keys never leave the server (they are encrypted at rest).
- `POST /tenants/{tenantId}/sso/keys` - Rotate the signing key · **write**  
  Permission: 'sso.manage' (ADMIN, OWNER). Success: 200.
  Body: alg: RS256|ES256, graceHours: integer.
  A new key (RS256 by default, or `alg: ES256`) signs from now on; the previous one stays in the JWKS for `graceHours` (default 24, at most 720) so tokens it signed keep verifying, then retires. Connected apps pick the new key up from the JWKS by `kid`.
