Internal Content API

User-delegated AI access

CMS users can create expiring personal API tokens from their Profile. A personal token authenticates as its owning user and is authorized by the intersection of the token capabilities, selected sites, the owner’s current role, current site assignments, and the normal content workflow. It becomes unusable immediately when the owner is inactive or no longer has CMS access.

Personal tokens are intended for content, navigation, Shared Slot, media, engagement, and draft workflows. Install-level operations remain system-token only. Personal write requests that create or select content must provide the target site rather than relying on the installation’s primary-site fallback. API activity continues to record the token, while page and revision audit actors resolve to the delegating user.

Each personal token may also carry an optional allowlist of exact IPv4/IPv6 addresses or CIDR networks and its own 30, 60, 120, or 300 request-per-minute ceiling. An empty network list permits any client address, preserving existing-token behavior; tokens without a stored ceiling use 60 requests per minute. These live constraints are included in authenticated discovery under token.network_policy, and a token-specific limit may therefore be lower than the installation-wide API throttle.

Purpose

The Internal Content API is a secure CMS API for trusted AI and operator tools. It lets those tools inspect CMS content contracts, create draft-first content, replace specific page-owned slots on existing draft pages, run explicit publish operations through structured JSON, and request allowlisted admin render snapshots for visual QA without logging into, scraping, or automating the browser admin UI.

Phase 1 is implemented as a token-protected, JSON-only, non-public API for read-only content discovery plus draft page creation through validated content plans. Phase 2A adds safe foundations for navigation menus, Shared Slots, and explicit page slot Shared Slot assignment. Phase 2B adds controlled draft-only replacement for page-owned slot content on existing pages. Publish endpoints are explicit and require content.publish; content apply remains draft-first and does not publish. The API remains intentionally narrow: no remote fetch, no broad page delete through content apply, no replacement of Shared Slot-backed slots, and no Shared Slot cascade publishing.

Product Positioning

The Internal Content API is:

  • an internal/operator CMS API
  • token-protected
  • not public
  • not a headless CMS delivery API
  • not a replacement for admin permissions
  • not an import/export replacement
  • not an AI vendor integration

CMS core should own this API because it operates on core content concepts: sites, pages, layouts, slots, blocks, translations, navigation, and shared slots. AI or operator tools may call the API, but CMS core should not embed OpenAI, LLM, crawler, or vendor-specific integration logic.

Route Prefix

The canonical prefix is:

/webadmin/api

This keeps the API inside the CMS admin boundary while using a concise, familiar API segment. Resource-style endpoints should live directly under this prefix, such as /webadmin/api/pages and /webadmin/api/blocks.

Before planning content, read the packaged CMS Inventory with GET /webadmin/api/inventory. It is the per-block design and authoring contract and is the first document an AI should read. The html block is human-only: it stays readable through the API, but every API mutation touching it is rejected with 422 and the stable code block_type_not_api_writable, and no capability overrides that. For an application block, first discover a ready definition and its settings schema through GET /webadmin/api/applications; API writes may reference registered applications but cannot introduce executable code or file paths.

API discovery starts at:

GET /webadmin/api

Unauthenticated callers receive only public-safe bootstrap JSON. Authenticated callers receive safe product version metadata plus links to OpenAPI, the AI guide, content contract, examples, content validate/apply, pages, blocks, media, navigation, and Shared Slots. External AI/operator tools should start from this live discovery response instead of reading the CMS repository or local package docs.

Plan-based content operations use:

POST /webadmin/api/content/validate
POST /webadmin/api/content/apply

Route choices to avoid:

  • /webadmin/internal-api, because it is unnecessarily verbose
  • /webadmin/api/content-plans/..., because content-plans is too technical and narrow for the URL contract
  • placing every resource under /webadmin/api/content/..., because resource APIs should stay clear and direct
  • /admin, because CMS must not assume the host product's /admin path is CMS-owned
  • /cms, because /cms remains reserved for static CMS assets only

Authentication

The API uses Bearer token authentication:

Authorization: Bearer <token>

The CMS has two token surfaces. Editors, Site admins, and Super admins create personal API tokens from Profile -> Personal API Tokens; those tokens act as their owner within live role, site, workflow, network, and capability limits. Super admins create System API tokens from System -> API Tokens for explicitly authorized installation-level automation. The CMS stores only a SHA-256 hash plus a safe preview in the cms_api_tokens database table. The plain token is shown once immediately after creation and is never shown again.

The one-time token panel provides copy controls for both the full token and the generated local .env example. Copy feedback is announced accessibly, and the browser implementation includes a fallback for environments without the modern Clipboard API.

Tokens created with the older default page-building capability set are treated as eligible for the read-only admin.render capability at runtime, so trusted operator tools can use allowlisted admin visual QA snapshots after updating without rotating the token. Narrow custom tokens that do not include the former default set remain restricted.

Both token screens include a history action for each token. It opens a WebBlocks UI modal with the latest 10 API activity records for that token. Activity records are intentionally small: request time, status, method, path without query string, route name, required capability when a capability guard was evaluated, IP, and a short user-agent value. The CMS does not store request bodies, query strings, response bodies, bearer token values, token hashes, or token previews in activity rows. Older activity rows are pruned automatically so each token keeps only the latest 10 records.

Users can edit, revoke, and delete their own personal tokens; Super admins manage System tokens. Editing changes policy metadata without revealing the secret. Revocation immediately disables API access while retaining the audit row. Deleting a token permanently removes it and its activity history.

Local AI and operator tools should store the generated token in a trusted operator secret store.

Use the Internal Content API base URL in local operator configuration:

WEBBLOCKS_CMS_API_URL=https://example.com/webadmin/api
WEBBLOCKS_CMS_API_TOKEN=...

The CMS runtime does not require WEBBLOCKS_CMS_INTERNAL_API_TOKEN.

Authentication rules:

  • missing, wrong, or revoked tokens return JSON 401
  • revoked tokens stop working immediately
  • tokens must never be printed in logs, diagnostics, support reports, tests, or documentation examples
  • token comparison must use a constant-time comparison
  • successful API requests update the token's last_used_at and last_used_ip
  • successful API requests also store a truncated user-agent for operator audit context
  • normal resource responses and all errors are JSON; allowlisted visual QA routes may return direct HTML only when the caller explicitly requests an HTML format

Example request:

GET /webadmin/api/sites
Authorization: Bearer <token>
Accept: application/json
Content-Type: application/json

Capabilities

Token owners choose capabilities when creating or editing a personal token; Super admins do the same for System tokens. Editing does not expose or rotate the secret. A personal token form shows only capabilities the owner's current role can delegate, and every request re-checks that live role. Discovery exposes the effective capabilities without returning the token value, token hash, or token preview. Standard page-building tokens use these capabilities:

  • content.read
  • content.validate
  • content.apply
  • navigation.write
  • shared-slots.write
  • media.read
  • site-settings.write

Advanced capabilities are separate options and are not selected by default:

  • navigation.delete
  • shared-slots.delete
  • domains.write
  • domains.delete
  • site-assets.read
  • site-assets.write
  • engagement.read
  • engagement.moderate
  • plugins.read
  • plugins.install
  • plugins.manage
  • plugins.setup
  • plugins.uninstall
  • commerce.read
  • commerce.products.write
  • commerce.orders.read
  • media.write
  • media.upload
  • media.replace
  • media.move
  • media.delete
  • content.publish
  • pages.delete
  • content.blocks.delete
  • backups.create
  • page-assets.write

Write endpoints check the relevant capability server-side. Missing capabilities return JSON 403 with api_discovery_url, openapi_url, documentation_url, and example_url guidance. Normal page-building tokens should not include destructive capabilities such as navigation delete, Shared Slot delete, content publish, plugin install/manage/setup/uninstall, or page delete. Reading Comments/Rating feedback requires explicit engagement.read; changing comment status requires explicit engagement.moderate. Plugin lifecycle automation requires explicit plugin capabilities, and WebBlocks Commerce product/order API access requires explicit commerce capabilities.

Rate Limit

Every /webadmin/api route and the legacy /admin-api routes share two request budgets. The per-client-IP-and-token budget defaults to 120 requests per minute (CMS_INTERNAL_API_RATE_LIMIT_PER_MINUTE). An independent IP backstop defaults to 300 requests per minute across all supplied tokens and API paths (CMS_INTERNAL_API_IP_RATE_LIMIT_PER_MINUTE), including invalid-token attempts. Rotating tokens does not reset this backstop. Exceeding either budget returns 429 with a Retry-After header. Nonpositive configuration values fall back to the defaults.

Read live budgets from x-rate-limit on GET /webadmin/api/openapi.json: requests_per_minute is the token-and-IP budget, and ip_requests_per_minute is the independent IP budget. Clients sharing an IP must coordinate their combined request volume.

An active Super admin can override both budgets under System → Settings → Security. Database-backed panel values take precedence over installation configuration and apply to subsequent requests without resetting existing counters. Use installation defaults removes the security overrides and restores the configured budgets. Saving or restoring does not edit .env or server settings. Request budgets accept positive integers up to 1,000,000. Personal token-specific limits remain additional limits; changing them does not change these shared API budgets.

Hosting, proxy, or CDN layers in front of the site may enforce a lower limit than the CMS does, so a bulk client such as a full-site translation pass should pace itself and back off on 429 instead of retrying immediately.

Personal tokens additionally carry a configurable ceiling of 30, 60, 120, or 300 requests per minute; existing personal tokens without a saved value use 60. They may also restrict access to exact IPv4/IPv6 addresses or CIDR networks. Authenticated discovery reports these settings under token.network_policy. Laravel's resolved client IP is authoritative, so hosts behind a reverse proxy or CDN must trust only their real proxy addresses and forwarded headers before relying on the allowlist.

API Model

The API has two complementary modes.

Resource API

Resource endpoints mirror individual admin-equivalent operations:

  • list and read pages
  • list and read blocks
  • list sites, locales, layouts, and block types
  • later create or update draft page resources directly
  • later list or ensure page slots
  • later add, update, move, and delete blocks through resource endpoints
  • later add child blocks through resource endpoints
  • later manage navigation and shared slots

Phase 1 resource endpoints:

