Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

API

Everything the admin does goes through this JSON API, so anything the admin can do, a script can do. The contract is in packages/shared/src/index.ts; the handlers are in packages/core/src/routes/.

Basics

  • Base path: /api. All request and response bodies are JSON unless noted.
  • Errors: status 4xx or 5xx with { "error": { "code": string, "message": string, "details"?: unknown } }. Codes you will meet: validation_error (400, details holds zod's flattened issues or a list of { field, message }), unauthorized (401), invalid_credentials (401), forbidden (403), csrf (403), not_found (404), conflict (409), payload_too_large (413), rate_limited (429).
  • Auth: an httpOnly cookie spillway_session set by login or setup. Sessions last 30 days and slide on use. The cookie is SameSite=Lax, and Secure when BASE_URL starts with https://.
  • CSRF: every request with a method other than GET, HEAD or OPTIONS must send the header X-Requested-With: spillway, or it fails with 403 csrf. This applies to all of /api, including login.
  • Pagination: list endpoints take page (default 1) and limit (default 20, max 100) and return { items, total, page, limit }.
  • Permissions are derived from roles. See the table in getting-started.md.
  • Site scope: every request belongs to one site, picked by the Host header (a site's primary domain or alias), by ?__site=<id|key> or the header X-Spillway-Site: <id|key> for a signed-in user who can open that site, else the default site. Every endpoint below reads and writes that site's data; permissions are the user's global roles ∪ their roles on that site. See multisite.md.

curl with a cookie jar

BASE=http://localhost:3000
H='X-Requested-With: spillway'

# log in and keep the cookie
curl -sS -c jar -H "$H" -H 'Content-Type: application/json' \
  -d '{"email":"you@example.org","password":"secret123"}' \
  $BASE/api/auth/login

# who am I
curl -sS -b jar $BASE/api/auth/me

# create a draft page
curl -sS -b jar -H "$H" -H 'Content-Type: application/json' \
  -d '{"type":"page","title":"About us","data":{"title":"About us","layout":{"content":[],"root":{"props":{}}}}}' \
  $BASE/api/entities

Setup

MethodPathAuthPermission
GET/api/setup/statusnone
POST/api/setupnone, only while not installed

GET /api/setup/status returns { installed: boolean, version: string, setupTokenRequired?: boolean }. The site counts as installed once it has at least one user.

POST /api/setup takes { siteName, email, password (min 8), name }. It creates the site settings with the default theme, the admin user, the two built-in content types and a published sample home page, points homePageId at it, starts a session for the new admin and returns 201 { user }. A second call returns 409 conflict. When the server runs with SETUP_TOKEN set, the request must carry that value in the X-Setup-Token header or it is refused with 403 setup_token_required.

Every request body is bounded before a route reads it: 1 MB for JSON, 25 MB (plus multipart framing) for POST /api/media, 64 MB for POST /api/modules/install, 256 KB for the public form submit and /_beacon. Larger bodies answer 413 payload_too_large.

Auth

MethodPathAuthPermission
POST/api/auth/loginnone
POST/api/auth/logoutcookie
GET/api/auth/meoptional

POST /api/auth/login takes { email, password } and returns { user } (the contract allows { user, permissions }; the server sends user only, so call /api/auth/me for permissions). Wrong credentials give 401 invalid_credentials after the same amount of work whether or not the email exists. Login is rate limited per client IP; too many attempts give 429 rate_limited.

POST /api/auth/logout deletes the session and returns 204.

Admin UI strings: GET /api/i18n/:locale (public) → { locale, available, messages }, the requested bundle over the English one; GET /api/i18n → { available, fallback, user }. PUT /api/auth/me accepts uiLocale (a BCP 47 tag or null), returned on User.uiLocale. See localization.md.

GET /api/auth/me returns { user: User | null, permissions: Permission[] }. Anonymous callers get { user: null, permissions: [] } with status 200.

Per-user preferences (the admin's saved list views): GET /api/me/preferences → UserPreference[]; GET /api/me/preferences/:key → { key, value, updatedAt } (404 not_found when unset); PUT /api/me/preferences/:key { value } stores any JSON of at most 64 KB (413 payload_too_large beyond; null is a value); DELETE /api/me/preferences/:key → 204. Keys are 1–120 letters, digits, dots, colons, dashes or underscores (the admin uses views:<table>). Any signed-in user, their own rows only; user.preference.set and user.preference.delete are audited (the key and size, never the value).

Shared saved views (SharedView): list setups a manager shares with everyone on the site or with chosen roles. GET /api/views?table=<key> → SharedView[] for any signed-in user: the table's shared views on the current site whose audience includes them (their instance roles plus the roles granted for the site; section roles do not count), each with inAudience; with views.share every shared view of the table. POST /api/views { table, name, view, roles?, isDefault? } → 201, PUT /api/views/:id { name?, view?, roles?, isDefault? } and DELETE /api/views/:id → 204 need views.share (manager, admin). view is { filters, sort: { key, dir }, hidden, density, limit? } (at most 16 KB), roles empty means everyone, and isDefault: true clears the flag on the table's other shared views whose audience overlaps. Audited as view.share.create, .update and .delete.

curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/views \
  -d '{"table":"content:page","name":"Ready to publish","view":{"filters":{"status":"approved"},"sort":{"key":"updated","dir":"desc"},"hidden":[],"density":"default"},"roles":["editor"],"isDefault":true}'

Content types

MethodPathAuthPermission
GET/api/typescookieany user
GET/api/types/:keycookieany user
POST/api/typescookietypes.manage
PUT/api/types/:keycookietypes.manage
DELETE/api/types/:keycookietypes.manage

Request body for POST and PUT is a ContentTypeInput (a ContentType without timestamps):

{
  "key": "case_study",
  "label": "Case study",
  "labelPlural": "Case studies",
  "hasPath": true,
  "pathPattern": "/customers/{slug}",
  "titleField": "title",
  "fields": [
    { "key": "title", "label": "Title", "type": "text", "required": true },
    { "key": "customer", "label": "Customer", "type": "text" },
    { "key": "industry", "label": "Industry", "type": "select", "options": ["Finance", "Health", "Retail"] },
    { "key": "summary", "label": "Summary", "type": "textarea" },
    { "key": "body", "label": "Body", "type": "richtext" },
    { "key": "logo", "label": "Logo", "type": "media" },
    { "key": "related", "label": "Related posts", "type": "reference", "refType": "post", "multiple": true }
  ]
}

Validation: key matches ^[a-z][a-z0-9_]{1,62}$, field keys match ^[a-z][a-z0-9_]{0,62}$ and are unique, select fields need options, pathPattern must start with /, at most 200 fields. On PUT the key in the body must equal the one in the path (400 otherwise). POST with an existing key gives 409. DELETE of a type that still has entities gives 409 with details.count.

Responses: the ContentType (201 on create), 204 on delete.

Entities

MethodPathAuthPermission
GET/api/entitiescookiecontent.view_drafts
GET/api/entities/by-path?path=/aboutoptionalanonymous sees published only
POST/api/entitiescookiecontent.create
GET/api/entities/:idcookiecontent.view_drafts
PUT/api/entities/:idcookiecontent.edit_any, or content.edit_own on your own (a block style limited to other roles: 403, see Block styles)
DELETE/api/entities/:idcookiecontent.delete
POST/api/entities/:id/submitcookiecontent.submit_review plus edit access
POST/api/entities/:id/publishcookiecontent.publish
POST/api/entities/:id/unpublishcookiecontent.unpublish
GET/api/entities/:id/revisionscookiecontent.view_drafts
POST/api/entities/:id/revisions/:revisionId/restorecookieedit access
GET/api/entities/:id/translationscookiecontent.view_drafts
POST/api/entities/:id/translationscookiecontent.create (ai.use for mode ai)
GET/api/translations/statuscookiecontent.view_drafts

Scheduling, editing locks and revision diff are listed under Editorial. PUT /api/entities/:id answers 409 lock_held while another user's editing lock is live.

List GET /api/entities query parameters: type, status (draft, review, published, archived, or the virtual scheduled, see Editorial), q (matches title or path, case-insensitive substring), page, limit, sort (updatedAt|createdAt|title|publishedAt + :asc|:desc, default updatedAt:desc). Returns Paginated<Entity>.

By path GET /api/entities/by-path?path=/about. path=/ resolves to the site's home page. The path may carry a locale prefix (/es/about, /es for the Spanish home) or name the locale with &locale=es. Signed-in users with content.view_drafts get the entity as stored (latest draft). Anonymous callers get the entity only if it is published, with data and title taken from the published revision; otherwise 404.

Create POST /api/entities body (EntityInput):

{ "type": "post", "title": "Hello", "slug": "hello", "locale": "en", "data": { "title": "Hello", "body": "<p>Hi</p>" } }

title falls back to data[titleField]; one of them is required. slug defaults to slugify(title); pass null for no slug. locale defaults to the site's default locale and must be one of the site's locales (400 otherwise). A new entity is the source of its own translation group (translationGroupId = its id, isSource: true, translationStatus: 'current'). data is validated against the type (required fields, value types, select options; unknown keys are allowed). The entity is created as draft with revision 1. If the path is taken, -2, -3, ... is appended to the slug. Returns 201 with the Entity.

Update PUT /api/entities/:id body: any of title, slug, locale, data, plus note (stored on the revision). data replaces the whole data object when given. type cannot change (400). Every update writes a new revision and makes it currentRevisionId; the published revision and the public page are untouched until you publish. The slug does not change when the title changes.

Translations GET /api/entities/:id/translations → { group: Entity[], missing: string[] } (the group, source first, and the site locales without a translation). POST /api/entities/:id/translations body { locale, mode: 'empty' | 'copy' | 'ai' } → 201 with the new draft Entity (plus aiTranslatedFields and usage for ai); 400 for a locale the site does not have, 409 when the locale already exists in the group, 403 for ai without ai.use. Saving a source (PUT) copies the shared fields (translatable: false) into its translations and marks them outdated when translatable content changed; saving a translation makes it current. Changing locale with PUT to one another group member holds answers 409. GET /api/translations/status → { defaultLocale, locales: { [locale]: { total, current, outdated, missing } }, items: [{ sourceId, type, title, locale, status, entityId, updatedAt }] }. GET /api/entities?locale=es lists one locale. See localization.md.

Submit sets status to review. Published entities cannot be submitted (409).

Publish first runs the pre-publish checks (see Review). If any check has level error, the request is refused with 422 { error: { code: 'checks_failed', message, details: EntityCheck[] } } and nothing changes. Send the body { "force": true } to publish anyway (the route already requires content.publish, so anyone who can publish can force). Otherwise it sets status to published, publishedRevisionId to the current revision and publishedAt to now, marks that revision published and evicts cached pages tagged with the entity.

Unpublish sets status to draft and clears publishedRevisionId. The page returns 404 on the public site.

Revisions returns Revision[], newest first. Restore copies the chosen revision's title and data into a new revision (note "Restored from revision N") and makes it current; it does not change the status.

Delete removes the entity, its revisions and references (204).

archived is set by the scheduler when a published entity's unpublishAt comes (see Editorial); there is no manual archive endpoint.

Inside a workstream. With the header X-Spillway-Workstream: <id or key>, PUT writes a branch revision (the main line is untouched), POST creates an entity only the workstream sees, DELETE records a delete intent, GET returns the branch version marked inWorkstream, and publish, unpublish, submit and restore answer 409 workstream_scope. See Workstreams and workstreams.md.

Media

MethodPathAuthPermission
GET/api/mediacookieany user
GET/api/media/:idcookieany user
POST/api/mediacookiemedia.upload
PUT/api/media/:idcookiemedia.upload
DELETE/api/media/:idcookiemedia.delete
GET/media/:id/:filenamenone

GET /api/media?q=&page=&limit= returns Paginated<MediaItem>; q matches filename, alt or title.

POST /api/media is multipart/form-data with file and optional alt and title. Limit 25 MB; larger files give 413 payload_too_large, empty files 400. The MIME type is sniffed from the file's bytes (PNG, JPEG, GIF, WebP, PDF, SVG), falling back to the extension; the stored filename is sanitized. Width and height are read for PNG, JPEG, GIF and WebP. Returns 201 with the MediaItem, whose url is /media/<id>/<filename>.

curl -sS -b jar -H "$H" -F file=@photo.jpg -F alt="Our office" $BASE/api/media

PUT /api/media/:id takes { alt, title? }. DELETE removes the row and the file (204). GET /media/:id/:filename serves the file with a long cache lifetime; it is public.

DAM connectors

A media item can reference an asset in Cloudinary, Bynder or Brandfolder instead of a local file (MediaItem.source: { provider, externalId, url, version?, metadata }). Its url stays /media/<id>/<filename>; GET /media/:id/:filename then answers 302 to the DAM's delivery URL with the derivative parameters (w, h, fit, fmt, q) translated into the provider's transformation syntax, and variants, mediaUrl() and rendered pages use those delivery URLs directly. The provider is configured per site in SiteSettings.media.dam (PUT /api/site, site.settings): { provider: 'none' | 'cloudinary' | 'bynder' | 'brandfolder', credentials, defaultFolder? }; the secret credentials (DAM_CREDENTIAL_FIELDS in the contract: Cloudinary apiSecret, Bynder token and webhookSecret, Brandfolder apiKey and webhookSecret) are encrypted at rest and returned as ********, and sending the mask (or leaving a key out) keeps the stored value. Changing the provider drops the old credentials; provider: 'none' disconnects.

MethodPathAuthPermission
POST/api/media/dam/testcookiesite.settings
GET/api/media/dam/searchcookiemedia.upload
POST/api/media/dam/importcookiemedia.upload
POST/api/media/:id/copy-localcookiemedia.upload
POST/api/media/dam/webhook/:providernone (signature or token)

POST /api/media/dam/test takes an optional { provider, credentials, defaultFolder } (masked values are filled from what is stored) or an empty body for the stored settings and returns DamTestResponse { ok, provider, name, message }; it never saves.

GET /api/media/dam/search?q=&page=&cursor=&kind=image|video|document|other returns DamSearchResponse: items (DamAsset: externalId, name, kind, mime, size, width, height, url, thumbnailUrl, alt and title from the DAM's metadata, version, updatedAt, metadata), page, total (null when the provider does not count), nextCursor (Cloudinary paginates by cursor: pass it back as cursor), provider and name. The default folder narrows the search (Cloudinary folder:, Bynder tags=, Brandfolder section:). 400 bad_request when no DAM is configured; provider failures answer 502 dam_error, rejected credentials 401 dam_auth.

POST /api/media/dam/import takes { externalId, alt?, title? } and creates the external item: the file name from the asset's name, the MIME type, size and dimensions from the DAM, alt text and title from the request or else the DAM's metadata. 201 with the MediaItem; 200 with the existing item when the site imported that asset before. 404 when the DAM has no such asset. Audit media.dam_import.

POST /api/media/:id/copy-local downloads the asset (through the outbound SSRF guard, redirects re-checked, refused over 25 MB with 413 and when the URL answers with a web page) and converts the item to a local file: the sniffed type, size and dimensions replace the DAM's, source is removed, the file name and URL stay, cached pages using it are evicted. 409 when the item is already local. Audit media.copy_local.

POST /api/media/dam/webhook/:provider is public and rate limited (120 per minute per IP), exempt from CSRF. Cloudinary notifications are verified with X-Cld-Signature and X-Cld-Timestamp against the API secret (SHA1(body + timestamp + secret)); every provider also accepts ?token= or X-Spillway-Webhook-Token equal to the webhookSecret credential (Bynder and Brandfolder only have that). The body is the provider's notification (Cloudinary { notification_type, public_id, version } or resources: [...]; Bynder { media_id, event }; Brandfolder { data: { attributes: { key, resource_id } } }). Every site configured for the provider whose token or signature matches is updated: each item referencing a changed asset is re-read from the DAM (URL, dimensions, metadata, version; when the DAM gives no new version the stored one is bumped) and the pages using it are evicted through media:<id>. 202 DamWebhookResponse { changed: [media ids], errors: [{ siteId, message }] }: a site whose connector or update fails (a credential that no longer decrypts, a database error) is listed under errors and the remaining sites are still processed, so one broken site never hides a notification from the others. 401 when no site accepts the signature or token (when no site verified it and one failed, that failure is the answer: 502 dam_error); 404 for an unknown provider or one no site uses. Audit media.dam_webhook with a null actor.

An asset is imported once per site: media has a partial unique index on (site_id, source_provider, source_external_id) where source_external_id is not null, and two POST /api/media/dam/import calls racing for the same asset end with one item (the loser answers 200 with it).

Site

MethodPathAuthPermission
GET/api/sitecookieany user
PUT/api/sitecookiesite.settings
POST/api/site/cdn/testcookiesite.settings
POST/api/site/cdn/purgecookiesite.settings
GET/api/site/cdn/purgescookiesite.settings
GET/api/site/onboardingcookiesite.settings
POST/api/site/onboarding/dismisscookiesite.settings
POST/api/site/onboarding/blankcookiesite.settings

PUT /api/site also takes media.dam (the DAM connector, see "DAM connectors" above) and cdn (the CDN purge hook: provider of none, cloudflare, fastly, bunny or generic, credentials per CDN_CREDENTIAL_FIELDS with the secret ones write-only and echoed back as ********, zoneId, tagPurge for a Cloudflare Enterprise zone, purgeEverythingOnTheme, and sMaxAge and staleWhileRevalidate in seconds for the public pages' Cache-Control). POST /api/site/cdn/test checks the posted settings (the mask fills in stored secrets) or, with an empty body, the stored ones and answers { ok, provider, name, message }. POST /api/site/cdn/purge { tags?, urls?, everything? } queues a purge (site-relative paths are allowed in urls; an empty body purges everything), sends it at once and answers 202 with the CdnPurge row (status, attempts, last error, note). GET /api/site/cdn/purges?page=&limit= lists the site's last 200 purges, automatic and manual. See caching.md. It takes a partial SiteSettings: name, tagline, baseUrl (must be a URL), defaultLocale and locales (BCP 47 tags, canonicalized; the default is always kept in the list; removing a locale that still has content answers 409 unless ?force=1), localePrefixDefault (prefix the default locale in URLs too), homePageId (entity id or null), theme (partial ThemeTokens: the core colorPrimary, colorText, colorBackground, fontBody, fontHeading, radius, maxWidth, and the extended tokens, which are validated as in Brand), identity (logoMediaId, logoMarkMediaId, logoOnDarkMediaId, faviconMediaId; null clears one) and seo (titleSuffix, defaultDescription, ogImageMediaId). theme, identity and seo are merged, not replaced. Saving evicts every cached page. theme here writes the live theme at once; the admin's Brand area writes the brand draft instead.

curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT \
  -d '{"theme":{"colorPrimary":"#0f766e"}}' $BASE/api/site

The getting started checklist on the dashboard reads GET /api/site/onboarding, which answers OnboardingState: each step with done computed on the server from the site's state (a kit installed or the blank start chosen, the home page edited, a logo or theme colors set or a brand version published, a second user, a domain, SEO defaults, an AI provider, the page builder tour taken), the counts, and whether the current user hid the list. POST /api/site/onboarding/dismiss hides it for the current user ({ "undo": true } shows it again) and POST /api/site/onboarding/blank records the choice to start without a kit; both answer the new state and are audited (onboarding.dismiss, onboarding.restore, onboarding.blank).

System

Instance-level operations. Backups are configured in the instance environment (BACKUP_TARGET, BACKUP_SCHEDULE, BACKUP_RETENTION_DAYS, BACKUP_ENCRYPTION_KEY; see install.md, Backups), so these routes only read the configuration and start runs.

MethodPathAuthPermission
GET/api/system/backupscookie or tokenusers.manage
POST/api/system/backups/runcookie or tokenusers.manage

GET /api/system/backups answers { settings, runs }: settings is BackupSettingsInfo (configured, target with credentials stripped, schedule in words, retentionDays, encrypted, nextRunAt, lastRun) and runs the last 200 BackupRun rows, newest first (id, trigger of schedule, manual or cli, status of running, ok or failed, startedAt, finishedAt, bytes, location, error, mediaUploaded, mediaTotal, version, database, warnings: unencrypted_secrets when the run holds the instance key files without BACKUP_ENCRYPTION_KEY). POST /api/system/backups/run (optional body { media?: boolean }, default true) runs a backup to BACKUP_TARGET and answers when it is done: 201 with the run when it succeeded, 500 with the failed run as the body (its error says why, and backup.failed is emitted to the notification rules), 400 without BACKUP_TARGET, 409 while another run started from the admin is still going. Audited as backup.run.

curl -sS -b jar -H "$H" -X POST $BASE/api/system/backups/run

The public health endpoints, /status and /status.json, are under Public routes.

Users

MethodPathAuthPermission
GET/api/userscookieusers.manage
POST/api/userscookieusers.manage
GET/api/users/:idcookieusers.manage
PUT/api/users/:idcookieusers.manage
DELETE/api/users/:idcookieusers.manage

POST takes { email, name, password (min 8), roles: Role[] } (global roles; an empty list is allowed for a user who only holds site or section roles) and returns 201 with the User (never the password hash). GET /api/users and GET /api/users/:id also carry siteRoles: [{ siteId, roles }] and sectionRoles: [{ sectionId, roles }]. Duplicate email gives 409. PUT takes any of name, roles, password. You cannot remove admin from your own roles or delete your own account (409). A new password or a changed roles list ends the user's other sessions (your own current session survives a self-change; audit action auth.sessions_revoked); DELETE ends them all. Sessions slide with use but never live longer than 30 days after sign-in.

Roles per site and per section

MethodPathNotes
GET/api/users/:id/site-roles[{ siteId, roles }] (users.manage).
PUT/api/users/:id/site-roles{ siteRoles: [{ siteId (id or key), roles }] } replaces the user's site roles; an entry with roles: [] removes it. The actor must hold users.manage on every site that changes (a global admin always does). Audited as user.site_roles; ends the user's other sessions.
GET/api/users/:id/section-roles[{ sectionId, roles }].
PUT/api/users/:id/section-roles{ sectionRoles: [{ sectionId, roles }] }, same rules, on the section's site. Audited as user.section_roles.

Sites

Permission: sites.manage (the global admin role). The id in the path may be the site key.

MethodPathNotes
GET/api/sitesSite[]: { id, key, name, domains, primaryDomain, defaultLocale, locales, status, brandParentId, createdAt, updatedAt }, the default site first.
POST/api/sites{ key, name, domains?, primaryDomain?, defaultLocale?, locales?, status?, brandParent? } → Site (201). Host names are lowercased and must be unique across sites (409); the primary domain leads domains. The new site gets default settings and its main and footer menus.
GET/api/sites/:idOne site.
PUT/api/sites/:idPartial update of the same fields. The default site keeps its key. status: 'paused' makes visitors see a 503 page while the API keeps working. brandParent (a site id or key, or null) sets "Inherit the brand of <site>" (Shared brands); 400 for the site itself or a chain. Audited as site.brand_parent.
DELETE/api/sites/:id204. Removes the site's content, revisions, media rows and files, menus, redirects, collections, forms and submissions, alerts, audiences, experiments, analytics, sections, role assignments and its brand. Sites that inherit its brand keep a copy of it first (audited as brand.inheritance_clear). The default site answers 409.
GET/api/me/sitesAny user: { sites: (Site & { roles })[], currentSiteId }, the sites the caller can open (every site for a user with a global role; otherwise the sites of their site or section roles) with their effective roles there. For the admin's site switcher.

GET /api/site and PUT /api/site act on the request's site. GET /api/auth/me reports siteId (the request's site) and, for a signed-in user, sections: [{ id, key, label, pathPrefix, menuKey?, roles, permissions }] for the sections they hold a role in on that site.

Sections

Department delegation on one site. Reads need a session; writes need site.settings. The id may be the section key.

MethodPathNotes
GET/api/sectionsSection[]: { id, siteId, key, label, pathPrefix, menuKey?, createdAt, updatedAt }. A user with site-wide rights sees every section; a section-only user sees theirs.
POST/api/sections{ key, label, pathPrefix, menuKey? } → Section (201). The prefix is a site path such as /engineering (never /), unique per site; menuKey must name a menu of the site. Entities already under the prefix join the section.
GET/api/sections/:idOne section.
PUT/api/sections/:idPartial update; a changed prefix releases and claims entities accordingly.
DELETE/api/sections/:id204; clears sectionId on its entities and removes its role assignments.

Entities carry sectionId (auto-assigned from the longest matching prefix on every save; a path change re-assigns; POST/PUT /api/entities accept sectionId to pin one, or null to re-assign). A section role grants its role's content permissions only on entities in that section: GET /api/entities and GET /api/review/queue return only the user's sections when they hold no site-wide role; every entity action outside them answers 403. A section editor may create content under the prefix (a slug may contain /, e.g. engineering/labs; a plain slug is prefixed with the section's path automatically), may PUT /api/menus/<menuKey> (items and label) when they hold content.publish in the section, and never touches site settings.

Forms

MethodPathAuthPermission
GET/api/formscookieforms.manage or forms.view_submissions (webhook secrets are masked as ******** without forms.manage)
POST/api/formscookieforms.manage
GET/api/forms/:idcookieas list; :id may also be the form key
PUT/api/forms/:idcookieforms.manage (sending the mask back keeps the stored secret)
DELETE/api/forms/:idcookieforms.manage; deletes its submissions too
GET/api/forms/:id/submissions?page=&limit=cookieforms.view_submissions → Paginated<FormSubmission>, newest first
GET/api/forms/:id/submissions.csvcookieforms.view_submissions
GET/api/forms/:id/feedback?days=30cookieforms.view_submissions → FormFeedbackSummary: the yes/no counts of a feedback form (fields helpful, comment, hidden page and version, as the docs kit's docs_feedback form) over the last days days (1 to 365) and the last five comments with their page and version. The Dashboard's Docs feedback card reads it when a form with the key docs_feedback exists.
DELETE/api/forms/:id/submissions/:sidcookieforms.manage
POST/api/forms/:id/submissions/:sid/redelivercookieforms.manage → FormSubmission
GET/api/forms/:id/submissions/:sid/files/:fidcookieforms.view_submissions; an uploaded file as an attachment (Content-Disposition: attachment, Cache-Control: private, no-store, nosniff, a sandbox CSP; HTML and SVG are served as application/octet-stream). 404 for any id that is not a file of that submission.
POST/api/forms/:key/submitnonepublic; exempt from the CSRF header
POST/api/forms/:key/validatenonepublic; exempt from the CSRF header. { _step: n, ...values } → 200 { ok: true, step } or 400 validation_error with details for the fields of step n only (conditions see every value). Stores nothing, needs no _t and no CAPTCHA; the Form block's stepper calls it before moving on. _step beyond the last step is a 400.

Definition body (FormDefinition without id/timestamps): key (^[a-z][a-z0-9_]{1,62}$, unique, 409 on clash), label, fields[] ({ key, label, type, required?, options?, placeholder?, help?, visibleWhen?, accept?, maxSize?, maxCount? }; types text, email, tel, textarea, select, checkbox, number, hidden, file; keys must be unique and select needs options), steps?[] ({ title, fields: [keys] }), submitLabel (default "Send"), successMessage, connectors[], captureUtm (default true).

  • Conditions: visibleWhen: { field, op, value? } with op one of equals, notEquals, contains, isEmpty, isNotEmpty, gt, lt (value is required except for isEmpty/isNotEmpty; a number for gt/lt). The named field must exist and may not be the field itself. A field is visible only while the rule matches the submitted value of field and that field is itself visible (rules chain; a cycle hides every field in it). Comparisons are case-insensitive on trimmed text; checkboxes compare as true/false; contains on a multi-value field means "includes". A hidden field is never required and its value is dropped from data even when sent.
  • Steps: each step names field keys (every key must exist, no key in two steps); fields in no step belong to the last step. steps: [] or absent means one page. The Form block renders a progress indicator and one step at a time; the final submit validates every step again.
  • File fields (type: 'file'): accept is a list of extensions (pdf, .pdf) or MIME types (application/pdf, image/*); empty means any type except a short block list of executable and browser-run types (exe, js, html, svg, …). maxSize in bytes (default 10 MB, never above FORM_UPLOAD_MAX_BYTES = 25 MB), maxCount (default 1, at most 10). These three keys are refused on other field types. The type is read from the file's bytes (sniffMime), so a PNG renamed .pdf is refused for accept: ['pdf'].

Connectors:

  • { kind: 'webhook', url, secret? }: POST JSON { form: { id, key, label }, submission: { id, data, createdAt }, meta } with headers X-Spillway-Event: form.submitted, X-Spillway-Delivery: <uuid> and, when secret is set, X-Spillway-Signature: sha256=<hex HMAC-SHA256 of the raw body>. Any non-2xx answer is a failed delivery.
  • { kind: 'hubspot', portalId, formGuid, fieldMap }: POST to https://api.hsforms.com/submissions/v3/integration/submit/{portalId}/{formGuid} with fields mapped through fieldMap (Spillway key → HubSpot property; empty values and unmapped keys are skipped), context: { pageUri, pageName (the form label), hutk (from the visitor's hubspotutk cookie when the browser sent it), ipAddress } and legalConsentOptions.consent.consentToProcess: true when the submission carried _consent. The endpoint needs no API key; the HubSpot form must accept external submissions. Set SPILLWAY_HUBSPOT_FORMS_URL to point the connector elsewhere (tests use a local fake).
  • { kind: 'email', to: string[] }: a plain-text message (one line per filled field, then page, referrer and UTM values). It is sent over SMTP when SMTP_URL is set (smtp://user:pass@host:587?from=cms@example.org, smtps://…:465 for implicit TLS; STARTTLS is used when the server offers it, &starttls=never disables it; SMTP_FROM is an alternative to ?from=). Without SMTP_URL the message is written to the log at info level and the delivery is recorded as ok: true, error: 'smtp not configured'.

Connectors run one after another, each with a 10 second timeout; the submission is stored before any of them runs. All outbound requests go through one helper that follows redirects by hand and refuses localhost, private (10/8, 172.16/12, 192.168/16, 100.64/10), link-local (169.254/16, fe80::/10) and unique-local addresses, including hostnames that resolve to one. Set SPILLWAY_ALLOW_PRIVATE_URLS=1 to deliver to an internal service; NODE_ENV=test also lifts the guard.

Submit POST /api/forms/:key/submit accepts application/json, application/x-www-form-urlencoded or multipart/form-data (required for file fields; repeated keys become lists; a multipart submit gets the media upload body limit of 25 MB plus framing instead of the 256 KB of the other encodings). Rules, in order:

  1. _hp (honeypot) must be absent or empty, else 400 rejected.
  2. Without a CAPTCHA provider: _t, the millisecond epoch when the form was rendered, must be present and at least 2000 ms in the past, else 400 too_fast. Headless clients must send it too. With a provider configured under Site settings → Forms (SiteSettings.forms.captcha): the token (cf-turnstile-response, h-captcha-response or _captcha) must be present and verify at the provider's siteverify endpoint (https://challenges.cloudflare.com/turnstile/v0/siteverify or https://api.hcaptcha.com/siteverify, called through the outbound guard with the secret, the token and the client IP; SPILLWAY_CAPTCHA_VERIFY_URL points both elsewhere for tests), else 400 captcha_failed; the timing rule is then not applied, the challenge takes its own time. A verified submission records meta.captcha.
  3. At most 20 submissions per 10 minutes per client IP, else 429 rate_limited.
  4. Values are validated against the definition: conditions first (hidden fields are skipped, see above), then required fields, email shape, select options, numbers (numeric strings are coerced), checkboxes (true, on, 1, yes → true; a required checkbox must be true), length limits (5000 characters, 20000 for textarea), and files (count, size, type from the bytes, an empty file is "file is empty", a nameless empty part is no file). Failures return 400 validation_error with details: [{ field, message }]. Keys that are not in the definition are dropped. Nothing is written until every check passed.
  5. Uploads are written under <DATA_DIR>/form-uploads/<file id>/<safe name> (never under /media, never in the media library) and the submission stores files: [{ id, field, filename, mime, size }]; data[field] holds the stored file name (a list for maxCount > 1), so connectors, email and CSV see names. Deleting the submission or the form removes the files. scanUpload() in packages/core/src/forms/uploads.ts is the antivirus hook point: a no-op that an operator replaces (throw to refuse the file).

Context fields: _page (the page URL; falls back to the Referer header), _utm (a JSON object of utm_* values; utm_* query parameters of _page are read as well), _consent (boolean-ish). With captureUtm true the stored meta holds page, referrer and utm; ip and userAgent are always stored; consent when sent.

Responses: with a JSON body or an Accept: application/json header, 200 { ok: true, message: <successMessage>, id } or the error shape above. A form-encoded request without that header (a browser with JavaScript off) gets 303 back to the page's path on this origin with ?form=<key>&sent=1#form-<key>, or &error=1 for a 4xx; other origins in _page are ignored and the redirect goes to /.

Connectors then run; the stored status is received (no connectors), delivered (all ok) or failed (at least one failed, the submission is still stored). deliveries is an append-only log of { connector, ok, error?, at }. Redeliver re-runs every connector kind whose latest delivery failed and appends the results.

CSV streams text/csv with Content-Disposition: attachment; filename="<key>-submissions-<yyyy-mm-dd>.csv". Columns: the field keys in definition order (a multi-file field as a.png; b.png), then createdAt, utm (a JSON object, empty when none), referrer, page, and files (field:id:name pairs joined by ;) when the form has a file field. RFC 4180 quoting (CRLF records, quotes doubled, values with commas, quotes or newlines quoted). Text that starts with =, +, -, @ or a tab gets a leading apostrophe so spreadsheets do not run it as a formula.

Saving a form evicts every cached page that embeds it (pages carry the tag form:<id>).

Review

MethodPathAuthPermission
GET/api/review/queuecookiecontent.publish → ReviewQueueItem[]
POST/api/entities/:id/rejectcookiecontent.publish; body { note }
GET/api/entities/:id/commentscookiecontent.view_drafts → EntityComment[], oldest first
POST/api/entities/:id/commentscookiecontent.view_drafts; body { body, fieldKey? } → 201 EntityComment
PUT/api/comments/:idcookiecontent.view_drafts; body { resolved: boolean }
DELETE/api/comments/:idcookiethe comment's author, or content.edit_any
GET/api/entities/:id/checkscookiecontent.view_drafts → { checks: EntityCheck[] }

Queue: every entity with status review, oldest submission first (waitingSince is the entity's updatedAt, which the submit transition sets), with authorName ("Former user" when the account is gone) and commentCount (all comments, resolved or not).

Reject only works on an entity in review (409 otherwise). It sets status to draft and writes a new revision whose note is Rejected: <note> (just Rejected when the note is empty); that revision becomes currentRevisionId.

Comments are deleted with their entity. authorName is joined from the user table at read time.

Checks run on the entity as stored (the latest revision). Levels:

idlevelrule
required:<field>errora required field of the type is empty (possible when a field became required after the entity was saved)
alt:<blockId>erroran Image block with a src and no alt, at any depth (slots are walked)
`seo:seoTitle:missingshortlong`
`seo:seoDescription:missingshortlong`
heading:no-h1, heading:many-h1warningacross Hero titles and Heading blocks there should be exactly one H1 (only when the layout has headings)
heading:skip:<blockId>warninga heading more than one level deeper than the previous one
banned:<word>warningthe brand profile's bannedWords found as whole words (case-insensitive) in the title, text fields, richtext and block text
link:<path>warningan internal link (href starting with /) in a link field, richtext, HTML text or block link props that is neither a published page nor an enabled redirect; /, /admin, /api, /media, /search, /_* and file-like paths are ignored

Only errors block publishing (see Entities → Publish).

Editorial: scheduling, locks, revision diff, notifications

MethodPathAuthPermission
PUT/api/entities/:id/schedulecookiecontent.publish; body { publishAt?, unpublishAt? } → Entity
DELETE/api/entities/:id/schedulecookiecontent.publish → Entity with both times cleared
GET/api/review/queue?view=scheduledcookiecontent.publish → ScheduledItem[]
GET/api/entities/:id/lockcookiecontent.view_drafts → EntityLock or null
POST/api/entities/:id/lockcookieedit access; acquires or heartbeats → EntityLock; 409 lock_held when someone else holds it
DELETE/api/entities/:id/lockcookiethe holder, or content.edit_any to break it → 204
GET/api/entities/:id/revisions/:a/diff/:bcookiecontent.view_drafts → RevisionDiff
GET/api/notifications/rulescookiesite.settings → NotificationRule[]
POST/api/notifications/rulescookiesite.settings; body NotificationRuleInput → 201
PUT/api/notifications/rules/:idcookiesite.settings; body NotificationRuleInput
DELETE/api/notifications/rules/:idcookiesite.settings → 204
POST/api/notifications/rules/:id/testcookiesite.settings → OutboxItem (delivered right away)
GET/api/notifications/outbox?page=&limit=cookiesite.settings → Paginated<OutboxItem>, newest first, at most the last 500
PUT/api/auth/mecookieany user; body { name?, notifyOnReview?, notifyOnComment? } → MeResponse

Schedule. Entity carries publishAt, unpublishAt and lastScheduleError (all nullable). PUT changes only the keys present; null clears that half, and clearing both is the same as DELETE. Times are ISO 8601 (anything Date parses; stored as UTC). Rules: publishAt is refused with 409 while the entity is published (unpublish first); unpublishAt must be after publishAt (400). Setting a schedule clears lastScheduleError. The list endpoint accepts status=scheduled, a virtual filter for "a pending publishAt on unpublished content, or a pending unpublishAt on published content"; the real status stays draft/review/published until the time. The scheduled queue view returns { entity, authorName, publishAt, unpublishAt, lastScheduleError }, soonest first.

The ticker (SCHEDULE_TICK_MS, 30 s; in-process unless SPILLWAY_TICKER=0, and spillway run-scheduled from the CLI) publishes due items through the same path as POST /publish with force: false and the actor system, so checks, cache eviction, entity.publish audit rows and the entity.published notification all happen; a successful publish clears publishAt. A refused publish logs a warning, writes lastScheduleError, audits entity.schedule_failed and queues a schedule.failed notification, once per distinct error; the item stays scheduled and is retried every tick until the checks pass. A due unpublishAt on a published item moves it to archived (publishedRevisionId cleared, page 404, cache evicted, audit entity.archive). Every pass that did something writes a scheduler.run audit row with what was published, archived and failed. The same tick then merges due workstreams.

Locks. EntityLock is { entityId, userId, userName, acquiredAt, heartbeatAt, expiresAt }. POST by the holder refreshes heartbeatAt and pushes expiresAt to now plus LOCK_TTL_MS (15 min); an expired row counts as no lock and anyone may take it. POST by someone else, and PUT /api/entities/:id by someone else while a live lock exists, answer 409 { error: { code: 'lock_held', message, details: EntityLock } }. A PUT with ?breakLock=1 from a user with content.edit_any deletes the lock first (audited as entity.break_lock); DELETE by such a user does the same. Locks are removed with their entity. The admin heartbeats every LOCK_HEARTBEAT_MS (60 s) and releases on leave.

Diff. RevisionDiff is { a: { id, number, createdAt }, b: {...}, fields: FieldDiff[], layout: { added, removed, changed, blocks: { id, type, change }[] } }. Both revisions must belong to the entity (404 otherwise). fields covers the title (when it is not a type field), every field of the type except the layout field, and any extra data keys; each is { key, label, kind: 'added'|'removed'|'changed'|'same', before, after, inline? }. Empty-on-both-sides fields are dropped. inline is present for changed string values: a word-level diff as { op: 'eq'|'ins'|'del', text } parts (whitespace tokens preserved), with rich text first reduced to plain text (HTML stripped, or the text of a ProseMirror document). The layout field is compared block by block, matching on props.id across the root content and every zone: a block is changed when its type or any prop differs (deep, key-order-insensitive); order changes alone are same.

Pruning. SiteSettings.content.keepRevisions (PUT /api/site with { content: { keepRevisions } }, 1 to 10000, default DEFAULT_KEEP_REVISIONS = 50). After every revision write (update, reject, restore) the entity keeps its newest N revisions plus, whatever N, the current revision, the published revision and every revision a comment pins (EntityComment.revisionId, the current revision at the time the comment was posted). spillway prune-revisions [--keep N] runs the same rule over every entity.

Notifications. NotificationRule is { id, event, channel, enabled, filter?: { types? }, createdAt, updatedAt }; events are review.submitted, review.rejected, entity.published, entity.unpublished (a manual unpublish or the scheduler's unpublishAt, for an entity that was live), entity.deleted (of a published entity), comment.added, schedule.failed, form.submitted, workstream.merged, translation.ready (a TMS job's translations can be pulled; data.translationJob carries { id, provider, externalId, externalUrl, locales, entityCount, title, adminUrl }); channels are { kind: 'email', to: 'author'|'reviewers'|'custom', emails? }, { kind: 'webhook', url, secret? } and { kind: 'slack', webhookUrl }. Webhook secrets are returned masked as ********; sending the mask back keeps the stored secret. filter.types limits a rule to those content types (events without an entity, such as form.submitted, always match).

When an event happens, one outbox row is queued per matching enabled rule, inside the same transaction as the change. Email recipients are resolved at that moment: author is the entity's author, reviewers is every user whose roles grant content.publish; both skip the user who caused the event and anyone whose notifyOnReview (review and publish events, schedule failures) or notifyOnComment (comments) preference is off, and a rule that resolves to nobody queues nothing. OutboxItem is { id, event, ruleId, channel, target, subject, status: 'pending'|'delivered'|'failed', attempts, lastError, nextAttemptAt, createdAt, deliveredAt }.

Delivery runs on the ticker (deliverOutbox): email through the SMTP connector (SMTP_URL, plain text; without it the row is marked delivered with lastError: 'smtp not configured' and the text is logged), Slack as POST { text } to the incoming webhook, and webhooks as POST JSON { event, at, subject, site: { name, baseUrl }, data } with X-Spillway-Event, X-Spillway-Delivery and, with a secret, X-Spillway-Signature: sha256=<hmac-sha256 of the body>. data holds what the event has: entity (id, type, title, path, publicPath, locale, status, authorId, adminUrl, cacheTags; publicPath carries the locale prefix and cacheTags are the page cache's tags for the entity plus list:<type>, so a headless front end can revalidate by them, see adapters.md), actor, note, comment, error, form, submission, and test: true for a test send. A failed attempt sets nextAttemptAt 30 s later, then 2 min; after OUTBOX_MAX_ATTEMPTS (3) the row is failed. The table is trimmed to about twice OUTBOX_KEEP (500) rows. Outbound URLs go through the same private-network guard as form webhooks.

Translation management systems (TMS)

MethodPathAuthPermission
GET/api/i18n/tms/statuscookiecontent.view_drafts → TmsStatus { provider, name, configured, manual }
GET/api/i18n/tms/settingscookiesite.settings → TmsSettings (secrets masked)
PUT/api/i18n/tms/settingscookiesite.settings; body TmsSettingsInput → TmsSettings
POST/api/i18n/tms/testcookiesite.settings → TmsTestResult { ok, message, provider }
POST/api/i18n/tms/jobscookiecontent.translate; body { entityIds, targetLocales, due? } → 201 TranslationJob
GET/api/i18n/tms/jobs?status=&page=&limit=cookiecontent.view_drafts → Paginated<TranslationJob>, newest first
GET/api/i18n/tms/jobs/:idcookiecontent.view_drafts → TranslationJob
POST/api/i18n/tms/jobs/:id/pullcookiecontent.translate → TranslationJob & { errors } (API providers; 400 for the manual one; 502 tms_error when the provider fails)
POST/api/i18n/tms/jobs/:id/cancelcookiecontent.translate → TranslationJob
GET/api/i18n/tms/jobs/:id/xliffcookiecontent.view_drafts → application/zip (the XLIFF bundle)
POST/api/i18n/tms/jobs/:id/xliffcookiecontent.translate; a zip, an XLIFF document or multipart file parts → TranslationJob & { errors } (manual provider only, 403 otherwise)
POST/api/i18n/tms/webhook/:providernonerate limited; X-Webhook-Secret or X-Spillway-Signature → { ok, ready: string[], polled }

Settings. TmsSettings is { provider: 'none'|'xliff'|'crowdin'|'phrase'|'smartling', projectId, baseUrl, credentials: { token?, userId?, secret? }, webhookSecret, configured, webhookUrl, updatedAt }. Secrets are write-only: a stored token or secret comes back as ******** (the Smartling user identifier in clear), PUT with the mask keeps the stored value and null clears it. baseUrl must be a public https URL (400 otherwise). Switching provider drops credentials the new one does not use. The audit row translation.settings.update holds the masked before and after.

Jobs. TranslationJob is { id, siteId, provider, externalId, externalUrl, entities: [{ id, type, title, locale, units }], sourceLocale, targetLocales, status, report, error, due, createdBy, createdAt, updatedAt }; status is requested (manual provider, waiting for an upload) → in_progress (handed to an API provider; the ticker polls every 10 minutes) → ready (every target complete; a translation.ready notification is queued) → applied, or failed / cancelled. report is { files: [{ entityId, locale, targetEntityId, appliedAt, applied: string[], skipped: [{ id, reason }], unmatched: string[] }], progress?: { [locale]: percent }, message? }. Creating a job sends each entity's translation group source as one XLIFF 2.0 file (unit ids field:<key>, field:<key>.<i>, block:<blockId>:<prop>; rich text inline markup as <pc>/<ph> with <originalData>); 400 when a target is the source locale or not a site locale, when the entities mix source locales, or when one has no translatable text; 409 when no provider is configured. The job row exists even when the provider refuses the hand-off (status: 'failed', error).

Provider failures. When the provider's API fails during a pull, a job creation or a cancel (an HTTP error, a malformed answer), the route answers 502 tms_error with the provider's message and details.provider, like the DAM routes' dam_error; nothing is applied or marked. Validation stays 400 (the manual provider's refusal to pull included), and a hand-off refused at creation is still recorded on the job (status: 'failed', 201).

Pull and upload write the targets as drafts: the translation in trgLang is created with mode copy when the group has none, then updated with a revision note; the published revision is untouched. The answer carries the job and errors: [{ name, error }] for files that could not be applied at all (wrong locale, unknown entity, malformed XLIFF). A job is applied once every (entity, locale) pair has a file report. The manual bundle is a zip of <entityId>.<locale>.xlf plus manifest.json; uploads take a zip (application/zip), one document (application/xliff+xml or any XML type) or multipart file parts, up to 500 files of 16 MB each, bounded like a media upload.

Webhook. Public, 60 requests per minute per IP, no CSRF header. 404 for none, xliff, an unknown provider or one the site does not use; 403 without a configured secret or with a bad one (audited as translation.webhook.rejected). Verified with X-Spillway-Signature: sha256=<hex hmac-sha256(body, secret)> or X-Webhook-Secret: <secret>. The payload's references (Crowdin file.id, Phrase jobParts[].uid, Smartling fileUri as a query parameter or JSON field) select the open jobs to check; a payload that names nothing checks every open job of the provider. Each is confirmed with the provider's status call before it is marked ready. See localization.md.

Headless: the public render API

For front ends that render Spillway content elsewhere (the Astro and Next.js adapters in adapters.md, or any fetch client through @spillway-cms/client). All GET, JSON, under /api/public, no session needed for published content. Every response carries Cache-Tag with the same tags the page cache uses, and the public Cache-Control (no-store for previews). The site is the request's site (Host header), or the one a bearer token names with X-Spillway-Site.

MethodPathWhat
GET/api/public/render/<path>PublicRender: the entity at the public path (/, /about, /es/about; ?locale= names the locale instead), its published snapshot with every block's server-resolved props (Collection items, Form definition, Image and Hero media, Shared blocks, Embed hosts) and author HTML sanitized, richHtml and media for article-style types, seo (title, description, canonical, hreflang alternates, Open Graph image, JSON-LD, noindex), menus (main, footer, resolved), cacheTags, site (PublicSite) and preview. Media URLs are absolute, internal links relative. Query parameters other than the endpoint's own are handed to the blocks (exposed filters, page, month). 404 for anything not live. Cached per site and path with its tags (X-Spillway-Cache: HIT or MISS); query-dependent pages and previews are not cached.
GET/api/public/sitePublicSite: id, key, name, tagline, origin, baseUrl, locales, localePrefixDefault, homePageId, theme and themeCss (the :root{…} rule), seo (titleSuffix, defaultDescription, ogImageUrl). No integrations, privacy or embed settings.
GET/api/public/menus/:key?locale=&path=PublicMenu { key, label, items } with items resolved to links the way the shell renders them (per-locale labels, translations, locale prefixes, unpublished targets dropped, current for the item that matches path).
GET/api/public/paths?type=&locale={ items: PublicPath[] }: every published path (/ and the locale homes first, then every entity with a path, locale prefixes included) with id, type, title, locale, updatedAt, publishedAt. For getStaticPaths and generateStaticParams.
GET/api/public/entities/:type/:idOrSlug?locale=The Entity by id or slug, as its published snapshot. 404 for drafts and other types.
GET/api/public/collections/:key/items?page=&limit=&locale=&q=&filters=Paginated<CollectionItem> for the collection (key or id): its saved filters plus filters (a JSON-encoded CollectionFilter[], at most 20, same ops as collections) and a keyword q. Image URLs absolute.
GET/api/public/media/:idThe MediaItem with absolute url and variants.

Previews. Inside a workstream (X-Spillway-Workstream: <id or key>, or ?workstream=) the render, site, menu, entity and collection endpoints return the branch version for a bearer token with content.view_drafts or a share-link ?token= (POST /api/workstreams/:id/share-link); anyone else gets 401. On the main line, ?preview=1 with such a token renders the latest revision of any status. Preview responses are Cache-Control: no-store, X-Spillway-Cache: BYPASS and X-Robots-Tag: noindex, and seo.noindex is true, seo.canonical absent and seo.jsonLd empty.

Revalidation. A notification rule with a webhook channel on entity.published, entity.unpublished or entity.deleted (see Editorial) posts the event with data.entity.publicPath and data.entity.cacheTags, signed; the adapters verify it and revalidate by those tags.

Public routes

These are not under /api and return HTML or files.

MethodPathWhat
GET/The home page (SiteSettings.homePageId), or a welcome page when none is set.
GET/<path>The published entity whose path matches. 404 otherwise.
GET/_preview/:entityIdThe latest revision, any status. Requires a session with content.view_drafts (401 otherwise). Sent with Cache-Control: no-store, X-Spillway-Cache: BYPASS, X-Robots-Tag: noindex and a noindex meta tag. ?workstream=<id> renders the entity's branch version.
GET/_preview/ws/:id/<path>The whole site as it would look after the workstream merges (branch revisions, created pages, delete intents, menu and settings overlays), internal links rewritten to stay in the preview. content.view_drafts, or ?token= from a share link. Same no-store, bypass and noindex headers.
GET/media/:id/:filenameAn uploaded file.
GET/admin, /admin/*The admin SPA (503 with instructions if it is not built).
GET/healthz{ ok: true, version }. No auth.
GET/status.jsonInstanceStatus: status (up or degraded), version, uptimeSeconds, startedAt, database (sqlite or postgres), cache (memory or redis), search (sqlite, postgres or meilisearch), ticker (enabled, lastTickAt, stale), backup (configured, lastRunAt, lastStatus, failed), redis.ok (null without Redis), degraded (any of ticker_stale, backup_failed, backup_unencrypted, redis_down) and checkedAt. No auth, Cache-Control: no-store, 120 requests a minute per address, nothing secret. 404 with STATUS_PAGE=off. See install.md, Status.
GET/statusThe same as a small HTML page. A site that publishes its own page at /status (a StatusBoard, see blocks.md) is served that page instead.

Rendered pages carry Cache-Tag: entity:<id>,type:<type>,site:<siteId>, X-Spillway-Cache: HIT or MISS, and Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300. Paths that start with /_ or /. or end in a file extension (/robots.txt, /favicon.ico) are answered 404 without a content lookup. Errors on non-API paths come back as a small HTML page when the request accepts text/html. Until setup has run, every non-API, non-admin request except /healthz, /status and /status.json redirects to /admin. See architecture.md.

Redirects and the 404 log

Permission: site.settings for everything here.

MethodPathWhat
GET/api/redirectsRedirect[], newest first.
POST/api/redirects{ from, to, status?, enabled? } → Redirect (201). from is a site path; it is stored normalized (lowercase, no trailing slash, no query). to is a site path or an absolute http(s) URL. status is 301 (default) or 302. 409 when a redirect from that path exists, 400 when to points back at from. Creating a redirect also removes that path from the 404 log.
GET/api/redirects/:idOne redirect, with its hits.
PUT/api/redirects/:idPartial update of the same fields.
DELETE/api/redirects/:id204.
GET/api/redirects/misses?limit={ items: [{ path, count, lastSeen }] }: the paths visitors asked for that answered 404, most frequent first (limit 100 by default, max 500).
DELETE/api/redirects/misses?path=Drops one path from the log. 204.

Public behavior: before any page lookup the request path is lowercased, decoded and stripped of a trailing slash and compared with the enabled redirects (kept in memory, refreshed on every change). A match answers with the redirect's status, a Location header (the original query string is appended when to has none) and Cache-Control: no-store, and increments hits. A page that answers 404 is logged in the 404 log unless it looks like scanner noise (.php, .env, wp-admin, file extensions such as .ico or .png, and so on). Paths under /api, /admin, /media and /_preview are never redirected or logged.

Automatic redirects: when a published entity's path changes (a slug edit, or a locale change), a 301 from the old path to the new one is created inside the same transaction. An existing redirect from the old path is left alone; redirects that pointed at the old path are repointed at the new one so there are no chains; and a redirect that started at the new path is deleted so there is no loop. Drafts never create redirects.

Collections

A collection is a saved query over one content type: filters, a sort and a limit. Reads need any session; writes need types.manage; the items endpoint is public.

MethodPathWhat
GET/api/collectionsCollection[] sorted by label.
POST/api/collections{ key, label, type, filters?, sort?, limit? } → Collection (201). key follows the content-type key rule and must be unique (409 otherwise). filters default to [], sort to { field: 'publishedAt', dir: 'desc' }, limit to 10 (1..100).
GET/api/collections/:idBy id or by key.
PUT/api/collections/:idPartial update; evicts every cached page that embeds the collection.
DELETE/api/collections/:id204.
POST/api/collections/preview{ type, filters?, sort?, limit? } → CollectionItem[] (first page) for the admin builder. Any signed-in user; content.view_drafts sees unpublished items.
GET/api/collections/:id/items?page=&limit=&locale=Paginated<CollectionItem>. No auth needed; anonymous callers get published entities only, and a published entity with newer draft edits is listed from its published revision. limit defaults to the collection's own limit; locale narrows to one language (every locale without it; a Collection block on a page lists the page's locale by default). path is the public path (/es/blog/x) and locale the item's language. Sent with Cache-Control: public, max-age=0, s-maxage=60.

Filters: { field, op, value? }, all combined with AND. field is a system field (title, status, slug, path, locale, authorId, publishedAt, createdAt, updatedAt, id) or a data key, which is read with json_extract(data, '$.key') so numbers, booleans and ISO date strings compare naturally. Ops: eq, neq (null counts as not equal), in (value must be an array; an empty array matches nothing), contains (case-insensitive substring; % and _ are literal), gt, gte, lt, lte and exists (value: false inverts it; empty strings and empty arrays count as missing). sort.field takes the same names. Unknown types, malformed field names and an in without an array are 400.

CollectionItem is { id, type, title, path, summary?, imageUrl?, date?, data? }: summary is the first non-empty of summary, seoDescription, excerpt, description; imageUrl is the public URL of the media item in heroImage, image, thumbnail or cover; date is publishedAt, else publishDate or date from the data. The API includes data; the Collection block drops it before rendering.

Pages that contain a Collection block carry the cache tags collection:<id> and list:<type>. Publishing, unpublishing, updating a published entity or deleting an entity of that type evicts them, so a new post appears in every "latest posts" list on the next request.

Search

MethodPathWhat
GET/api/search?q=&type=&term=&locale=&page=&limit=SearchResponse: Paginated<SearchResult> plus facets ({ type: { post: 3 }, terms: { Research: 2 } }, counts over the matching set; the SQL providers fill type only). Public. Anonymous callers and sessions without content.view_drafts get published entities only; editors get drafts, review and archived content too. type narrows to one content type, term to entities tagged with that term label (Meilisearch only), locale to one language (every locale otherwise). An empty q returns no items. path in a result is the public path (/es/about); locale is the result's language.
POST/api/search/reindexRebuilds the whole index (and the Meilisearch indexes when SEARCH_URL is set) → { indexed }. site.settings. spillway search reindex does the same from the CLI.

SearchResult is { id, type, title, path, snippet, score, updatedAt }. snippet is plain text (HTML stripped) with each matched term wrapped in « and »; score is the negated FTS5 bm25 rank (title weighted 5, body 1), higher is better.

The index is a SQLite FTS5 table (search_index) over the title and the body: every text, textarea and richtext field that is not searchable: false (HTML stripped, ProseMirror JSON flattened) plus the readable text of a layout field (block string props, excluding links, ids and option values). It is updated inside the same transaction as every create, update, publish, unpublish, restore and delete. A published entity is indexed from its published revision, so draft edits are not searchable by visitors until they are published. The query is split into tokens, each quoted so FTS5 syntax in user input is inert, and the last token matches as a prefix (kaya finds "kayak"). If the SQLite build has no FTS5 the migration creates a plain table instead and search falls back to case-insensitive LIKE with the same response shape.

Public page: GET /search?q=&page= renders a results page (search form, results with links and highlighted snippets, "No results" text, pagination of 10) inside the site shell, with X-Robots-Tag: noindex. Visitor results are cached for 60 seconds per query under the tag search, which every index write also evicts; sessions that can view drafts see drafts and bypass the cache (X-Spillway-Cache: BYPASS). The /search route takes precedence over an entity whose path is /search.

SEO files

MethodPathWhat
GET/sitemap.xmlEvery published entity with a path, loc built from BASE_URL (else the site's baseUrl, else the request origin) and lastmod = updatedAt. The home page is listed as / as well as under its own path. On a site with more than one locale this is a <sitemapindex> pointing at /sitemap-<locale>.xml for each locale.
GET/sitemap-<locale>.xmlOne locale's URL set with xhtml:link rel="alternate" hreflang entries for its published translations and x-default. 404 for a locale the site does not have.
GET/robots.txtSiteSettings.seo.robots verbatim when set, else User-agent: * / Allow: / with /admin, /api/, /_preview/ and /search disallowed. A Sitemap: line is appended unless the text already has one.
GET/llms.txtOnly when SiteSettings.seo.llmsTxt is true (404 otherwise): # <name>, > <tagline>, the default description, then one ## <type plural> section per content type with - [Title](url): summary lines for every published entity with a path.
GET/llms-full.txtOnly when seo.llmsTxt is true: the generated llms-full.txt (every documentation page as text) from DOCS_DIR (default docs/reference); 404 when the file is absent. Written by pnpm docs:build.
GET/openapi.jsonThe generated OpenAPI 3.1 description of this API from the same folder, whenever the file exists; 404 otherwise.

PUT /api/site accepts seo.robots (string, up to 5000 characters) and seo.llmsTxt (boolean). These files are generated per request and sent with Cache-Control: public, max-age=0, s-maxage=300.

Structured data: every rendered page has JSON-LD <script type="application/ld+json"> blocks in the head, built only from values that exist (no empty properties). The home page (homePageId) gets WebSite and Organization (name, url, default description or tagline, the site og image as logo). Types whose fields include publishDate or body get an Article (headline, description, datePublished from publishDate else publishedAt, dateModified, author name, first media field as image, publisher, mainEntityOfPage); everything else gets a WebPage (name, description, url, dates). Paths with more than one segment also get a BreadcrumbList starting at Home; an intermediate segment uses the title of the published entity at that path when there is one, else the segment itself. Previews carry no structured data.

AI

All routes need a cookie session. Task routes are limited to 60 requests per minute per user (429 rate_limited) and return 409 with code ai_disabled, ai_not_configured or ai_cap_reached when AI is off, the task's provider has no key (or, for openai_compatible, no base URL), or the monthly token cap is reached. A provider failure is 502 ai_provider with the provider's own message (never the key). Every call, successful or not, is written to ai_log and to the audit log (ai.<task>). Nothing is ever published or saved by these routes.

MethodPathPermission
GET/api/ai/settingssite.settings
PUT/api/ai/settingssite.settings
GET/api/ai/statusany user
GET/api/ai/usage?month=YYYY-MMsite.settings
GET/api/brandany user
PUT/api/brandsite.settings
POST/api/ai/pageai.use + content.create
POST/api/ai/assistai.use + content.create
POST/api/ai/typeai.use + types.manage
POST/api/ai/altai.use + media.upload

GET /api/ai/settings returns AiSettings: providers (per provider: model, optional baseUrl, hasKey), tasks (task → { provider, model? }), monthlyTokenCap (number or null) and enabled. Keys are never returned. PUT takes AiSettingsInput: any of those fields, where providers.<name>.apiKey is write-only ("sk-…" stores a new key, null clears it, omitted keeps it). A baseUrl must be https:// and must not point at a private network address (400 ai_base_url_invalid, checked again before every call); http://127.0.0.1 is accepted only when the server runs with NODE_ENV=development (a local Ollama). Keys are encrypted with AES-256-GCM using <DATA_DIR>/ai-secret (created once, mode 0600); when no key is stored the server also looks at ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY or OPENAI_COMPATIBLE_API_KEY. Defaults: all tasks → anthropic, model claude-opus-5-5; enabled: false.

curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT \
  -d '{"enabled":true,"providers":{"anthropic":{"apiKey":"sk-ant-…"}},"monthlyTokenCap":2000000}' $BASE/api/ai/settings

GET /api/ai/status → { enabled, configured, providers } so the admin can gray out its buttons. GET /api/ai/usage → AiUsageReport for the month (default: the current UTC month): calls, inputTokens, outputTokens, byTask, byUser.

GET/PUT /api/brand read and update the BrandProfile (name, voice, tone[], audience, terminology[], bannedWords[], boilerplate, examples[], updatedAt) of the current site (the request's site, like every other route). Before migration 0041 there was one profile per instance; it was copied to every site, so existing sites read what they read before. PUT is a partial merge of the published profile, kept in step with the brand draft when there is one; on a site that inherits a brand whose parent locks Voice it answers 403 brand_locked. The admin edits the voice in the brand draft instead. The current site's published (effective) profile is injected into the system prompt of every task and gives the pre-publish checks their banned words.

POST /api/ai/page takes { brief (10..8000 chars), typeKey?, goal?, audience? } and returns { title, layout, notes, usage }. The prompt lists every block from @spillway-cms/blocks (type, description, aiHint and the props derived from the Puck field definitions, including slot fields); the prompt also lists, per block, the block styles the person may use (key, name and description) and a returned style outside that list is dropped with a note, so the block takes the default; the reply is validated against the config: unknown block types and props are dropped, select/radio values outside the options fall back to the default, missing props are filled from defaultProps, Columns slots are nested, and every block gets an id. Invalid JSON is retried once with the parse error. notes holds the model's notes followed by the validator's.

POST /api/ai/assist takes AiAssistRequest (action: write, rewrite, shorten, expand, tone, fix, translate, summarize; text?, instruction?, locale? for translate, field? { key, label, aiHint?, type? }, context? { title?, typeLabel? }) and returns { text, usage }. Rich text fields get simple HTML back; text fields get one line. translate runs on the translate task, summarize on summary, everything else on copy.

POST /api/ai/type takes { description } and returns { type: ContentTypeInput, rationale, usage }. Keys are repaired to match the key regexes, titleField is guaranteed to exist, unknown refTypes are downgraded to text. The type is not created; POST /api/types it when you are happy.

POST /api/ai/alt takes { mediaId } (PNG, JPEG, WebP or GIF up to 8 MB), sends the image to the alt task's provider and returns { alt, usage }. The media item is not updated; PUT /api/media/:id it.

usage is { provider, model, inputTokens, outputTokens, ms } on every task response.

Events, listings and feeds

MethodPathNotes
GET/feeds/:collectionKey.icstext/calendar: one VEVENT per published entity of the collection with RRULE/EXDATE for recurring daterange fields. Public; Cache-Tag collection:<id>,list:<type>.
GET/feeds/:collectionKey.rssRSS 2.0 of the collection's published items.

Collections accept expand: 'occurrences' and filters or a sort on the virtual field occurrence.start (values: ISO instant, now, now+<n>d, now-<n>d); expanded items carry occurrence: { start, end?, allDay? }. Field type daterange stores { start, end?, allDay?, timezone?, recurrence?: { freq, interval?, byWeekday?, until?, count?, exceptions? } }; the server keeps entity_occurrence in sync on every write (spillway sync-occurrences rebuilds it).

Alerts

MethodPathNotes
GET/api/alertsSiteAlert[] (content.publish).
POST/api/alertsSiteAlertInput → SiteAlert (201). level is info, warning or emergency; href a path or http(s) URL; startsAt/endsAt ISO or null.
PUT/api/alerts/:idPartial<SiteAlertInput> → SiteAlert.
DELETE/api/alerts/:id204.
GET/_alertsPublic { alerts }: enabled alerts inside their window, minus those dismissed by the sw_alert_<id>=1 cookies; Cache-Control: no-store. The shell's inline script renders them.

Menus

MethodPathNotes
GET/api/menusMenu[] (any user). main and footer always exist.
POST/api/menus{ key, label, items } → Menu (201, site.settings). Keys match ^[a-z][a-z0-9_-]{0,62}$; items nest two levels and carry label, optional per-locale labels ({ es: 'Acerca' }), href (path, http(s), mailto:, tel:) or entityId, newTab, children. On a localized page the locale's label wins, entity links go to the translation in that locale when it is published, and plain internal hrefs get the locale prefix.
GET/api/menus/:idMenu by id or key (/api/menus/main).
PUT/api/menus/:idPartial<{ key, label, items }> → Menu (site.settings). The built-in keys cannot change.
DELETE/api/menus/:id204; main and footer answer 409.

Pages that show a menu carry the cache tag menu:<key>; a menu write, and a path or status change of a linked entity, evicts them.

Workstreams

A workstream is a branch of content changes merged or discarded as one; see workstreams.md for the semantics.

MethodPathAuthPermission
GET/api/workstreams?status=cookiecontent.view_drafts → Workstream[]
POST/api/workstreamscookiecontent.create; body { label, key?, description? } → Workstream (201; the caller owns it; key defaults to the slug of the label)
GET/api/workstreams/:idcookiecontent.view_drafts → Workstream & { items: WorkstreamItem[] } (the id may be the key)
PUT/api/workstreams/:idcookieowner or content.publish; body { label?, description? } → Workstream
DELETE/api/workstreams/:idcookieowner or content.publish → 204 (purges an open branch first)
GET/api/workstreams/:id/diffcookiecontent.view_drafts → WorkstreamDiff
POST/api/workstreams/:id/mergecookiecontent.publish; body { force? } → { workstream, merged: { kind, targetId, action }[] }; 409 workstream_conflicts, 422 checks_failed
POST/api/workstreams/:id/discardcookieowner or content.publish → Workstream (status discarded)
POST/api/workstreams/:id/schedulecookiecontent.publish; body { publishAt: string | null } → Workstream; the ticker merges it when the time comes
POST/api/workstreams/:id/share-linkcookieowner or content.publish → { token, url, expiresAt } (201; 7 days)

Workstreams belong to the request's site (Host or X-Spillway-Site): keys are unique per site, the list shows that site's workstreams, another site's workstream answers 404 by id as much as by key, and the preview renders that site. Editing inside a workstream is done through the existing entity, menu and site endpoints with the header X-Spillway-Workstream: <id or key> (?workstream= on preview URLs); an unknown workstream (or another site's) answers 404, a merged or discarded one answers 409 workstream_read_only on writes. GET /api/menus, GET /api/menus/:id and GET /api/site return the branch's overlays with the header; PUT /api/menus/:id and PUT /api/site write to them. The notification event workstream.merged is available to rules.

Domain importer

Crawl a site and land it as drafts; see importer.md. Every route needs imports.manage (admin) and is scoped to the request's site.

MethodPathAuthPermission
POST/api/imports/estimatecookieimports.manage; body { url, options?: Partial<ImportJobOptions> } → ImportAiEstimate (stage before_crawl: the AI calls and tokens the job's classification would spend, as a range from the page cap and the sitemap, and the monthly token cap left; reads robots.txt and the sitemap, stores nothing; 30 a minute per user, then 429)
POST/api/importscookieimports.manage; body { url, options?: Partial<ImportJobOptions> } → ImportJob (201, status queued; the ticker starts the crawl within a tick; options.useAi: false keeps the type proposals heuristic)
GET/api/imports?page=&limit=cookieimports.manage → Paginated<ImportJob>
GET/api/imports/:idcookieimports.manage → ImportJob (stats, detected, status)
POST/api/imports/:id/cancelcookieimports.manage → ImportJob (409 when the job is finished)
DELETE/api/imports/:idcookieimports.manage → 204 (rows and fetched files; landed entities stay)
POST/api/imports/:id/recrawlcookieimports.manage; body { pageIds? } → ImportJob (the listed pages are fetched again; without ids the whole crawl restarts)
GET/api/imports/:id/eventscookieimports.manage → { items: string[] } (the last 200 log lines)
GET/api/imports/:id/estimatecookieimports.manage → ImportAiEstimate (stage after_crawl: the crawled pages clustered as classify will and the request it would send, measured; while the job is queued or crawling, the before_crawl range)
GET/api/imports/:id/pages?status=&cluster=&q=&page=&limit=cookieimports.manage → Paginated<ImportPageSummary> (status ok, redirect or error; cluster an id or none; q on url and title; without mainHtml, text and blocks)
GET/api/imports/:id/pages/:pidcookieimports.manage → ImportPage
GET/api/imports/:id/pages/:pid/rawcookieimports.manage → the fetched HTML, Content-Security-Policy: sandbox; script-src 'none'
GET/api/imports/:id/assetscookieimports.manage → { items: ImportAsset[] }
GET/api/imports/:id/redirectscookieimports.manage → { items: ImportRedirect[] } (origin crawl or source)
GET/api/imports/:id/menuscookieimports.manage → { items: ImportMenu[] }
POST/api/imports/:id/classifycookieimports.manage → { job, clusters: ImportCluster[], notes: string[], usedAi } (clusters by URL pattern and skeleton, a TypeProposal per cluster, AI-refined when the import task has a provider and the job's useAi is not false: one provider call, two with the retry; status classified)
GET/api/imports/:id/clusterscookieimports.manage → ImportCluster[]
PUT/api/imports/:id/clusters/:cidcookieimports.manage; body { decision?: 'pending' | 'approved' | 'merged' | 'skipped', label?, targetTypeKey?, mergedIntoId?, proposal? } → ImportCluster (400 for a proposal with unknown field types or sources, a missing title field or an unknown type)
POST/api/imports/:id/convertcookieimports.manage; body { clusterIds? } → { job, converted, skipped, averageScore, notes } (fields and blocks for the pages of approved clusters, quality per page; status planned)
GET/api/imports/:id/pages/:pid/comparecookieimports.manage → { original: { html, text, images, links }, converted: { blocks, html, text }, quality } (converted.html is server-rendered with the block library; blocks and quality are null before conversion)
POST/api/imports/:id/landcookieimports.manage; body { pageIds?, clusterIds?, publish? } → ImportReport & { summary } (types, drafts in the job's workstream at the original paths, media, 301s, menus, shared blocks; publish only applies outside a workstream for callers with content.publish; status landed; a rerun updates rather than duplicates)
GET/api/imports/:id/reportcookieimports.manage → ImportReport
curl -b jar -H "$H" -H 'Content-Type: application/json' -d '{"url":"https://www.example.edu","options":{"maxPages":500,"exclude":["/calendar/**"]}}' $BASE/api/imports
curl -b jar "$BASE/api/imports/$JOB/pages?status=error"
curl -b jar -H "$H" -H 'Content-Type: application/json' -d '{"url":"https://www.example.edu","options":{"maxPages":500}}' $BASE/api/imports/estimate

ImportAiEstimate is { stage, ai: { enabled, configured, provider, model }, useAi, willUseAi, pages: { cap, sitemap, expected: { min, max } }, clusters, calls, tokens, cap: { monthlyTokenCap, usedThisMonth, remaining, share }, exceedsCap, summary, notes }; clusters, calls and tokens are { min, max } per classification run, share is tokens over the monthly cap (null without a cap) and summary is the sentence the wizard shows. The figures are calls and tokens only: the AI layer does not know prices. See importer.md, AI usage.

Options (ImportJobOptions): maxPages (200, at most 5000), rateLimitMs (500), respectRobots (true), include/exclude (globs on the path, or re: regexes), followSubdomains (false), useSitemap (true), adapters (['drupal','wordpress'], auto-detected), locale, workstream (true). Audit actions: import.create, import.cancel, import.delete, import.recrawl, import.classify, import.cluster.update, import.convert, import.land (landing also writes the usual type.*, media.upload, redirect.create, menu.*, shared_block.* and workstream.* rows). A job's status moves queued → crawling → crawled → classifying → classified → converting → planned → landing → landed; a step called out of order answers 409 import_status. The theme routes of the same job are listed below.

Theme tokens

MethodPathNotes
POST/api/theme/import{ format: 'auto' | 'w3c' | 'figma' | 'css' | 'tailwind-json', content } → { tokens, mapped, unmapped, warnings, format } (site.settings). Nothing is saved; send tokens as theme in PUT /api/brand/draft (or PUT /api/site to skip the draft).
GET/api/theme/exportThe current theme as a DTCG JSON document (any user).
GET/api/theme/tokens.cssPublic text/css with the published :root block, the dark mode and high contrast media blocks and the rules of every published block style, cached under the theme tag (Cache-Tag: theme).

PUT /api/site accepts theme.custom ({ "<kebab-name>": "<css value>" }, up to 200 entries) alongside the named tokens; see theming.md.

Brand

The Brand area edits a per-site brand draft (theme tokens, identity, block styles, the style guide setting, the brand profile and, on a site others inherit from, the locked parts) that visitors and the AI tasks never see until it is published. GET/PUT /api/brand (the published profile) are listed under AI. Every route needs a cookie session or a bearer token; writes are audited (brand.draft_save, brand.draft_reset, brand.publish, brand.draft_discard, brand.restore, and brand.import_read and brand.import for brand imports).

MethodPathPermissionNotes
GET/api/brand/draftsite.settingsBrandDraft ({ theme, identity, styles, guide, profile, locks, overrides, updatedAt, updatedBy }) or null
PUT/api/brand/draftsite.settings{ theme?, identity?, styles?, guide?, profile?, locks? } → BrandDraft
DELETE/api/brand/draftsite.settings204; discards the draft
POST/api/brand/draft/publishsite.settings→ { revision, site }; 404 without a draft
GET/api/brand/revisionssite.settingsthe last 20 published BrandRevisions, newest first
POST/api/brand/revisions/:id/restoresite.settings→ BrandDraft; the version becomes the draft

PUT /api/brand/draft starts from the published theme and identity the first time and merges after that: theme tokens one by one (an empty string clears an optional token; modes and custom replace as a whole), identity keys one by one (null clears one; an id must be a media item of the site), guide ({ public: boolean }, the style guide setting) replaces the stored one, profile merges field by field into the drafted voice, and locks (part names, see Shared brands) replaces the locked parts. Validation is strict and answers 400 validation_error: every color must be a CSS color (hex, rgb(), hsl(), a color name, or a color function such as oklch()), fontSizeBase a px, rem or em length from 10 to 32 px, spaceUnit one from 2 to 32 px, typeScaleRatio a number from 1 to 2, modes.dark.enabled off or auto, and nothing may contain <, >, {, } or ;. Unknown keys are refused.

POST /api/brand/draft/publish copies the draft onto the site (theme, identity, blockStyles, brandGuide, and brandLocks or, on a child site, brandOverrides) and its brand profile, records a BrandRevision (keeping 20 per site, styles, the style guide setting, the voice and the locks included), recomputes every site that inherits this brand, deletes the draft and invalidates the theme and brand-guide tags, the site:<id> tag when the theme or the identity changed, and style:<block>:<key> for every block style whose look changed (when a block's default moves, both the old and the new default), which purges the CDN through the hook when one is connected. Restoring never touches the live site; publish afterward.

Preview. Add ?__brand=draft to any public page, /search or /_preview/... URL. A signed-in user with site.settings gets the page rendered with the draft (X-Spillway-Cache: BYPASS, never stored); everyone else gets the published page. Either way the response carries Cache-Control: no-store.

curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT \
  -d '{"theme":{"colorPrimary":"#0f766e","modes":{"dark":{"enabled":"auto","colors":{"colorBackground":"#0b1120"}}}}}' $BASE/api/brand/draft
curl -sS -b jar "$BASE/?__brand=draft" | grep -o -- '--sw-color-primary:[^;]*'
curl -sS -b jar -H "$H" -X POST $BASE/api/brand/draft/publish

Block styles

The named styles editors pick in the page builder's Style field (theming.md, blocks.md). The list always holds every built-in style (builtIn: true, modified when the site changed it) and the styles the site added, with one isDefault per block. A style is { block, key, label, description?, tokens, roles?, isDefault? }: block is a core block (Hero, Cards, Button, CallToAction, Heading, Text) or a module block's own set (<module>/<blockKey>), tokens maps each of the block's slots to one of its values, roles limits who may pick it (empty: everyone).

MethodPathPermissionNotes
GET/api/brand/stylesany signed-in user{ styles, slots }: the published list and every styled block's slots; ?draft=1 (site.settings) answers the draft's list
PUT/api/brand/draftsite.settings{ styles } replaces the draft's list as a whole; a built-in style left out is reset, not deleted
PUT/api/brand/draft/stylessite.settingsone style → BrandDraft: adds it or replaces the one with the same block and key
DELETE/api/brand/draft/styles?block=&key=site.settings→ BrandDraft: deletes a style the site added, resets a built-in one; 404 for an unknown style

Every style is validated against its block's slots and answers 400 validation_error naming it: an unknown block or slot, a value outside the slot's list (never a raw color), a key that is not kebab-case (^[a-z][a-z0-9-]{0,39}$) or is used twice in a block, two defaults in one block, a role that does not exist, more than 24 styles in a block or 300 in all. A style of a module block whose module is not loaded is kept only unchanged. Writes are audited as brand.draft_save.

On pages, a styled block keeps style: "<key>" in its props. The server renders the stored style when the site has it, else the block's default, adds sw-style-<block>-<key> to the block's class, inlines the style's rules and tags the page style:<block>:<key>. GET /api/builder/config also answers styles (the published list, each with available for the person's site roles; people with site.settings may use every style) and styleSlots. POST and PUT /api/entities refuse (403) a layout that gives a block a style limited to roles the person lacks, unless that block already carried it.

curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT \
  -d '{"block":"Hero","key":"campaign","label":"Campaign","tokens":{"background":"accent","text":"light","layout":"stacked"},"roles":["manager"]}' $BASE/api/brand/draft/styles
curl -sS -b jar -H "$H" -X POST $BASE/api/brand/draft/publish

Style guide

The brand rendered as a page (theming.md, Style guide): identity, palette, contrast grid, type scale, space and shape, voice, and every block in every style, built from the tokens on each request.

MethodPathPermissionNotes
GET/api/brand/guideany signed-in userBrandGuide for the published brand; ?draft=1 (site.settings) answers the draft's, or the published one with brand: "published" when there is no draft
GET/brandpublicthe published guide in the site's header and footer, only while the published brand's brandGuide.public is on; otherwise /brand is routed like any path

BrandGuide is { brand, html, sections, styles, logos, public, updatedAt }. html is a standalone HTML document with no scripts: the theme tokens, the block stylesheet, the guide's rules (with @media print rules for the brand book) and the rules of every style it shows are inlined, and logos are linked with absolute URLs, so it works in an iframe, a print window or a saved file. For people with site.settings every style carries a Duplicate style button (data-sw-duplicate-block, data-sw-duplicate-key) for the page that frames it to handle. sections lists the sections in order (identity, color, contrast, type, space, voice, styles), styles is the full style list shown, logos the identity files ({ key, label, mediaId, url, filename, mime }) and public the brand's style guide setting. Nothing is cached or written; the response carries Cache-Control: no-store.

GET /brand never shows the draft (with ?__brand=draft it answers the published guide with no-store), carries <meta name="robots" content="noindex, nofollow"> and X-Robots-Tag: noindex, and is cached under its path with the tags theme, brand-guide, site:<id>, style:<block>:<key> per style shown and media:<id> per logo. A brand publish purges theme and brand-guide; PUT /api/brand (the voice) purges brand-guide.

curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT -d '{"guide":{"public":true}}' $BASE/api/brand/draft
curl -sS -b jar -H "$H" -X POST $BASE/api/brand/draft/publish
curl -sS -I $BASE/brand | grep -i -E 'x-robots-tag|cache-tag'
curl -sS -b jar "$BASE/api/brand/guide?draft=1" | jq -r .html > brand-guide.html

Brand import

Import brand in the admin (theming.md). An import reads a source into a stored BrandImportResult for review; applying writes the checked rows into the brand draft. Nothing is published, and neither the live theme nor the published brand profile is ever written. Audited as brand.import_read (read) and brand.import (apply); an AI call is logged under the import task and counts toward the monthly cap.

MethodPathPermissionNotes
POST/api/brand/importssite.settings{ source: "tokens", format?, content }, { source: "website", url } or { source: "kit", content } (a brand kit's kit.json text) → 201 BrandImportResult; 422 fetch_failed when the page cannot be read (an SSRF guard refusal included); 400 for a kit that is not a valid brand kit
POST/api/brand/imports/pdfsite.settingsmultipart: file (a PDF, at most 20 MB) and up to four logos (SVG or PNG, 5 MB each) → 201 BrandImportResult; 413 over a limit, 400 for a file that is not a PDF
GET/api/brand/imports/:idsite.settingsthe stored BrandImportResult; 404 once applied or after 24 hours
POST/api/brand/imports/:id/applysite.settings{ rows: string[] } → { draft, profile, applied, skipped }

A result is { id, source, label, theme, identity?, profile?, mapped, unmapped, warnings, ai, createdAt, expiresAt }. mapped is the review: one row per value, { id, group, label, source, value, current }, where group is color, type, space, dark, contrast, custom, identity, styles, voice or guide, current is the draft's (or the published) value and id says what applying it writes: theme.<token>, theme.modes.dark.enabled, theme.modes.dark.<colorToken>, theme.modes.highContrast.<colorToken>, theme.custom.<name>, identity.<key> (a logo, filed in the media library on apply), styles.<block>.<key>, guide.public or profile.<field>. A brand kit's result also carries styles and guide. unmapped lists { source, value, reason } for what was left out; ai is used, off, failed or none.

The website source fetches the page through the SSRF guard with the importer's user agent and limits and runs the importer's theme extractor on it; with AI on (and ai.use), one call drafts the voice from the page copy. The PDF source extracts the text itself (no external service); with AI, one call returns the palette, typefaces, logo rules and voice as JSON that is validated field by field, the PDF text passed as delimited data the model is told never to follow; without AI, colors and typefaces are found by pattern. Logo rules come back as warnings.

Apply checks every theme value again with the draft's validation and skips (with a reason in skipped) a row that no longer fits, an unknown row id, a block style its block's slots refuse, or a logo that cannot be fetched. Voice rows go to the draft's profile like every other row; profile echoes the voice fields written (null when none). On a site that inherits a brand, a checked row of a part its parent locks refuses the whole apply with 403 brand_locked before anything is filed, and the parts written become overridden. The import is deleted after apply.

ID=$(curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X POST \
  -d '{"source":"website","url":"https://www.example.org"}' $BASE/api/brand/imports | jq -r .id)
curl -sS -b jar "$BASE/api/brand/imports/$ID" | jq '.mapped[] | [.id, .value, .current]'
curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X POST \
  -d '{"rows":["theme.colorPrimary","theme.fontHeading","identity.logoMediaId"]}' $BASE/api/brand/imports/$ID/apply
curl -sS -b jar -H "$H" -F file=@brand-guide.pdf -F logos=@logo.svg $BASE/api/brand/imports/pdf

Shared brands and brand kits

A brand travels as a kit (kind: "brand", kits.md), and on a multisite instance a site can inherit another site's brand part by part (theming.md, Shared brands). The parts are identity, color, type, space, styles and voice.

MethodPathPermissionNotes
GET/api/brand/exportsite.settingsKitManifest with kind: "brand", types: [], the logos inline as media and brand (theme, identity as $media:<key>, styles, guide, profile) for the published (effective) brand; ?key=&label=&version= name it. Attachment <key>.kit.json. Nothing is written.
GET/api/brand/inheritancesite.settingsBrandInheritance: { parent, children, locked, inherited, overridden } (parent and children are { id, key, name })
POST/api/brand/draft/resetsite.settings{ part } → BrandDraft: on a child site, the part follows the parent again; 400 on a site that inherits nothing. Audited as brand.draft_reset

Installing a brand kit never touches the live brand: POST /api/kits/:key/install and POST /api/kits/install-from-registry answer a KitInstallReport whose brandImport is the BrandImportResult to review and apply (Brand import); POST /api/brand/imports { source: "kit" } reads one directly.

On a child site, a write that would change a part its parent locks answers 403 brand_locked with details: { parts, parent }: PUT /api/brand/draft, PUT and DELETE /api/brand/draft/styles, POST /api/brand/imports/:id/apply, PUT /api/brand and PUT /api/site { theme | identity }. A change to any other part marks it overridden (BrandDraft.overrides, published as SiteSettings.brandOverrides). A child's locks must be empty (400). Publishing a parent (or PUT /api/brand or PUT /api/site { theme | identity } on it) recomputes every child's published brand and profile and purges theme, brand-guide and site:<child> for each child that changed (audited as brand.inherit).

curl -sS -b jar "$BASE/api/brand/export?version=1.1.0" > brand.kit.json
curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT -d '{"brandParent":"default"}' $BASE/api/sites/engineering
curl -sS -b jar -H "$H" -H 'Content-Type: application/json' -X PUT -d '{"locks":["color","type"]}' $BASE/api/brand/draft
curl -sS -b jar -H "$H" -X POST $BASE/api/brand/draft/publish
curl -sS -b jar -H "$H" -H 'X-Spillway-Site: engineering' "$BASE/api/brand/inheritance"

Kits and structure

MethodPathNotes
GET/api/kitsKitSummary[]: bundled kits with counts, whether they are installed and their requires. Any user.
GET/api/kits/:key/planKitInstallPlan (types.manage): the kits in install order (install/skip, local/registry), the modules (install/skip/fail with tier), the core check, ok and problems. Nothing is installed. See kits.md.
POST/api/kits/:key/install{ withSampleContent?: boolean } → KitInstallReport (types.manage + site.settings). Resolves requires first and answers 400 kit_dependency (the plan in details) when it cannot be satisfied; otherwise installs the modules, the dependency kits (structure only) and the kit. Idempotent: existing items are skipped. A trusted-tier module dependency needs SPILLWAY_TRUSTED_MODULE_INSTALL=1 and users.manage.
POST/api/kits/plan-from-registry{ name, version? } → KitInstallPlan (users.manage) for a registry kit, dependencies resolved against the bundled kits and the registry.
POST/api/kits/install-from-registry{ name, version?, withSampleContent? } → KitInstallReport (users.manage). Downloads the kit or theme from REGISTRY_URL's signed index, verifies its sha256, resolves its requires and installs it like /api/kits/:key/install; 404 not_found for a name or version the index lacks, 400 sha256_mismatch for tampered bytes, 400 kit_dependency for an unsatisfiable plan. See registry.md.
GET/api/site/structureKitManifest of the site's types, vocabularies (with terms), collections, forms and menus (site.settings).
POST/api/site/structureApplies a KitManifest with update semantics → KitInstallReport (site.settings). Pages and sample content are ignored.

PUT /api/site also accepts forms: { captcha: { provider: 'none' | 'turnstile' | 'hcaptcha', siteKey?, secret? } } (Site settings → Forms, site.settings): the secret is write-only, GET and the PUT response show it as ********, and sending the mask back keeps the stored one. Saving evicts every page (the Form block renders the widget and the CSP allows the provider's hosts). PUT /api/site also accepts integrations: { gtmId?, ga4Id?, plausibleDomain?, customHeadHtml? } and embedAllowedHosts: string[] (host names the Embed block may frame; an empty list restores DEFAULT_EMBED_HOSTS). Text blocks in HTML mode and Embed blocks are sanitized on save and at render: scripts, event handlers and javascript: URLs are removed, and an Embed keeps <iframe> only for an https src on an allowed host, always with sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" and loading="lazy".

Bulk actions and clone

MethodPathNotes
POST/api/entities/bulk{ ids: string[] (≤ 200), action: 'publish' | 'unpublish' | 'archive' | 'delete' | 'submit' | 'setAuthor' | 'addTerm', value? } → { results: { id, ok, status?, error? }[], ok, failed }. Permissions are checked per item like the single-entity routes; value is a user id for setAuthor and { field?, termId } for addTerm. Publishing respects the pre-publish checks (no force).
POST/api/entities/:id/cloneA draft copy "Copy of <title>" with a fresh slug (201). content.create plus edit access to the source.

Enterprise sign-in (SSO)

Providers are managed with users.manage; the browser flow is public. See sso.md for IdP setup. protocol is oidc, saml or github (OAuth 2 with the GitHub user id as identity and the primary verified email; needs clientId and clientSecret, optional issuer for GitHub Enterprise; see registry.md).

# List, create, update, test and delete providers (the client secret is write-only; `hasClientSecret` reports it)
curl -b c -H 'X-Requested-With: spillway' $BASE/api/sso/providers
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/sso/providers \
  -d '{"slug":"okta","label":"Okta","protocol":"oidc","enabled":true,"issuer":"https://acme.okta.com","clientId":"...","clientSecret":"...","groupClaim":"groups","groupRoleMap":{"cms-editors":["editor"]},"defaultRoles":["viewer"]}'
curl -b c -H 'X-Requested-With: spillway' -X POST $BASE/api/sso/providers/<id>/test     # OIDC: discovery + JWKS; SAML: certificate
curl -b c -H 'X-Requested-With: spillway' -X PUT  $BASE/api/sso/providers/<id> -H 'content-type: application/json' -d '{"enabled":false}'
curl -b c -H 'X-Requested-With: spillway' -X DELETE $BASE/api/sso/providers/<id>

# Public
curl $BASE/api/sso/providers/public            # [{ slug, label, protocol }] for the login screen
curl -i "$BASE/sso/okta/start?return=/admin"   # 302 to the IdP (OIDC: PKCE + state + nonce cookie; SAML: AuthnRequest, HTTP-Redirect)
# GET /sso/<slug>/callback (OIDC) and POST /sso/<slug>/acs (SAML) complete the sign-in and set the session cookie;
# failures redirect to /admin/login?error=<code>. GET /sso/<slug>/metadata serves the SAML SP metadata.

SCIM 2.0 provisioning

An identity provider manages users and groups through SCIM 2.0 at /scim/v2 with a per-provider bearer token (scim_<prefix>_<secret>, not a user API token). Requests and responses are application/scim+json (JSON is accepted on input); errors use the SCIM shape { schemas: [...Error], status: "409", scimType: "uniqueness", detail }. See sso.md for the Okta and Entra setup and what each operation does.

# Admin (users.manage, browser session): the token is created or rotated here and shown once
curl -b c -H 'X-Requested-With: spillway' $BASE/api/sso/providers/<id>/scim                 # { baseUrl, token, lastSyncAt, events[≤20] }
curl -b c -H 'X-Requested-With: spillway' -X POST $BASE/api/sso/providers/<id>/scim-token   # 201 { token, secret } (rotates)
curl -b c -H 'X-Requested-With: spillway' -X DELETE $BASE/api/sso/providers/<id>/scim-token # 204 (revokes)

# The IdP's side (T = the SCIM token)
curl $BASE/scim/v2/ServiceProviderConfig                      # also /ResourceTypes and /Schemas; no token needed
curl -H "Authorization: Bearer $T" "$BASE/scim/v2/Users?filter=userName%20eq%20%22nina%40example.edu%22&startIndex=1&count=100"
curl -H "Authorization: Bearer $T" -H 'content-type: application/scim+json' -X POST $BASE/scim/v2/Users \
  -d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"nina@example.edu","name":{"givenName":"Nina","familyName":"New"},"externalId":"00u1","active":true}'
curl -H "Authorization: Bearer $T" $BASE/scim/v2/Users/<id>
curl -H "Authorization: Bearer $T" -H 'content-type: application/scim+json' -X PUT   $BASE/scim/v2/Users/<id> -d '{...}'    # replace
curl -H "Authorization: Bearer $T" -H 'content-type: application/scim+json' -X PATCH $BASE/scim/v2/Users/<id> \
  -d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"active","value":false}]}'
curl -H "Authorization: Bearer $T" -X DELETE $BASE/scim/v2/Users/<id>              # 204: deactivates, never deletes
curl -H "Authorization: Bearer $T" -H 'content-type: application/scim+json' -X POST $BASE/scim/v2/Groups \
  -d '{"displayName":"cms-editors","members":[{"value":"<user id>"}]}'              # the name is matched against the provider's group → roles map
curl -H "Authorization: Bearer $T" -H 'content-type: application/scim+json' -X PATCH $BASE/scim/v2/Groups/<id> \
  -d '{"Operations":[{"op":"remove","path":"members[value eq \"<user id>\"]"}]}'
MethodPathNotes
GET/scim/v2/Usersfilter (userName eq, emails.value eq, emails[type eq "work"].value eq, externalId eq, id eq; other operators are 400 invalidFilter), startIndex (1-based), count (≤ 500; 0 for the total only), attributes, excludedAttributes. Sees the provider's own accounts and local password accounts; another provider's are invisible.
POST/scim/v2/Users201. userName must be an email address; 409 uniqueness when it exists. New accounts get the provider's default roles.
GET, PUT, PATCH/scim/v2/Users/:idPATCH takes the PatchOp grammar on active, userName, externalId, name(.givenName/.familyName/.formatted), displayName, emails, groups; unknown core attributes are 400 invalidPath, unstored ones and the enterprise extension are ignored. active: false ends sessions, revokes API tokens and blocks sign-in.
DELETE/scim/v2/Users/:id204; deactivates.
GET, POST/scim/v2/GroupsFilter displayName eq, externalId eq, id eq, members.value eq. 409 on a duplicate displayName within the provider.
GET, PUT, PATCH, DELETE/scim/v2/Groups/:idPATCH members add / remove (members[value eq "id"] or a value list) / replace, displayName, externalId. Membership changes recompute the members' roles from the group → roles map (default roles when no mapped group is left).
GET/scim/v2/ServiceProviderConfig, /ResourceTypes, /SchemasStatic, unauthenticated.

Every mutation writes an audit row with the actor scim:<provider slug> (scim.user.create|update|activate|deactivate|roles, scim.group.create|update|members|delete). Limits: 1 MB bodies (413), 600 requests per minute per token (429 with Retry-After), 415 for a body that is neither application/scim+json nor application/json.

Page builder: module blocks

The blocks installed modules offer to the page builder (modules.md), and the preview the builder's canvas shows.

curl -b c -H 'X-Requested-With: spillway' $BASE/api/builder/config         # { blocks: BuilderModuleBlock[], safeMode, styles, styleSlots }; any signed-in user, never cached
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/render/block \
  -d '{"module":"hello-banner","key":"notice","props":{"text":"Open day"}}'   # { html, placeholder, label, reason? }; content.create, content.edit_own or content.edit_any; add "style" for a styled block

The page builder lists each block as its own draggable item and converts back before saving, so a page always stores a module block as { "type": "ModuleBlock", "props": { "module", "blockKey", "props", "style"? } } (style for a block that declares block styles); the public render, /_preview and the headless render API carry the resolved html. A module that is not loaded answers a placeholder (placeholder: true with the block's label), never an error.

Registry

Only when the instance runs with REGISTRY_HOST=1 (otherwise /registry/v1/* is 404). See registry.md for the publishing rules, the checks, the review policy and the GitHub sign-in setup.

# Public, no auth, CORS *, cached 60 s
curl $BASE/registry/v1/index.json                                   # the signed index (REGISTRY_URL for other installs)
curl "$BASE/registry/v1/packages?q=guard&kind=module&sort=downloads" # Paginated<RegistryPackage>
curl $BASE/registry/v1/packages/profanity-guard                     # { package, versions, advisories, publisher }
curl -O $BASE/registry/v1/packages/profanity-guard/0.1.0/download   # the tarball (X-SHA256 header; counted once per IP and day)
curl $BASE/registry/v1/packages/profanity-guard/0.1.0/readme        # markdown
curl $BASE/registry/v1/packages/ocean-theme/1.0.0/files/screenshot.png   # a file out of the tarball: declared screenshots, README.md, CHANGELOG.md, manifest.json, kit.json (immutable)
curl $BASE/registry/ocean-theme/preview                             # a listed theme's preview site (HTML, noindex, cached 10 min); /preview/<page> for a kit page, ?version= to pin one
curl $BASE/registry/v1/packages/profanity-guard/manifest.json       # agent-readable: install command, capabilities, config schema, docs
curl "$BASE/registry/v1/advisories?package=profanity-guard"
curl $BASE/registry/v1/publishers/ada                               # { publisher, packages }
curl $BASE/registry/v1/orgs/acme                                    # { org, members, packages }: an organization and the packages of its namespaces
curl $BASE/registry/v1/stats

# Publishing: an API token with registry.publish (every role has it); 201 listed, 202 pending review, 422 checks_failed
curl -H "Authorization: Bearer spw_..." -F tarball=@profanity-guard-0.1.0.tgz $BASE/registry/v1/publish
# the same from a browser session: POST /api/registry/publish with the CSRF header

# Publisher profile and namespaces
curl -b c -H 'X-Requested-With: spillway' $BASE/api/registry/status            # { enabled, fourEyes } on every instance, registry host or not
curl -b c -H 'X-Requested-With: spillway' $BASE/api/registry/publishers/me
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/publishers/me -d '{"handle":"ada","displayName":"Ada"}'
curl -b c -H 'X-Requested-With: spillway' $BASE/api/registry/namespaces/mine

# Organizations (registry.publish; see registry.md#organizations): a shared handle that owns namespaces, with owner, admin and member roles
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/orgs -d '{"handle":"acme","displayName":"Acme"}'   # 201; you become the owner
curl -b c -H 'X-Requested-With: spillway' $BASE/api/registry/orgs/mine                                   # RegistryOrgMembership[] (with your role)
curl -b c -H 'X-Requested-With: spillway' $BASE/api/registry/orgs/<id>                                   # { org, members, namespaces } (members, reviewers)
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X PUT $BASE/api/registry/orgs/<id> -d '{"displayName":"Acme Corp"}'    # owner or admin
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/orgs/<id>/members -d '{"handle":"bob","role":"admin"}'   # owner
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X PUT $BASE/api/registry/orgs/<id>/members/<publisherId> -d '{"role":"owner"}'
curl -b c -H 'X-Requested-With: spillway' -X DELETE $BASE/api/registry/orgs/<id>/members/<publisherId>   # 204; the last owner answers 409 last_owner
curl -b c -H 'X-Requested-With: spillway' -X POST $BASE/api/registry/orgs/<id>/leave                     # 204; nothing you published changes hands
curl -b c -H 'X-Requested-With: spillway' -X POST $BASE/api/registry/orgs/<id>/namespaces/acme/transfer  # your namespace into your organization (reviewers: any)
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X PUT $BASE/api/registry/orgs/<id>/namespaces/acme/maintainers -d '{"maintainers":["<publisherId>"]}'

# Review and moderation (users.manage; publishers may deprecate and yank their own packages)
curl -b c -H 'X-Requested-With: spillway' $BASE/api/registry/review
curl -b c -H 'X-Requested-With: spillway' "$BASE/api/registry/publishers?q=ada"   # Paginated<Publisher>; /api/registry/namespaces → Paginated<Namespace>
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/review/hello-trusted/1.0.0/approve -d '{"note":"read it"}'
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/advisories -d '{"package":"profanity-guard","versions":"<0.2.0","severity":"high","title":"Bypass","url":"https://..."}'
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/packages/profanity-guard/deprecate -d '{"note":"use @acme/guard"}'
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/packages/profanity-guard/versions/0.2.0/yank -d '{"reason":"wrong word list"}'
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/namespaces/acme/transfer -d '{"ownerPublisherId":"...","maintainers":["..."]}'
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/publishers/<id>/verify -d '{"verified":true}'
curl -b c -H 'X-Requested-With: spillway' "$BASE/api/registry/orgs?q=acme"                             # Paginated<RegistryOrg> (users.manage)
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/registry/orgs/<id>/verify -d '{"verified":true}'

Importer

The domain importer (imports.manage, admins; see importer.md). Jobs belong to the request's site. The crawl, classification, conversion and landing routes are listed in the Importer block of packages/shared/src/index.ts; the theme routes:

MethodPathNotes
POST/api/imports/:id/theme/extractReads the stylesheets the crawled pages link to (SSRF-guarded, 10 sheets of 2 MB from the site's hosts) and answers ImportThemeResult: tokens, custom, fonts, logo, favicon, palette, sources, confidence (token → 0..1), spacing, radii, warnings. Stored on the job; running it again replaces the earlier extract. 409 while the job is still queued.
GET/api/imports/:id/themeThe stored extract, with applied, appliedAt and logoMediaId; 404 until extracted.
POST/api/imports/:id/theme/apply{ tokens?, custom?, logoAsMedia? } merges the given (or the extracted) tokens into the brand draft, never the live theme (audit brand.draft_save + import.theme_apply); logoAsMedia fetches the logo into the media library and sets it as the draft's logo. Publish the draft under Brand.

Personalization and analytics

MethodPathNotes
GET / POST/api/audiencesAudience[]; AudienceInput → Audience (201). site.settings.
GET / PUT / DELETE/api/audiences/:idThe id may be the key. PUT takes Partial<AudienceInput>.
GET / POST/api/experimentsExperiment[]; ExperimentInput (variants a, b, optional c with weights; goal form | path | click) → Experiment (201, draft). site.settings.
GET / PUT / DELETE/api/experiments/:idThe id may be the key.
POST/api/experiments/:id/start | /pause | /endStatus transitions (409 for an impossible one).
GET/api/experiments/:id/resultsExperimentResult: exposures, conversions and rate per variant, winner and confidence from a two-proportion z-test (95%).
GET/api/analytics/overview?from=&to=AnalyticsOverview (site.settings or content.publish); YYYY-MM-DD, default the last 30 days.
GET/api/analytics/pages/:path?from=&to=AnalyticsPage for one path.
GET/_island/:entityId/:blockId?u=&r=Public { html, exposure?, goal? } for a Personalize or Experiment block; no-store, Vary: Cookie; sets sw_x (assignment) and sw_v (returning visitor, consent permitting).
POST/_beaconPublic, no CSRF: { p, r, u, x, w } (page view) or { e: 'click', x } (click goal). Always 204; 429 over 60/min/IP; 413 over 4 KB. Bots are not counted.

PUT /api/site also accepts privacy: { consentMode: 'none' | 'banner' }. See personalization.md.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?