GET /webadmin/api/sites
GET /webadmin/api/locales
GET /webadmin/api/locale-options
POST /webadmin/api/locales
PATCH /webadmin/api/locales/{locale}
GET /webadmin/api/page-layouts
GET /webadmin/api/block-types
GET /webadmin/api/content-contract
GET /webadmin/api/pages
GET /webadmin/api/pages/{page}
GET /webadmin/api/pages/{page}/versions
GET /webadmin/api/pages/{page}/versions/{version}
POST /webadmin/api/pages/{page}/versions/{version}/candidate
POST /webadmin/api/pages/{page}/version-candidates/{candidate}/apply
DELETE /webadmin/api/pages/{page}/version-candidates/{candidate}
POST /webadmin/api/pages/{page}/publish
POST /webadmin/api/pages/{page}/publish-page-owned-blocks
POST /webadmin/api/pages/{page}/slots/{slot}/shared-slot
PUT /webadmin/api/pages/{page}/slots/{slot}/source
GET /webadmin/api/pages/{page}/assets
POST /webadmin/api/pages/{page}/assets/{type}
PATCH /webadmin/api/pages/{page}/assets/{pageAsset}
DELETE /webadmin/api/pages/{page}/assets/{pageAsset}
GET /webadmin/api/media
POST /webadmin/api/media/fetch
GET /webadmin/api/blocks
GET /webadmin/api/blocks/{block}
PATCH /webadmin/api/blocks/{block}
POST /webadmin/api/pages/{page}/slots/{slot}/blocks
PATCH /webadmin/api/pages/{page}/slots/{slot}/blocks/reorder
DELETE /webadmin/api/pages/{page}/slots/{slot}/blocks/{block}
GET /webadmin/api/navigation-menus
GET /webadmin/api/navigation-menus/{navigationMenu}
POST /webadmin/api/navigation-menus
POST /webadmin/api/navigation-menus/{navigationMenu}/items
PATCH /webadmin/api/navigation-menus/{navigationMenu}/items/{item}
PATCH /webadmin/api/navigation-menus/{navigationMenu}/items/reorder
DELETE /webadmin/api/navigation-menus/{navigationMenu}/items/{item}
GET /webadmin/api/shared-slots
GET /webadmin/api/shared-slots/{sharedSlot}
POST /webadmin/api/shared-slots
POST /webadmin/api/shared-slots/{sharedSlot}/blocks
PATCH /webadmin/api/shared-slots/{sharedSlot}/blocks/reorder
DELETE /webadmin/api/shared-slots/{sharedSlot}/blocks/{block}
DELETE /webadmin/api/shared-slots/{sharedSlot}/blocks
GET /webadmin/api/plugins
POST /webadmin/api/plugins/install
POST /webadmin/api/plugins/{plugin}/enable
POST /webadmin/api/plugins/{plugin}/setup
POST /webadmin/api/plugins/{plugin}/disable
DELETE /webadmin/api/plugins/{plugin}
GET /webadmin/api/commerce/products
POST /webadmin/api/commerce/products
PATCH /webadmin/api/commerce/products/{product}
GET /webadmin/api/commerce/orders
GET /webadmin/api/commerce/orders/{order}
GET /webadmin/api/sites/{site}/assets/css
PUT /webadmin/api/sites/{site}/assets/css

GET /webadmin/api/locale-options returns the curated standard language locale picker catalog as a flat locale_options list. API tools should choose a returned locale_option when creating a normal language locale instead of inventing code and name strings.

POST /webadmin/api/locales and PATCH /webadmin/api/locales/{locale} require site-settings.write and accept safe locale fields only: locale_option, code, name, is_default, and is_enabled. Sending locale_option copies the canonical code and English name from the standard catalog; direct code and name remain available for controlled country-variant or custom/operator cases. Setting is_default=true forces the locale enabled and demotes any previous default through the normal CMS locale invariant. Migration tools should patch an existing locale id when correcting a language-only setup mistake, such as changing a fresh single-locale German install from en to de, so existing page, block, and site locale relations keep their ids.

Content Validate / Apply API

Content validate/apply endpoints handle complete multi-step content plans:

POST /webadmin/api/content/validate
POST /webadmin/api/content/apply

validate checks a complete content plan and writes nothing. apply validates the plan again and then creates the requested draft page, navigation items, Shared Slots, Shared Slot block trees, and page slot Shared Slot assignments transactionally. It can also replace named page-owned slots on an existing draft page when the plan uses mode: replace_existing_draft_page and includes an optimistic safety guard. This is useful for AI-generated pages, templates, starter pages, shared headers/footers, and migration helpers where the CMS should avoid half-created content.

The request body may still contain a plan field or another structured content plan payload. The URL should remain /content/validate and /content/apply.

Both modes are needed:

  • the Resource API exposes the existing CMS content model and contracts to internal tools
  • the Content Validate / Apply API avoids partial writes during larger page builds

Apply Restore Point

POST /content/apply accepts an optional top-level create_restore_point: true flag. When set, the CMS takes a full system backup (database plus uploads) as a restore point before applying the plan, so an operator can roll back from System -> Backups if an AI-generated apply goes wrong.

{
  "create_restore_point": true,
  "plan": { "mode": "create_draft_page", "page": { "...": "..." } }
}

Rules:

  • it requires the dedicated backups.create capability, which is not part of the default page-building set; without it the request returns JSON 403
  • the plan is validated first, so an invalid plan returns 422 without creating a wasted backup
  • if the backup cannot be created the apply is aborted and returns JSON 409; content is never applied without the requested safety net
  • on success the apply response includes a restore_point object with the backup id, type (content_apply), label, status, and created_at
  • the backup is full and synchronous, so it adds noticeable latency; use it for large or destructive applies, not high-frequency automation

The API intentionally exposes only restore-point creation. Restoring, downloading, and deleting backups stay in the operator admin UI and are not part of the token API.

Plugin And Commerce API

Trusted operator tools can manage manually installed plugins through explicit lifecycle endpoints:

GET /webadmin/api/plugins
POST /webadmin/api/plugins/install
POST /webadmin/api/plugins/{plugin}/enable
POST /webadmin/api/plugins/{plugin}/setup
POST /webadmin/api/plugins/{plugin}/disable
DELETE /webadmin/api/plugins/{plugin}
GET /webadmin/api/plugins/catalog
GET /webadmin/api/plugins/catalog/{plugin}
POST /webadmin/api/plugins/catalog/{plugin}/install
POST /webadmin/api/plugins/catalog/{plugin}/update

These endpoints require plugins.read, plugins.install, plugins.manage, plugins.setup, or plugins.uninstall respectively. They work with bearer authentication and do not require an authenticated browser session. The manual install endpoint accepts a validated plugin ZIP artifact. Catalog list/detail requests require plugins.read; catalog installation and update require plugins.install and reuse the CMS catalog client, compatibility checks, artifact size/ZIP validation, and SHA-256 verification. A catalog update must be newer than the installed version and restores the plugin's previous enabled state only after startup validation and database setup succeed. Both installation paths keep a newly installed plugin disabled by default. Since CMS 1.92.0, installation, updates, and activation apply required plugin database changes automatically. A failed setup leaves the plugin disabled with an explicit retry action. Panel/API enable and setup reject incompatible plugins with HTTP 409 and plugin_incompatible before changing state. CMS 1.94.0 adds a mandatory isolated startup check, retained packages, runtime quarantine, and /webadmin/plugin-recovery; see Plugin System for recovery conditions. Uninstall is limited to disabled manually uploaded plugins and preserves plugin-owned tables.

When WebBlocks Commerce is enabled and setup-ready, trusted tools can create/list products and read orders:

GET /webadmin/api/commerce/products
POST /webadmin/api/commerce/products
PATCH /webadmin/api/commerce/products/{product}
GET /webadmin/api/commerce/orders
GET /webadmin/api/commerce/orders/{order}

Commerce product reads require commerce.read, product writes require commerce.products.write, and order reads require commerce.orders.read. If the plugin is disabled or setup migrations have not run, Commerce endpoints return JSON 409 with setup guidance instead of a raw database error.

The plugin-owned webblocks-commerce-buy-button block is discoverable from GET /webadmin/api/block-types and GET /webadmin/api/content-contract only while the plugin is enabled. Its settings require settings.commerce_product_id from GET /webadmin/api/commerce/products; validate/apply rejects missing, unknown, or inactive product ids. Checkout remains on the public Commerce/PayPal flow. The CMS API creates products and page blocks, but it does not collect card data or start a card-entry flow.

Existing Block Native Field Updates

Use content validate/apply to build a full page in one transaction. For incremental draft edits, use the Page Block Topology API to add, reorder, or delete individual page-owned blocks. Use the existing-block update endpoint only when the block already exists and the change maps to a safe native field:

GET /webadmin/api/media?kind=image
PATCH /webadmin/api/blocks/{block}

The endpoint requires content.apply. If the block is part of a Shared Slot source tree, the token must also have shared-slots.write.

Supported fields are intentionally narrow:

  • media_id or asset_id for navbar-brand and sidebar-brand logo media
  • media_id or asset_id for hero, section, card, cta, and content_header background media
  • settings.url
  • settings.target
  • settings.aria_label
  • settings.background_position
  • settings.background_overlay
  • settings.show_search for header-actions
  • settings.show_mode_toggle for header-actions
  • settings.show_accent_toggle for header-actions
  • settings.show_language_switcher for header-actions
  • text translations such as title and subtitle
  • image block translations through translations.image.alt_text, translations.image.caption, or the shorthand translations.alt_text and translations.caption
  • Image Gallery Viewer grouping through settings.viewer_enabled and a lowercase settings.viewer_group; enabled, unlinked Image blocks sharing a group render in one existing CMS Gallery Viewer while remaining independently positioned
  • contact form translations through translations.contact_form.submit_label and translations.contact_form.success_message, or the same names as shorthand, alongside title and content
  • url
  • variant

Translated fields are written into the translation family the block type belongs to, for the locale in the request (the site default locale when locale is omitted):

text

Field Details
Family text
Block types header, hero, rich-text, columns, cta, and most content blocks
Fields title, eyebrow, subtitle, content, meta

button

Field Details
Family button
Block types button
Fields title

image

Field Details
Family image
Block types image
Fields caption, alt_text (also accepted as title, subtitle)

contact_form

Field Details
Family contact_form
Block types contact_form
Fields title, content, submit_label, success_message

A field the family does not own is refused with 422 and code unsupported_block_translation_fields, and a block type with no translation family refuses translations outright with unsupported_block_translations — neither is written to the text table, where nothing would read it. A locale the block's site has not enabled is refused with invalid_block_translation_locale. Stored rows are returned under block.translations, keyed by family.

For Image blocks, an explicitly empty string or null clears the selected locale’s caption or alt_text; the title and subtitle aliases have the same behavior. Omitted fields keep their stored values. Clearing editorial image text preserves other languages, media references and dynamic bindings. An empty editorial caption produces no figcaption unless a dynamic binding supplies one; the existing Media/dynamic-source fallback for alt text remains in effect.

A contact form's submit_label and success_message are translations, not settings: they are the only way to localize the form's button and confirmation text, and they cannot be set through settings.

The endpoint rejects topology and database implementation fields such as parent_id, slot_type_id, block_type_id, type, sort_order, and children. It also rejects raw HTML, remote media URLs, and source URLs. Existing media can be selected from the CMS Media Library, but this API does not import or download new remote media.

Example:

{
  "locale": "en",
  "media_id": 12,
  "settings": {
    "url": "/",
    "target": "_self",
    "aria_label": "WebBlocks CMS home"
  },
  "translations": {
    "title": "WebBlocks CMS",
    "subtitle": "A modern block-based CMS."
  }
}

For an existing Image block, update the locale-owned image translation row with:

{
  "locale": "en",
  "translations": {
    "image": {
      "alt_text": "Dashboard-style ecosystem panel",
      "caption": "Homepage ecosystem panel"
    }
  }
}

Page Block Topology API

For incremental draft page editing, page-owned block topology can be changed one operation at a time without sending a full content plan:

POST /webadmin/api/pages/{page}/slots/{slot}/blocks
PATCH /webadmin/api/pages/{page}/slots/{slot}/blocks/reorder
DELETE /webadmin/api/pages/{page}/slots/{slot}/blocks/{block}

{slot} is a page-owned slot slug. These endpoints are intentionally draft-safe and narrow:

  • they operate only on draft pages; a non-draft page returns JSON 409 with code page_not_draft. Use content apply staged updates for published pages.
  • they operate only on page-owned slots; a Shared Slot-backed slot returns JSON 422. Use the Shared Slot endpoints for Shared Slot content.
  • an unknown page slot returns JSON 404.

POST .../blocks adds one block using the same normalized block payload as content apply (type, settings, translations, and nested children). New blocks are created with draft status and appended to the end of the slot. Pass parent_id to append under an existing container block in the same slot; the parent must accept children. Creating and reordering require content.apply.

PATCH .../blocks/reorder renumbers one sibling group. The blocks array must contain every sibling id for a single parent group exactly once, in the desired order; the parent is derived from the submitted blocks. Submitting a partial group returns JSON 422.

DELETE .../blocks/{block} removes the block and its whole subtree and returns deleted_blocks_count. Deletion requires the dedicated content.blocks.delete capability, which is not part of the default page-building capability set. Shared Slot source blocks cannot be deleted through this endpoint.

Every write captures a page revision so draft edits stay reversible.

Hero And CTA Actions

hero and cta are ordinary containers whose action buttons are plain button_link children — declare them under children like any other nested block. Two optional block fields remain as a shorthand for the common one-or-two-button case:

{
  "type": "hero",
  "translations": { "title": "Welcome" },
  "primary_cta": { "label": "Get started", "url": "/signup" },
  "secondary_cta": { "label": "Docs", "url": "https://example.com/docs" }
}

Both fields accept an object with a required label and a required url, or null to leave the action unset. URLs must be a safe internal path or an http(s) URL. The shorthand writes the first two button_link children, so the result is indistinguishable from buttons an editor adds by hand. Declaring the children explicitly is the canonical form and the only way to author more than two buttons. Sending primary_cta or secondary_cta on any other block type is rejected.

The fields work everywhere a block payload is accepted: content validate/apply plans, POST /pages/{page}/slots/{slot}/blocks, and POST /shared-slots/{sharedSlot}/blocks.

Page Render API

Page version review and restore candidates

Page recovery uses an inspect-preview-apply workflow. There is no direct restore endpoint.

  • GET /webadmin/api/pages/{page}/versions lists saved versions with their page state, audit fields, and a summary of changes from the preceding version. GET .../versions/{version} returns the full comparison and reference-health result. Both require content.read.
  • POST .../versions/{version}/candidate requires content.apply. It creates a private draft candidate from the snapshot and returns its browser preview URL. The source page is not changed.
  • POST .../version-candidates/{candidate}/apply requires both content.apply and content.publish. Apply is rejected with JSON 409 and code restore_candidate_stale if the source page changed after the candidate was prepared. A successful apply creates the normal pre- and post-restore safety versions and removes the technical draft.
  • DELETE .../version-candidates/{candidate} requires content.apply; it removes the candidate without changing the source page.

Candidates are deliberately absent from ordinary page discovery and site export. Tools should always inspect the version, prepare a candidate, render or open its preview_url, and only then call apply_url after explicit operator approval.

GET /webadmin/api/pages/{page}/render requires content.read and returns the page rendered exactly as the browser admin preview renders it: draft blocks included, X-Robots-Tag: noindex, nofollow, and never through the public route, so nothing about it publishes anything.

Add ?format=html for the markup itself instead of the JSON envelope, and ?locale=de to render a specific translation; the default is the site default locale. A locale the page has no translation for returns 422 with page_translation_missing and the locales it does have. Shared Slot source pages are editing scaffolding and return 422 with page_not_renderable, matching the browser admin's own 404 on them.

Rendering was already reachable before this endpoint existed: GET /webadmin/pages/{page}/preview accepts a Bearer token with content.read as well as an admin session, and still does. The API adds a JSON envelope and discovery/OpenAPI links. The browser preview also accepts a locale code: the language must be enabled for the site and have a page translation. Omitting it keeps the default language. Both previews pass the chosen language to block rendering; neither saves or publishes content.

Either way, render before reporting a page finished. Content apply says what was stored, which is not the same as what renders: a wrapper block with no children, a slot assigned to the wrong Shared Slot, or a layout without the slots the plan assumed all apply cleanly and still produce a page nobody wants.

Page Translation API

Page translations own localized page identity and the page-level SEO and Open Graph overrides. Content apply writes one translation row for one locale when it creates a page; these endpoints are how that row is read back and changed afterwards.

GET /webadmin/api/pages/{page}/translations lists every translation on a page with name, slug, path, seo_title, seo_description, seo_keywords, list_excerpt, og_title, og_description, and og_image_media_id. list_excerpt is the per-locale sentence a Page List block shows under this page's title on a listing card; it is capped at 300 characters and falls back to the SEO description when empty. The same fields now appear on each translation in GET /webadmin/api/pages/{page}.

POST /webadmin/api/pages/{page}/translations/{locale} requires content.apply and adds a translation for a locale that is already enabled for the page site. The {locale} segment accepts a locale code or a numeric ID. Only name is required: omit slug and path and both are derived from the name, exactly as the browser admin derives them. Creating a translation for a locale the page already has returns 422 with the code page_translation_exists.

PATCH /webadmin/api/pages/{page}/translations/{translation} requires content.apply and writes only the fields the request carries, so a tool can set seo_description without resending page identity. Send null to clear an optional field.

Slug and path always move together. Sending path re-derives the slug from its last segment; sending slug alone moves the page to /{slug}. Paths are canonicalized, checked against reserved CMS and host routes, and must stay unique for the site and locale. Because Page title and slug read through to the default translation, renaming that translation renames the page itself.

Fields outside localized identity and SEO are rejected with 422 and the code unsupported_page_translation_fields, which names them in blocked_fields. Page status, layout, and block content have their own endpoints.

Page Assets API

Pages can load their own /site/... CSS and JS files in addition to the site-wide assets:

GET    /webadmin/api/pages/{page}/assets
POST   /webadmin/api/pages/{page}/assets/{type}
PATCH  /webadmin/api/pages/{page}/assets/{pageAsset}
DELETE /webadmin/api/pages/{page}/assets/{pageAsset}

{type} is css or js. Listing is available to any valid token; the write endpoints require the dedicated opt-in page-assets.write capability, which is not part of the default page-building set.

The path contract is deliberately narrow and is the security boundary for this API:

  • it must be a local path starting with /site/ and ending in .css or .js matching the asset type
  • external URLs (http://, https://), protocol-relative (//), javascript:, and data: paths are rejected
  • directory traversal, backslashes, query strings, and fragments are rejected

This endpoint only attaches an existing file to a page; it never writes file contents. Write the file itself with the site assets API (PUT /webadmin/api/sites/{site}/assets/{type}, which requires site-assets.write) or from the admin UI.

Supported fields are path (required on create), sort_order, is_enabled, and — for JS assets only — is_defer, is_async, and is_module. load_position is derived from the type (head for CSS, body_end for JS), matching the admin editor. The css/js type is immutable on update; delete and re-add to change it.

Unlike the page block topology endpoints, page assets are not draft-only: they can be changed on any page, matching the admin Page Assets tab. Every write captures a page revision.

Shared Slot Block Topology API

Shared Slot block trees have their own topology endpoints because they are shared across every page the slot is assigned to:

PATCH  /webadmin/api/shared-slots/{sharedSlot}/blocks/reorder
DELETE /webadmin/api/shared-slots/{sharedSlot}/blocks/{block}
DELETE /webadmin/api/shared-slots/{sharedSlot}/blocks

Adding blocks stays at POST /shared-slots/{sharedSlot}/blocks, and editing a single block's safe content fields stays at PATCH /blocks/{block} (which already accepts Shared Slot source blocks with shared-slots.write). The new endpoints add the missing topology operations:

  • PATCH .../blocks/reorder renumbers one sibling group; the blocks array must contain every sibling id for a single parent group exactly once, in the desired order. It requires shared-slots.write.
  • DELETE .../blocks/{block} removes one block and its subtree and returns deleted_blocks_count.
  • DELETE .../blocks clears every block in the slot so a tool can clear-and-replace a Shared Slot; it returns deleted_blocks_count.
  • Both deletes require shared-slots.write plus the dedicated content.blocks.delete capability.

Unlike page block edits, these are not draft-only: Shared Slots have no draft-page concept. Reordering or deleting an already-published Shared Slot block therefore affects every assigned page immediately, which is why deletion is gated behind the destructive content.blocks.delete capability. Every write rebuilds the slot's page assignments and captures a Shared Slot revision so changes stay reversible.

Site Settings API

Alongside branding, head, timezone, and public-theme, three further site fields are writable. All three require site-settings.write.

PATCH /webadmin/api/sites/{site}/seo writes seo_title, seo_description, and seo_keywords: the site-wide fallback metadata a public page inherits when its own page translation carries no override. Partial, and null clears a field. Page-level overrides belong on the page translation; see the Page Translation API above.

PATCH /webadmin/api/sites/{site}/contact-recipient writes contact_recipient_email, the address contact form submissions are mailed to. Send an empty value to fall back to the system recipient. A contact form assembled through the API does nothing useful until this is set.

PUT /webadmin/api/sites/{site}/locales replaces the site's locale set from locale_ids. The default locale is always kept, exactly as the admin form keeps it, and locales must be globally enabled first through POST/PATCH /webadmin/api/locales. A page translation cannot be saved for a locale the site has not enabled, so this is the endpoint that makes a new locale usable on a site.

This endpoint is stricter than the admin form in one respect: it refuses to detach a locale that still has page translations on the site, and the 422 names the locale and the count. The form lets an operator do that knowingly; an unattended tool would silently orphan the content.

Site Domain API

Domain records decide which hostname resolves to which site, so the browser admin keeps them behind system-level access. The API endpoints are:

  • PATCH /webadmin/api/sites/{site}/seo
  • PATCH /webadmin/api/sites/{site}/contact-recipient
  • PUT /webadmin/api/sites/{site}/locales
  • GET /webadmin/api/sites/{site}/domains requires content.read
  • POST /webadmin/api/sites/{site}/domains requires domains.write
  • PUT /webadmin/api/sites/{site}/domains/{domain} requires domains.write
  • POST /webadmin/api/sites/{site}/domains/{domain}/primary requires domains.write
  • DELETE /webadmin/api/sites/{site}/domains/{domain} requires the destructive domains.delete
  • GET /webadmin/api/domains/{domain}/status requires content.read

These endpoints previously lived only under an /admin-api prefix and checked nothing beyond token validity, so any token at all could add or remove a domain. They now sit under /webadmin/api with the rest of the API and every route carries a capability. The /admin-api prefix keeps working for existing provisioning tools, with the same capability requirements and one extra route, GET /admin-api/sites; new integrations should use /webadmin/api, whose paths are also covered by the CSRF exemption that the legacy prefix never had.

Shared Slot Metadata API

PATCH /webadmin/api/shared-slots/{sharedSlot} requires shared-slots.write and changes the Shared Slot itself rather than its blocks: label, handle, slot, layout, and is_active. Only the fields present are written. Handles stay unique per site, the slot name must resolve to a published slot type, and the Shared Slot source page and its assignments are rebuilt afterwards exactly as the browser admin rebuilds them. A metadata or status change captures a Shared Slot revision.

Moving a Shared Slot to another site is not supported here and is rejected with 422 and the code unsupported_shared_slot_fields, which also covers any other field outside that set. Cross-site moves stay in the browser admin with the other site transfer operations.

DELETE /webadmin/api/shared-slots/{sharedSlot} requires the destructive shared-slots.delete capability and applies the same guard as the browser admin: a Shared Slot still referenced by any page slot is never deleted. The 422 response carries the code shared_slot_in_use and a usage array naming the referencing page slots, so a tool can detach them first with PUT /webadmin/api/pages/{page}/slots/{slot}/source. If the Shared Slot reference columns have not been migrated yet, the endpoint returns 409 with shared_slots_not_ready instead of guessing.

Media Library API

The Media Library API is intentionally split from broad content permissions so AI/operator tools can be granted media discovery or metadata cleanup without receiving unrelated write powers.

Capabilities:

  • media.read: list and inspect existing CMS Media Library records.
  • media.upload: upload files into the Media Library.
  • media.write: update safe metadata on existing media records.
  • media.replace: replace an existing media file while preserving its media id.
  • media.move: move media between folders or clear its folder.
  • media.delete: delete unused media with the same usage guard as the admin panel.

Phase 1 scope:

GET /webadmin/api/media
POST /webadmin/api/media
GET /webadmin/api/media/{media}
PATCH /webadmin/api/media/{media}
POST /webadmin/api/media/{media}/replace
POST /webadmin/api/media/{media}/move
DELETE /webadmin/api/media/{media}

GET /webadmin/api/media returns existing media records with safe fields such as id, kind, title, filename, original name, mime type, visibility, public URL when available, alt text, dimensions, previewability, and compact metadata label. It requires media.read. During the transition from older API tokens, content.read may also be accepted for read-only media discovery so existing page-building integrations keep working.

GET /webadmin/api/media/folders lists Media Library folders with their media counts, and POST /webadmin/api/media/folders requires media.write and creates one from name, with optional slug and parent_id. A name that already exists under the same parent is refused with 422 and the code media_folder_exists, and the response carries the existing folder so a retrying tool reuses it instead of creating duplicates. File uploads into a folder with folder_id; move existing media with POST /webadmin/api/media/{media}/move.

POST /webadmin/api/media requires media.upload and accepts multipart/form-data uploads into the normal CMS Media Library. The uploaded file becomes a regular media row and is visible in the browser admin Media screen and media pickers. The upload validation mirrors the browser admin Media Library allowed file types: common images, videos, PDFs, Office documents, text/CSV/RTF, and ZIP files.

Supported metadata fields are folder_id, title, alt_text, caption, and description. The endpoint returns the created media object with its id and public URL when available.

PATCH /webadmin/api/media/{media} requires media.write and updates only safe descriptive metadata:

  • title
  • alt_text
  • caption
  • description

POST /webadmin/api/media/{media}/replace requires media.replace and replaces the stored binary file while preserving the media id and existing references. The replacement file must resolve to the same media kind as the existing record, such as image-to-image or document-to-document. The response includes current usage details so tools can see whether the replacement affects blocks, site branding, or page SEO.

POST /webadmin/api/media/{media}/move requires media.move and accepts folder_id, including null to clear the folder assignment.

DELETE /webadmin/api/media/{media} requires media.delete and uses the same usage guard as the browser admin. Media referenced by blocks, galleries, attachments, site branding, or page SEO is not deleted; the API returns 422 with usage details instead.

This API explicitly does not support:

  • changing disk, path, filename, mime type, kind, visibility, size, dimensions, uploader, or usage relationships directly
  • fetching or importing remote media URLs

This boundary lets AI/operator tools receive only the media powers they need. For example, a page-building token can read and upload media without being able to delete or replace existing files, while a trusted maintenance token can be granted media.replace or media.delete explicitly.

Existing Draft Page Slot Replacement

Existing draft page replacement stays inside the validate/apply contract:

POST /webadmin/api/content/validate
POST /webadmin/api/content/apply

Use mode: replace_existing_draft_page to replace one or more page-owned slots on an existing draft page. The operation requires content.validate for validate and content.apply for apply. It does not require pages.delete, because it is not a general page delete operation.

Page Translation path is the canonical public URL. New plans should use paths such as /contact, /features, /games/fruit-train, or /docs/internal-content-api; /p/... is legacy compatibility only. Slash-bearing paths are normalized segment by segment, so /docs/internal-content-api/ becomes /docs/internal-content-api and is not collapsed into docsinternal-content-api. CMS derives the short page slug from the final path segment, so /games/fruit-train stores slug fruit-train and path /games/fruit-train. Reserved route areas such as /webadmin, /webadmin/api, /cms, /search, /search.json, /contact-messages, /install, and host auth routes cannot be created as public page paths.

Example:

{
  "plan": {
    "mode": "replace_existing_draft_page",
    "site": "default",
    "locale": "en",
    "page": {
      "id": 9,
      "expected_path": "/contact",
      "status": "draft"
    },
    "replace_slots": {
      "main": [
        {
          "type": "plain_text",
          "translations": {
            "content": "Updated draft contact content."
          }
        }
      ]
    }
  }
}

Rules:

  • target page must be in draft status
  • expected_path or expected_updated_at is required
  • expected_path uses the canonical public Page Translation path, not a /p/... legacy alias
  • the target page must belong to the requested site and locale must be enabled for that site
  • each slot must exist on the page and use page-owned blocks
  • Shared Slot-backed slots are rejected instead of being cleared
  • only blocks in the named replace_slots are removed
  • old blocks are removed and new blocks are written in one transaction
  • page revisions are captured before and after apply
  • no publish, media fetch/import, broad delete, or Shared Slot assignment clearing happens

Published Page Staged Updates

Published pages cannot be replaced directly. For source-linked docs pages or other live pages that must remain public while content changes are prepared, use the staged update modes:

create_staged_update_for_published_page
replace_staged_page_update
promote_staged_page_update

create_staged_update_for_published_page creates a draft staging page from the published source page only when that source page does not already have an active draft staged update. Repeating the create call for the same source page returns the existing active staged draft with data.reused_staged_update=true instead of creating another technical page. The source page remains published at its existing public path. The staged page stores settings.staged_update metadata with the source page id, source path, source updated timestamp, lifecycle state, and managed slots. It uses an internal draft path such as /staged-updates/page-123/update-456, so it does not collide with the source canonical path and is not publicly routed. The normal admin preview URL /webadmin/pages/{page}/preview can render the staged page for authenticated admin sessions or CMS API Bearer tokens with content.read.

replace_staged_page_update reuses the draft replacement rules against the staged draft page. Use it for every subsequent revision after a staged draft exists. It replaces only page-owned slots and rejects Shared Slot-backed slots.

promote_staged_page_update applies staged page-owned slot content back onto the published source page in a transaction. It preserves the source page path, status, layout, Shared Slot assignments, and page settings unless an allowlisted source_sync update is supplied. Promoted page-owned blocks are written as published so the public page reflects the promoted content immediately. Shared Slot content is never cascaded. After the source page's pre/post-promote safety versions are captured, the technical staged page is deleted in the same transaction; a failed promotion therefore leaves the draft available for correction. Promote requires the normal content.apply route capability and the advanced content.publish capability.

Abandoning an active staged draft uses DELETE /webadmin/api/pages/{staged_page}/staged-update. This narrow discard endpoint requires content.apply, accepts only a draft page with active staged-update metadata, and never changes the published source page or its saved versions. It avoids granting a page-building tool the broad pages.delete capability merely to clean up its own technical draft.

If a normalized apply plan passes validation but fails during transactional writes, the API returns normal validation JSON with ok=false and an error at plan.apply. The detailed exception is recorded in application logs; API responses stay public-safe and do not include stack traces, database internals, tokens, or local paths.

Example staged workflow:

{
  "plan": {
    "mode": "create_staged_update_for_published_page",
    "site": "default",
    "locale": "en",
    "page": {
      "id": 123
    },
    "expected_source_path": "/docs",
    "managed_slots": ["main"]
  }
}

Then replace the staged content:

{
  "plan": {
    "mode": "replace_staged_page_update",
    "staged_page_id": 456,
    "expected_source_page_id": 123,
    "expected_source_path": "/docs",
    "replace_slots": {
      "main": [
        {
          "type": "plain_text",
          "translations": {
            "content": "Replacement staged content."
          }
        }
      ]
    }
  }
}

Promote after preview and explicit approval:

{
  "plan": {
    "mode": "promote_staged_page_update",
    "staged_page_id": 456,
    "expected_source_page_id": 123,
    "expected_source_path": "/docs",
    "promote_slots": ["main"]
  }
}

No new schema table is required for this workflow; active staged lifecycle metadata uses the existing Page settings JSON column. Promoted technical pages are not retained because source-page versions provide the recovery trail.

Installations upgrading from a version that archived promoted staged pages can preview and remove those legacy records once:

php artisan webblocks:staged-updates:prune --dry-run
php artisan webblocks:staged-updates:prune

The command selects only archived pages whose staged metadata explicitly says type=published_page_update and state=promoted. Active drafts and ordinary archived pages are never candidates.

Source Sync Metadata

Content plans may persist a limited, secret-safe source_sync object for AI/operator docs sync workflows. Arbitrary page settings are rejected. The accepted shape is:

{
  "page": {
    "settings": {
      "source_sync": {
        "type": "markdown_documentation",
        "source_id": "webblocks-cms:docs/internal-content-api.md",
        "source_path": "docs/internal-content-api.md",
        "source_sha256": "64-character-lowercase-sha256",
        "managed_slots": ["main"],
        "last_synced_at": "2026-06-25T00:00:00Z"
      }
    }
  }
}

Apply persists this metadata to page settings, and page list/detail API responses expose the same allowlisted source_sync fields for future matching. Do not include tokens, environment values, local/server absolute paths, or other secrets.

Explicit Publish Endpoints

Publishing is separate from content apply and requires a token with content.publish.

POST /webadmin/api/pages/{page}/publish
POST /webadmin/api/pages/{page}/publish-page-owned-blocks
POST /webadmin/api/pages/{page}/archive

POST /webadmin/api/pages/{page}/publish publishes the page record. Its default payload is page-only:

{
  "include_page_owned_blocks": false
}

Rules:

  • omitted include_page_owned_blocks behaves as false
  • include_page_owned_blocks: false publishes only the page record and leaves draft or in-review blocks unchanged
  • include_page_owned_blocks: true publishes draft and in-review blocks owned by the page's non-shared page slots, including nested child blocks
  • already published blocks remain unchanged
  • Shared Slot-backed slots are excluded and reported in the response
  • unsupported Shared Slot cascade fields such as publish_shared_slots, include_shared_slot_blocks, or shared_slot_cascade return JSON 422
  • the response includes page id/status/path metadata, whether page-owned blocks were included, the count published, excluded Shared Slot summaries, and the page revision id

POST /webadmin/api/pages/{page}/publish-page-owned-blocks publishes only unpublished page-owned blocks and does not change the page workflow status. It uses the same content.publish capability and the same Shared Slot exclusion rule.

POST /webadmin/api/pages/{page}/archive takes the page off the public site: archived pages return 404 to visitors and their page-linked menu items stop rendering in public menus. It uses the same content.publish capability and mirrors the admin workflow rules: allowed from in_review and published, a draft page returns JSON 422, re-archiving an archived page is a no-op success, and staged update pages are rejected (promote or delete those instead). The response includes page metadata, from_status, and the captured page revision id. Publishing the page again reverses the archive.

AI/operator tools must not assume page publish makes all block content public. Use include_page_owned_blocks: true only when the user explicitly approved publishing all unpublished page-owned blocks for that page. Shared Slot content must be reviewed and published separately.

Content Contract Endpoint

GET /webadmin/api/content-contract is a read-only discovery endpoint for trusted AI/operator tools. It returns the API prefix, validate/apply URLs, admin preview URL template, safety flags, discovery URLs, recommended page-building patterns, a machine-readable design-direction contract, and sanitized block contract metadata.

Before choosing a block tree, tools must read design_direction, select one value for each of character, density, typography, geometry, imagery, corners, contrast, and framing, and explain how the composition map implements those choices. This is a planning contract rather than a hidden content store: the result is expressed through native block settings, public theme tokens, and stable site CSS. Its composition policy makes Columns plain by default for new API plans, keeps Cards opt-in, and requires a content-boundary/action justification for every Card collection. A source containing three short items is not such a justification.

framing is a planning dimension, not persisted page data. Choose open, restrained, framed, or dense-ui according to the content and context. Do not use a Card or bordered panel merely to organize a section. Framing must communicate semantic independence, interaction, or entity boundaries. Hero outer containers, feature/principle groups, section wrappers, navigation/footer columns, CTA bands, decorative stats, and editorial splits are unframed by default. Prefer whitespace, typography hierarchy, asymmetric layout, dividers, tone changes, wide sections, alignment, offset/overlap, and restrained rules. The required framed-surface audit must justify every non-entity framed surface; “to visually separate the section” is not sufficient.

The adjacent design_fixtures section publishes canonical native block trees, compatible design directions, expected public hooks, anti-patterns, and deterministic desktop/mobile capture sizes. Its status distinguishes a source-backed render contract from human-approved screenshots. Tools may use a fixture tree as a composition reference, but must not claim visual approval while the registry reports render_contract_ready_capture_pending. Inspect the available fixture contracts under design_fixtures in the discovery response.

The endpoint is generic CMS product behavior. It must not return install-specific secrets, token values, raw Blade contents, absolute filesystem paths, private server paths, or site-specific instructions. Block contract rows may include handle/slug, label, category, status, container and child support, translatable fields, shared settings fields, and public renderer root behavior.

AI tools should call this endpoint or GET /webadmin/api/block-types before building a plan and must use only handles that are present in the current install.

Block contracts may expose optional public icon and badge fields, including settings.icon_slug, settings.icon_tone, settings.icon_size, settings.badge_tone, and translated eyebrow/badge_label copy. Tools must treat icon slugs as catalog-backed selections rather than free text; public rendering skips unknown or inactive icons and only emits safe wb-icon wb-icon-{slug} classes for active WebBlocks UI catalog rows. settings.icon_tone is a shared public visual tone setting, not locale-owned copy, and accepts only default, soft, brand, accent, highlight, bold, and quiet; settings.icon_size accepts default, sm, lg, or xl and maps to the matching WebBlocks UI size class. info, success, warning, and danger remain semantic status tones for badges, alerts, validation, and feedback. Unknown icon tones or sizes are rejected during content plan validation or ignored by public rendering as a safe fallback. Badge tone is exposed only when the block also owns translated badge-label copy.

GET /webadmin/api/icon-catalog?context=content returns the active safe icon slugs that API clients may assign to public content blocks. Use context=navigation to rank navigation-tagged suggestions first for navigation item icon choices; both contexts still return every active icon because context is a hint rather than an allowlist. API tools should read this endpoint before sending settings.icon_slug or navigation icon values and must not guess icon names from the WebBlocks UI manifest.

The contact_form contract includes additional safe form metadata: settings schema, translated fields, the public POST /contact-messages submit endpoint, required CSRF browser behavior, server validation rules, the CMS-owned hidden generated anti-spam check field, generic check-field success behavior, spam classification/quarantine notes, storage-before-notification behavior, recipient fallback order, safe notification failure recording, and /webadmin/contact-messages review behavior. The check field is generated by the renderer, is not part of normal visitor input, and should not be created manually by API or AI/operator tools. Contact-page tools should use that native block rather than Trusted HTML, raw form markup, or mailto: forms. The old website field is no longer the public Contact Form contract.

The human-readable AI Page Building Guide ships in package-native installs at vendor/fklavyenet/webblocks-cms/docs/ai-page-building-guide.md.

API Surface

Discovery Endpoints

  • GET /webadmin/api
  • GET /webadmin/api/openapi.json
  • GET /webadmin/api/ai-guide
  • GET /webadmin/api/examples
  • GET /webadmin/api/examples/contact-page
  • GET /webadmin/api/examples/landing-page
  • GET /webadmin/api/sites
  • GET /webadmin/api/locales
  • GET /webadmin/api/locale-options
  • POST /webadmin/api/locales
  • PATCH /webadmin/api/locales/{locale}
  • GET /webadmin/api/page-layouts
  • GET /webadmin/api/block-types
  • GET /webadmin/api/icon-catalog
  • GET /webadmin/api/content-contract

Page Endpoints

  • GET /webadmin/api/pages
  • GET /webadmin/api/pages/{page}
  • GET /webadmin/api/pages/{page}/render
  • GET /webadmin/api/pages/{page}/translations
  • POST /webadmin/api/pages/{page}/translations/{locale}
  • PATCH /webadmin/api/pages/{page}/translations/{translation}
  • POST /webadmin/api/pages/{page}/sync-layout-slots
  • PATCH /webadmin/api/pages/{page}/layout
  • POST /webadmin/api/pages/{page}/slots/{slot}/shared-slot
  • PUT /webadmin/api/pages/{page}/slots/{slot}/source

Block Endpoints

  • GET /webadmin/api/blocks
  • GET /webadmin/api/blocks/{block}
  • GET /webadmin/api/navigation-menus
  • GET /webadmin/api/navigation-menus/{navigationMenu}
  • POST /webadmin/api/navigation-menus
  • POST /webadmin/api/navigation-menus/{navigationMenu}/items
  • PATCH /webadmin/api/navigation-menus/{navigationMenu}/items/{item}
  • PATCH /webadmin/api/navigation-menus/{navigationMenu}/items/reorder
  • DELETE /webadmin/api/navigation-menus/{navigationMenu}/items/{item}

Navigation menus use the existing CMS navigation_items.menu_key model. Phase 2A supports the shipped CMS menu handles such as primary, footer, mobile, legal, and docs; it does not add a separate menu table. Creating a navigation menu is treated as creating a safe site-scoped menu group with optional initial items. It refuses to overwrite a site/menu that already has items.

Navigation item create, update, visibility changes, and reorder require navigation.write. Delete requires the explicit destructive navigation.delete capability. Item updates support label/title, url, link_type, page_id, target, visibility, sort_order/position, parent_id, and icon where those fields are valid for the selected link type. Updates are partial: fields absent from the payload keep their stored values without re-validation, so legacy rows (a pre-tree-editor sort order of 0, a child nested under a page-type parent) stay updatable; the rules apply to the values a request actually sends.

Item updates also accept an optional locale key (a locale code enabled for the item's site), mirroring block translation writes. Without locale, or with the site default locale, label/title writes the canonical default-locale title. With a non-default locale, the label/title value is stored as a locale-keyed translation row for that item and the canonical title stays untouched; other fields in the same request (visibility, position, and so on) remain shared and apply to the item itself. Public rendering picks the navigation title for the requested locale and falls back to the canonical default-locale title when no translation row exists. Item read payloads expose the stored per-locale titles as a title_translations map keyed by locale code. Reorder payloads must include every item in the selected site/menu exactly once. Delete rejects parent items that still have child items, so tools must move or delete children first instead of relying on cascades.

Navigation item URLs may be internal paths such as /, /about, /contact, and /#platform, or safe http/https URLs. Use a path plus fragment for same-page anchors; raw fragment-only values such as #platform are not navigation URLs. The API rejects javascript:, data:, protocol-relative URLs, traversal, malformed URLs, unsupported targets, and empty labels. Navigation endpoints do not create pages, publish pages, crawl sites, fetch remote URLs, or cascade-delete child items.

Shared Slot Endpoints

  • GET /webadmin/api/shared-slots
  • GET /webadmin/api/shared-slots/{sharedSlot}
  • PATCH /webadmin/api/shared-slots/{sharedSlot}
  • DELETE /webadmin/api/shared-slots/{sharedSlot}
  • POST /webadmin/api/shared-slots
  • POST /webadmin/api/shared-slots/{sharedSlot}/blocks
  • POST /webadmin/api/shared-slots/{sharedSlot}/publish-blocks

Shared Slot creation is site-scoped and refuses duplicate handles for the same site. Shared Slot blocks reuse the same block payload writer used by page-owned blocks, so locale-owned copy stays in translation rows and shared settings remain on the block record/settings path. Shared Slot block append requests may include parent_id or parent_block_id to add a block under an existing block in the same Shared Slot source tree when that parent accepts the requested child type. Shared Slot blocks are created as draft content and require explicit POST /webadmin/api/shared-slots/{sharedSlot}/publish-blocks with shared-slots.write plus content.publish before they render publicly. Media import and media assignment remain outside this phase.

Page Slot Assignment

POST /webadmin/api/pages/{page}/slots/{slot}/shared-slot
PUT  /webadmin/api/pages/{page}/slots/{slot}/source

The shared-slot endpoint assigns an existing compatible same-site active Shared Slot to an existing page slot. It does not publish the page. It refuses cross-site, inactive, and incompatible Shared Slots. It also refuses to switch a slot that still has page-owned blocks, because Phase 2A does not delete or replace those blocks automatically.

PUT .../source writes the slot's content source and is the way back out: source_type accepts page, shared_slot, or disabled. It requires content.apply, and shared_slot additionally requires shared-slots.write and the same compatibility rules as the assign endpoint, which it delegates to. Detaching clears shared_slot_id and leaves page-owned blocks untouched, so page renders them again and disabled keeps the slot wrapper with nothing inside. Before this endpoint existed the API could create a Shared Slot reference it had no way to remove — the only exit was the session-authenticated admin screen.

If the page layout contains a slot such as header but the page record was created before that Page Slot existed, call POST /webadmin/api/pages/{page}/sync-layout-slots first. Slot sync is idempotent and only creates missing Page Slots from the selected Page Layout; it never deletes existing slots, blocks, disabled states, Shared Slot assignments, translations, or revisions. For a new Shared Slot header, publish the Shared Slot blocks explicitly before expecting public output.

Page Layout Assignment

PATCH /webadmin/api/pages/{page}/layout

Writes an existing page's Page Layout (public_shell). The admin edit form could always change this; the API could previously only set it when creating a page. content.apply covers it. layout (or handle) must be one of GET /webadmin/api/page-layouts' active handles — the legacy dashboard alias still normalizes to docs. Matches PageController::update()'s own contract: it does not mutate Page Slots on a plain layout change, so this endpoint doesn't either. Call sync-layout-slots separately if the new layout defines slots the page does not have yet.

Site Presentation Endpoint

POST /webadmin/api/sites/{site}/public-theme
PATCH /webadmin/api/sites/{site}/branding
PATCH /webadmin/api/sites/{site}/timezone
GET /webadmin/api/sites/{site}/assets/{css|js}
PUT /webadmin/api/sites/{site}/assets/{css|js}

This endpoint updates only the safe site public theme preset used by public rendering. Send public_theme_preset or theme with one of the supported Site::PUBLIC_THEME_PRESETS values such as canvas, atlas, pulse, prism, graphite, or horizon. Use this when API discovery shows a site rendering with the wrong data-wb-public-theme preset; do not try to override the preset with content blocks.

PATCH /webadmin/api/sites/{site}/branding requires site-settings.write and updates safe public site branding fields that are also visible in Sites -> Edit Site -> Branding:

  • display_name
  • tagline
  • favicon_media_id
  • social_image_media_id
  • Media replacement preserves the Media id and editorial references, clears obsolete image transforms, and derives future variants from the replacement binary.

The favicon and social image fields must reference image records from the CMS Media Library, and null clears the selected media. Public site favicon changes should use this endpoint so the result remains admin-editable. Do not overwrite /cms/brand/*; those files are CMS product/admin shell assets, not site-level public branding.

PATCH /webadmin/api/sites/{site}/timezone requires site-settings.write and sets a site's own timezone — a standard IANA identifier such as Europe/Berlin. Anything that resolves local wall-clock time for that site, such as a plugin's booking availability windows, is interpreted against this value. Send an empty timezone to clear it back to the install-wide system timezone from Admin -> System Settings.

GET /webadmin/api/sites/{site}/assets/{css|js} requires site-assets.read and reads the canonical physical site-level override file. It returns relative_path, public_path, exists, contents, checksum, size, updated_at, readiness, guidance, and CSS analysis without exposing the server absolute path. The readiness object reports whether the site directory, asset directory, and file are writable enough for CMS to create or update the asset. The guidance object tells AI/operator tools to keep CSS token-first, mode-aware, and native-block-first. For CSS, asset.analysis.mode_awareness reports status, warning messages, detected anti-pattern keys, signals such as literal color count, and recommended --wb-public-* tokens. PUT /webadmin/api/sites/{site}/assets/{css|js} requires site-assets.write and accepts:

  • contents
  • expected_checksum

The write endpoint uses the same physical-file store as Sites -> Edit Site -> Assets. It writes only public/site/{site_handle}/css/site.css or public/site/{site_handle}/js/site.js, creates missing directories on first save, rejects stale expected_checksum values, and stores a pre-overwrite revision snapshot before replacing an existing changed file. There is no database fallback route and no arbitrary /site/... path writer. If hosting permissions prevent directory creation or file writes, the endpoint returns JSON 422 with errors.0.path = asset.write and the current readiness metadata instead of an HTML server error.

For CSS writes, use native block structure/settings, Media Library background fields, public theme tokens, and inherited WebBlocks UI component styles before adding custom selectors. If custom colors are unavoidable, define semantic custom properties with light and dark values tied to active mode selectors or public theme tokens so public pages remain coherent in Light/Dark/Auto mode. Avoid page-wide hard-coded light backgrounds, dark text, white cards, or one-off dark-mode palettes when public theme tokens can express the design. PUT does not reject mode-awareness warnings because some brand systems need intentional custom colors, but tools should treat asset.analysis.mode_awareness.status = warning as work to review, report, or fix before considering a migration or new site setup complete.

Comments are stripped before analysis: a stylesheet that documents itself by quoting the rule it repairs does not report the quoted color as one of its own, and a dark-mode scope named only in a comment does not count as present.

The anti_patterns checks are about page-wide paint, so each one requires its token to be the last compound of the selector and to end where the name ends: body { background: #fff } is reported, body .promo { background: #fff } and .wb-public-body { ... } are not, and .wb-card-title is not read as .wb-card. A rule that paints one element is left to the literal-color signals rather than reported as a page-wide anti-pattern.

asset.analysis.mode_awareness is intentionally a heuristic contract, not a CSS linter. It reports pass or warning, warnings, detected anti_patterns, simple signals such as literal color declarations, uses_public_theme_tokens, and has_dark_mode_scope, plus the recommended --wb-public-* token roles. Tools should use this signal to catch likely mixed-mode regressions early and still rely on visual QA for final design parity.

Embedded Application Assets API

Application code should not be merged into a site's global site.css or site.js. A registered Embedded Application can own physical CSS, JavaScript, and one managed index.html entry under its site-scoped public directory:

GET    /webadmin/api/sites/{site}/applications/{application}/assets
GET    /webadmin/api/sites/{site}/applications/{application}/assets/{css|js|html}/{filename}
PUT    /webadmin/api/sites/{site}/applications/{application}/assets/{css|js|html}/{filename}
DELETE /webadmin/api/sites/{site}/applications/{application}/assets/{css|js|html}/{filename}
POST   /webadmin/api/sites/{site}/applications/{application}/package

CSS and JavaScript use the deterministic public path /site/{site_handle}/applications/{application_handle}/{type}/{filename}. The HTML type accepts only index.html, stores it at the application root, and returns the host-selected stable public path /webblocks-applications/{application_handle}/index.html. Writing this managed entry switches the definition to iframe mode and activates that URL. GET, PUT, and DELETE require applications.read, applications.write, and applications.delete respectively. Filenames are basenames only and must end in the selected .css, .js, or .html extension. HTML accepts no filename other than index.html; traversal, nested paths, other file types, and remote URLs are rejected.

PUT accepts contents and expected_checksum. Send null only when creating a missing file; read an existing file first and send its checksum when replacing it. Replacements and deletions preserve revision copies. Delete is refused while the Embedded Application definition still references the asset's public path. After writing an asset, use its returned public_path in css_assets or js_assets; the Application Block then loads it only where the application is placed.

Complete applications should use the package endpoint instead of writing files one by one. It requires applications.write and multipart/form-data with one package ZIP field (maximum upload size 50 MB). First increment the registered application's version with PATCH /webadmin/api/applications/{application}; then upload a ZIP containing index.html at its root and use relative asset URLs inside it. A successful response returns package version, activated immutable entry_url, file count and expanded byte size, plus the refreshed application definition. Installed versions cannot be overwritten. Validation and archive safety failures return structured JSON 422 errors under errors[0].path = application_package (or application_package.package for the multipart field).

Content Validate / Apply Endpoints

  • POST /webadmin/api/content/validate
  • POST /webadmin/api/content/apply

Content plans may assign already-uploaded CMS Media Library records to native media-backed blocks. Use media_id or asset_id on image, navbar-brand, sidebar-brand, file, download, and video blocks with media of the matching kind. Use media_id or asset_id on hero, section, card, cta, and content_header when the media is the block background image, with optional settings.background_position and settings.background_overlay. Use gallery_items or gallery_media_ids on gallery blocks with image media records; the API preserves the submitted Gallery item order when writing block_media.position. For card content images, create the normal nested structure, such as card -> card_body -> image, and put media_id on the child image block. Content plans still reject remote_url and source_url; upload files through POST /webadmin/api/media or import one approved public file URL through POST /webadmin/api/media/fetch first, then assign the returned media.id.

Content Plan Safety

Content plans use explicit draft or staged-update modes. Validate a plan before applying it and keep publication as a separately authorized operation. Existing live pages require an owned staged update rather than direct replacement.

A plan may refer to approved Media Library records. Remote fetching, uploads and other resource operations use their dedicated endpoints and capabilities; declaring a content plan does not grant those permissions. Discover the current endpoints before making changes.

Bearer-token JSON writes use token authorization. The minimal unauthenticated discovery response does not grant content access. See Authentication and Capabilities above for the required scope.

JSON Error Shape

API errors are JSON-only. They must not redirect to login, render CSRF pages, or expose stack traces. Common fields:

{
  "ok": false,
  "code": "invalid_internal_api_token",
  "message": "Invalid internal API token.",
  "api_discovery_url": "/webadmin/api",
  "openapi_url": "/webadmin/api/openapi.json",
  "documentation_url": "/webadmin/api/ai-guide",
  "example_url": "/webadmin/api/examples/contact-page",
  "errors": []
}

Expected statuses:

  • 401 invalid_internal_api_token for missing, invalid, revoked, expired, or no-longer-user-backed tokens
  • 403 missing_internal_api_capability for missing capabilities
  • 403 delegated_site_access_denied when a personal token targets a site outside its live scope
  • 403 delegated_workflow_access_denied when the owner cannot edit the page in its current workflow state
  • 403 delegated_operation_denied for installation-level operations that reject personal tokens
  • 403 delegated_network_access_denied when the resolved IP is outside the personal token allowlist
  • 429 personal_api_token_rate_limit_exceeded when the personal ceiling is reached; clients must honour Retry-After
  • 422 for validation errors

Resource API Examples

Render System Updates Admin Snapshot

GET /webadmin/api/admin-render/system-updates
GET /webadmin/api/admin-render/system-updates?format=html

Requires admin.render and a token created by a user who can access System. The JSON response includes the rendered admin HTML for the allowlisted System Updates screen; ?format=html returns direct text/html so operator tools can load the snapshot into a local browser and capture screenshots for visual comparison. The endpoint is read-only and must not click, install, publish, or mutate update state.

List Pages

GET /webadmin/api/pages

Read Page Details

GET /webadmin/api/pages/{page}

Read Engagement Comments

GET /webadmin/api/engagement/comments?status=pending&per_page=25

Requires engagement.read. Supported filters are status, site_id, page_id, block_id, search, and per_page. Responses include comment body, status, spam score/reasons, source page/block references, and timestamps, but do not expose visitor hashes, IP hashes, or user-agent values.

Moderate Engagement Comment

PATCH /webadmin/api/engagement/comments/{comment}
Content-Type: application/json

{"status":"approved"}

Requires engagement.moderate. Supported statuses are pending, approved, rejected, spam, and hidden.

Read Engagement Ratings

GET /webadmin/api/engagement/ratings?block_id=123

Requires engagement.read. Supported filters are status, site_id, page_id, block_id, and per_page. Responses include rating rows plus total, average, and value-count summaries, but do not expose visitor hashes, IP hashes, or user-agent values.

List Blocks

GET /webadmin/api/blocks

Read Block Details

GET /webadmin/api/blocks/{block}

Content Validate / Apply Example

The same payload can be submitted to either endpoint:

POST /webadmin/api/content/validate
POST /webadmin/api/content/apply

Example English marketing homepage draft:

{
  "plan": {
    "site": "example-site",
    "locale": "en",
    "layout": "default",
    "page": {
      "title": "Acme Studio",
      "path": "/",
      "status": "draft"
    },
    "slots": {
      "main": [
        {
          "type": "section",
          "children": [
            {
              "type": "container",
              "children": [
                {
                  "type": "content_header",
                  "translations": {
                    "title": "Plan, build, and publish with confidence",
                    "subtitle": "Structured content for modern teams",
                    "content": "Create a draft homepage from a validated content plan."
                  }
                },
                {
                  "type": "cluster",
                  "children": [
                    {
                      "type": "button_link",
                      "translations": {
                        "title": "Start planning"
                      },
                      "settings": {
                        "url": "/contact",
                        "variant": "primary"
                      }
                    }
                  ]
                }
              ]
            }
          ]
        },
        {
          "type": "section",
          "children": [
            {
              "type": "container",
              "children": [
                {
                  "type": "grid",
                  "settings": {
                    "columns": 3
                  },
                  "children": [
                    {
                      "type": "card",
                      "children": [
                        {
                          "type": "card_body",
                          "children": [
                            {
                              "type": "plain_text",
                              "translations": {
                                "content": "Validate the whole draft before anything is written."
                              }
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ]
            }
          ]
        },
        {
          "type": "section",
          "children": [
            {
              "type": "container",
              "children": [
                {
                  "type": "cta",
                  "translations": {
                    "title": "Ready to shape the next page?",
                    "content": "Use structured plans for repeatable content creation."
                  }
                },
                {
                  "type": "cluster",
                  "children": [
                    {
                      "type": "button_link",
                      "translations": {
                        "title": "Contact us"
                      },
                      "settings": {
                        "url": "/contact",
                        "variant": "primary"
                      }
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  }
}

Validation Rules

  • plan fields the API does not understand are rejected with 422 and the stable code unsupported_plan_fields, and the error path names each one; the accepted key set is scoped to the plan mode, so a key that is meaningful in one mode is still rejected in another
  • site handle or ID must resolve
  • locale must exist and be enabled for the target site
  • layout must exist
  • path conflict blocks page creation
  • block type must be published and usable
  • child support must follow block contracts where available
  • content plan block trees must use nested children; flat id, parent_id, block_id, slot_type_id, and block_type_id relationship fields are rejected because the API owns database IDs and parent assignment
  • block translations must contain direct field keys for the selected plan locale, such as title, subtitle, and content; locale-keyed shapes such as translations.en.title are rejected instead of being silently ignored
  • wrapper blocks such as section, container, stack, split, cluster, grid, card, card_body, card_footer, sticky-navbar, and sidebar-navigation must include child blocks so they cannot validate as empty public chrome; split requires exactly two direct children, and either side may be a nested stack
  • user-facing text belongs in translation rows
  • shared settings remain shared
  • new or changed core settings must match the discovered per-block authoring schema; unknown fields and invalid values are rejected with field paths
  • historical unknown settings remain readable and survive unrelated updates; an exact unchanged value may accompany PATCH but does not authorize changing an undeclared field
  • apply validates again before writing
  • apply is transactional
  • Content apply still rejects publish, site creation, media import, remote fetch, unsupported overwrite, unsupported replace, and delete operations
  • Shared Slot creation remains create-only unless a later phase adds explicit draft-safe mutation contracts
  • navigation item resource endpoints support explicit update, visibility, reorder, and delete operations outside content apply with capability guards

Response Shape

Responses should be predictable JSON:

{
  "ok": true,
  "writes": [],
  "data": {
    "page": {
      "id": 123,
      "title": "Product Overview",
      "status": "draft",
      "edit_url": "/webadmin/pages/123/edit"
    }
  },
  "normalized_plan": {},
  "renderability": {
    "root_blocks": 3,
    "total_blocks": 12,
    "html_blocks": 0,
    "wrapper_blocks_without_children": 0,
    "text_blocks_without_visible_content": 0,
    "button_blocks_without_label_or_url": 0
  },
  "warnings": [],
  "errors": []
}

Validation errors should include a path and message:

{
  "ok": false,
  "writes": [],
  "data": null,
  "normalized_plan": {},
  "renderability": {
    "root_blocks": 2,
    "total_blocks": 3,
    "html_blocks": 0,
    "wrapper_blocks_without_children": 1,
    "text_blocks_without_visible_content": 0,
    "button_blocks_without_label_or_url": 0
  },
  "warnings": [
    {
      "path": "plan.slots.main.1.settings.theme",
      "message": "Unknown harmless setting ignored."
    }
  ],
  "errors": [
    {
      "path": "plan.page.path",
      "message": "A page already exists at this path for the selected site and locale."
    }
  ]
}

Include edit_url where useful for created or updated CMS resources.

renderability is a plan-sanity summary for AI/operator tools. A successful structured page plan should normally have wrapper_blocks_without_children, text_blocks_without_visible_content, and button_blocks_without_label_or_url at 0. html_blocks above 0 means the plan used the Trusted HTML escape hatch; tools should do that only with explicit operator approval and only when first-class block types cannot represent the content.

Additional Content Plan Sections

Content plans may include navigation_menus, shared_slots, and page_slot_shared_slots alongside the existing page/slot plan. validate writes nothing. apply writes all valid sections in one transaction and rolls the full plan back when any later section fails.

{
  "plan": {
    "site": "default",
    "locale": "en",
    "layout": "default",
    "page": {
      "title": "Homepage Draft",
      "path": "/",
      "status": "draft"
    },
    "slots": {
      "main": []
    },
    "navigation_menus": [
      {
        "handle": "primary",
        "label": "Primary Navigation",
        "items": [
          {
            "label": "Home",
            "url": "/",
            "target": "_self",
            "sort_order": 10
          },
          {
            "label": "About",
            "link_type": "page",
            "page_id": 9
          },
          {
            "label": "Docs",
            "link_type": "group",
            "children": [
              { "label": "Intro", "url": "/docs/intro" },
              { "label": "Guide", "url": "/docs/guide" }
            ]
          }
        ]
      }
    ],
    "shared_slots": [
      {
        "handle": "site-header",
        "label": "Site Header",
        "slot": "header",
        "blocks": []
      }
    ],
    "page_slot_shared_slots": [
      {
        "page": "created",
        "slot": "header",
        "shared_slot": "site-header"
      }
    ]
  }
}

Navigation items carry link_type: custom_url (the default, requires a safe internal path or http(s) URL), page (requires page_id for a page on the same site; the label falls back to the page name), or group (a label that opens its children and carries no URL). Only a group accepts children, which are created with their parent_id already linked, so a dropdown is one plan rather than a POST plus a PATCH per item. Content plans nest one level; deeper trees use POST /navigation-menus/{menu}/items. An unsupported link_type is refused by name instead of being coerced to custom_url.

page_slot_shared_slots[].page may refer to the page created by the same plan by using created, or to an existing page ID. shared_slot may refer to a Shared Slot created earlier in the same plan or an existing same-site Shared Slot handle.

Content plans save one completed page version after all block writes and Shared Slot assignments, and one completed version per newly created Shared Slot. Existing-page assignment plans save one pre-change safety version per affected page. Slot replacement and staged promotion retain their explicit safety checkpoints. An unchanged consecutive capture reuses the latest complete snapshot and records a separate capture event; independent HTTP requests remain separate operations. Version detail reads include bounded inspection.capture_history containing recent capture events and a has_more flag. See Revisions.

Optional Mobile Images (1.91.0)

slide, image, hero, section, card, cta, content_header, and link-list-item accept an optional top-level mobile_media_id containing an image Media Library id in content plans and PATCH /blocks/{block}. The relationship is stored in block_media with role mobile_image and is shared across locales. Omit the field in PATCH to preserve it; send null to clear it.

At viewport widths up to 768 px, a public mobile image replaces the default image. If it is absent, deleted, or private, the renderer keeps the default. Foreground images use a responsive picture source; background blocks switch their CSS background. Use a different crop of the same visual because alt text, caption, position, fit, overlay, and link remain shared. The Image block's Gallery Viewer still opens the default full-resolution image. Gallery items, brand logos, video, and audio do not accept this field.

{
  "locale": "en",
  "mobile_media_id": 456
}

Slide and Slider settings.content_position support nine values from 1.91.1: center, center-left, center-right, top-left, top-center, top-right, bottom-left, bottom-center, and bottom-right.

AI Usage Guidance

  • discover sites, locales, layouts, and block types first
  • validate before apply
  • create draft content
  • prefer structured blocks from docs/public-block-render-markup.md
  • avoid Safe HTML except as a reviewed fallback
  • keep generated public copy in the target language, such as English for an English homepage

Boundaries

  • no OpenAI or LLM integration in CMS core
  • no crawling or fetching
  • no arbitrary import/export replacement
  • no automatic publish
  • no destructive delete in Phase 1
  • no host /admin route assumption
  • no /cms route prefix use
  • no QuizTem-specific runtime code; QuizTem homepage generation is a later consumer use case for this generic CMS API

Installation-Wide System Health

Since CMS 1.98.0, GET /webadmin/api/system/health returns { "ok": true, "health": { ... } } through the explicit system-health.read capability. Only an unrestricted system token with an active creator who currently has System access is accepted. Personal, revoked, expired, and site-scoped tokens are excluded; the capability is not included in the default content-authoring set.

Optional site_id must identify an existing site. It filters site, search, and scheduled-work checks while installation-wide checks remain in the response. The report includes checked_at, scheduler_checked_at, live scheduler evidence, cache_seconds (300), selected_site_id, site_options, sites, categories, counts, severity-ordered issues, System Information information, and recent operations. Check statuses are healthy, warning, critical, unknown, and not_applicable. Checks expose a stable code, controlled translation message key and parameters, plus admin route and route_parameters for follow-up.

The scheduler object contains status, scheduler_status, notification_status, UTC last_seen_at, last_completed_at, and last_failed_at timestamps, stale_after_seconds (300), and required derived from the filtered sites. The read reuses the five-minute overview snapshot and reads recorded scheduler evidence live. It does not contact the release server or execute maintenance. Missing evidence is reported as unknown or not applicable according to whether the site requires scheduling. Archive availability does not certify restore integrity; search coverage does not certify indexed text freshness. Operation outcomes omit logs, raw summaries, archive paths, and exception text. See Operations for the operator-facing overview.

Installation-Wide System Updates

Since 1.90.0, explicitly granted system-updates.read and system-updates.run capabilities expose update checks, synchronous approved execution and durable operation results. Personal and site-scoped tokens are excluded. Follow the System Updates API contract for exact version/checksum approval, idempotency and connection-loss recovery.

Shared Site Message Notification Policy (CMS 1.95.0)

GET /webadmin/api/sites/{site}/notifications requires content.read and returns the resolved notification_settings, aggregate delivery_status for message events in the last thirty days, and latest summary_delivery_status outcomes. Every request enforces token site restrictions; delegated tokens also retain their owner’s current site access.

PATCH /webadmin/api/sites/{site}/notifications requires site-settings.write. This is an operator setting, independent of block content editing. It merges supplied fields, preserves omitted settings and rejects unknown fields, nulls and unsupported values.

{
  "notification_mode": "alert_only",
  "notification_frequency": "batched",
  "batch_minutes": 10,
  "daily_summary": true,
  "summary_hour": 9
}

Supported values are full/alert_only and immediate/batched/daily; batch intervals are 1–60 minutes and daily hours are 0–23 in the site timezone. Alert-only notifications exclude visitor-derived content, addresses, subjects, headers and attachments. Daily summaries always contain counts and protected inbox links only. The host Laravel scheduler processes batches and summaries every minute; notifications do not grant access to the inbox.

Live Chat 0.7.0 exposes the inherited policy as notification_policy on settings reads and writes. Configure its notify_offline_messages and notification_email through the plugin settings API; change privacy and frequency through the shared site endpoint. Attempts to override the shared policy through the plugin endpoint are rejected. Other enabled plugins can integrate through the product-owned SiteNotificationChannel adapter contract.

Scheduler Health (CMS 1.95.1)

The notification-policy GET response also includes scheduler_health, with status, scheduler_status, notification_status, last_seen_at, last_completed_at, last_failed_at, stale_after_seconds (300), and the site-policy-derived required boolean. States distinguish healthy, unverified, delayed, running, failed and unavailable. Timestamps are UTC and only contain execution evidence; API reads do not generate a heartbeat or change settings. Scope and capability checks are unchanged.

A recent scheduled callback and completed notification run are required for verified healthy status. Manual dispatch is recorded separately and cannot certify scheduling. Dashboard/Site settings warn when the site's notification policy requires scheduling but the recorded runtime is unverified, delayed, failed or unavailable. Live Chat 0.7.1 settings responses and plugin health use the same recorded evidence. General setup requirements and server-administrator ownership are described in Installation.

Per-block authoring settings schemas

For the complete block catalog, storage ownership and renderer contracts, see Block Type Contracts. Schemas complement the CMS Inventory; they do not grant access or make a human-only block API-writable.

From CMS 1.105.9, block-type discovery and content-contract entries include an additive authoring_settings_schema object. Existing settings_schema payloads retain their earlier shapes. Schema revision 1 covers all published core blocks and exposes mode, unknown_fields, and fields. Each field describes its type, enum/range/length constraints, null/empty clearing semantics, omitted-field preservation on update, and separate writes.admin, writes.create, writes.patch decisions with any closed_reason.

Read those operation flags before writing. A field accepted when creating a block is not automatically writable through PATCH. Contact delivery/privacy fields retain their deliberate existing PATCH exclusions, HTML remains human-only, and media references use their dedicated relationship fields. Field schemas grant no permissions and do not query external content providers.

Content plans and slot-block POST reject unknown keys and invalid supplied values before writing. PATCH validates only changes and merges them with current settings. A misspelled aligment is rejected instead of accepting a value that has no visible effect; an invalid alignment value is rejected instead of silently clearing the existing choice. Missing values do not insert defaults during updates. Declared compatibility aliases remain supported: Slider fade normalizes to slide, Gallery masonary to masonry, and existing Contact Form copy keys in creation settings are consumed by the locale translation writer. PATCH uses translations for that copy. Integer values are not silently clamped and overlong text is not silently truncated. Existing creation defaults such as Columns plain and Split stack remain.

Historical unknown or malformed values remain readable, render with existing fallbacks and survive unrelated updates. A client may carry an identical historical field only when the server can compare it with that exact existing block; this does not authorize adding or changing unknown settings. New blocks and full replacement payloads are new authoring, so they cannot claim a historical-value exception. Trusted cloning, staging and revision restoration retain their lossless storage paths.

Plugins without schemas keep their own existing settings behavior. Plugins may opt in through the block definition or manifest with scalar rules; an invalid declaration blocks writes for that schema without blocking unrelated core blocks. Application settings, active icons and Dynamic Content bindings continue using their specialized validators after the common field check.

Server operators can run php artisan webblocks:blocks:audit-settings for a read-only compatibility report before authoring existing content. It prints identities, field paths and codes rather than settings values, scans without repairs or provider queries, and exits 1 if incompatible settings exist. --limit=100 bounds reported blocks (maximum 1000); the summary still counts the full scan. This checks scalar schema compatibility, not external provider or application health. Schema-less plugins are counted separately. New changed declared settings are bounded to 128 fields, 256 KiB, 32 nested levels and 4096 nodes without applying those limits retrospectively to unchanged historical data.