Skip to content
MCG Filament CMS Demo

Delivery API

This document explains the package's delivery API and its webhooks. The delivery API is a read-only JSON API over the site's content, for a frontend application that renders its own components. The webhooks notify that frontend when content changes and a rebuild is required. The document covers enabling the API, authentication, the endpoints and their parameters, the block payload, draft access, CORS, headless mode and webhooks. It is written for the developer building the consuming application and for the developer deciding whether to enable the API.

The API returns each block as its stored data, not as rendered markup. The consuming application decides how each block type, such as hero, is rendered.

---

Enabling the API

The API is disabled by default. An HTTP interface to the site's content is enabled per environment, not by the code. The MCP web endpoint is also disabled by default, for the same reason.

One-time setup

  1. In a terminal at the site's root, confirm that laravel/sanctum is installed, and install it if it is not:

Shell

composer show laravel/sanctum

The default middleware list includes auth:sanctum. See Sanctum for the alternatives.

  1. In the site's .env file, enable the API:

Plain text

CMS_API_ENABLED=true
  1. In the same terminal, clear the cached configuration:

Shell

php artisan config:clear
  1. Mint a token for the consuming application, as described in Minting a token.

The api group in config/cms.php holds these settings:

PHP

// config/cms.php
'api' => [
    'enabled' => (bool) env('CMS_API_ENABLED', false),
    'prefix' => env('CMS_API_PREFIX', 'api/cms/v1'),
    'middleware' => ['api', 'auth:sanctum'],
    'draft_guard' => env('CMS_API_DRAFT_GUARD', 'sanctum'),
    'origins' => array_filter(explode(',', (string) env('CMS_API_ORIGINS', ''))),
],

The routes use Laravel's api middleware group, which starts no session, performs no CSRF check, and applies the request throttling a public endpoint requires.

Sanctum

The default middleware list adds auth:sanctum to the api group, so every request must carry a bearer token, including requests for content that is already public on the site. The token does not keep the content secret. It identifies the caller, so that one caller's access can be revoked without affecting the others.

The auth:sanctum guard requires laravel/sanctum in the host application. Sanctum is not a dependency of this package. Three features use it:

  1. the delivery API
  2. the MCP HTTP transport
  3. the API Tokens screen in the panel, which is registered only when the class Laravel\Sanctum\Sanctum exists

Every other feature works without Sanctum.

An installation that authenticates API callers another way replaces the middleware list. Removing auth:sanctum makes published content readable by anyone. It does not make drafts readable, because draft access is checked separately against draft_guard; see Drafts and unpublished content.

The route file routes/cms.php reads config('cms.api.enabled', false), so its fallback matches the config default. The fallback was previously true. That value applied only where the api key was absent entirely, which happens with a config file published before the api group existed, because mergeConfigFrom does not merge into a key the host's file already contains. On those installations the API was enabled with auth:sanctum in front of it, and every request returned a 500 error from a guard the application did not define.

Minting a token

A token can be minted in the panel or in a terminal.

In the panel:

  1. In the admin panel, open Content Manager β†’ API Tokens.
  2. Choose New token.
  3. In the New API token modal, enter a value in the Label field that names the machine or client that will use the token, then confirm.
  4. The modal Copy this token now shows the token once. Copy it.

In a terminal at the site's root:

Shell

php artisan cms:mcp-token you@example.com --name=frontend

The first argument is the id or email address of the user the token acts as. --name labels the token so it can be identified and revoked individually; its default is mcp. --revoke deletes the user's existing tokens with the same name before issuing the new one.

Either way, the token is shown once. Sanctum stores only a hash of it, so a lost token cannot be recovered and is replaced by revoking it and minting a new one.

The panel has no field for minting a token on behalf of another user. A token carries exactly the permissions of the user it belongs to, so a token minted for oneself grants nothing that signing in does not already grant. A token minted for another user grants that user's permissions to the person who minted it, so that operation is available only through the console command, which requires access to the server.

---

Endpoints

Every path below is relative to cms.api.prefix, which defaults to api/cms/v1.

Method Path Returns
GET /content Published, routable content, paginated
GET /content/{path} One page. ?version=draft returns the newest version
GET /navigation The page tree
GET /search?q= Search results
OPTIONS CORS preflight

The /content/{path} route matches any path, including paths containing /. It is declared last in the route group, so it does not take precedence over /navigation or /search.

Query parameters

Endpoint Parameter Behavior
/content type A content type key. When absent, only routable types are returned
/content parent The parent's id or its path. A path is accepted because a decoupled frontend knows the URL it is rendering and not the UUID of the page behind it
/content pathprefix Returns every page whose path is under the given path
/content locale Filters by locale. When absent, every locale is returned
/content page, perpage perpage defaults to 25 and is clamped to 1–100
/content/{path} locale Required when the path exists in more than one language
/content/{path} version draft returns the newest version, published or not
/navigation root, current root is the id of the page the tree starts from. current is the path of the page being rendered, which marks the current and active entries
/navigation depth Clamped to 1–6. Default 3
/search q, locale The search text, and an optional locale filter
/search limit Clamped to 1–50. Default 20

per_page is capped at 100 so that one request cannot fetch every page of the site. Collections are returned in Laravel's standard paginated resource shape, with the keys data, links and meta.

When a path exists in more than one language and the request has no locale, /content/{path} returns 409 Conflict with the message "That path exists in more than one language (…). Pass ?locale= to choose one.", listing the locales found. See Multilingual sites.

Unroutable content types are excluded by default. A snippet has no URL, and a frontend cannot link to it. A request that names the type explicitly still returns those rows. A snippet can therefore be used as shared data, such as a footer call to action fetched once rather than repeated on every page:

Plain text

GET /api/cms/v1/content?type=snippet

---

The block payload

JSON

{
  "type": "rich_text",
  "region": "main",
  "data": { "body": "<p>…</p>", "width": "normal", "custom_classes": [] }
}

Each block is passed through its own cast() method before it is returned. cast() merges the block's defaults() and migrates any older stored shape, so a consumer receives the fields the block declares in the installed version of the package, not the fields that were stored when the version was saved. The MCP tool cms_describe_block publishes the same field contract; see MCP.

A block whose type is not registered, for example because the block was disabled, is returned unchanged rather than removed. VersionManager stores unknown block types the same way. Disabling a block therefore does not make a page unreadable through the API.

The page object contains id, type, slug, path, parent_id, sort, is_homepage, title, attributes, seo, blocks, published_at and updated_at. When multilingual content is enabled, it also contains locale and translations. When multilingual content is disabled, both fields are omitted, so a single-language consumer receives the same payload as before the feature existed. See Multilingual sites.

---

Drafts and unpublished content

GET /content/{path}?version=draft returns the newest version of the page, whether or not it is published. It is used to preview unpublished work in a decoupled frontend.

A caller that is not authenticated for drafts receives 404 Not Found, not 403 Forbidden. A 403 confirms that the page exists, and an unpublished page's existence is itself information: an acquisition announcement is confidential until the day it is published, and its URL is usually guessable. The API returns the same 404 for each of these cases, so the response does not reveal which one applies:

  • the path does not exist
  • the page has no versions
  • the caller has no token, or is not authenticated on draft_guard

Draft access is checked against the guard named in cms.api.draft_guard, not against the route middleware. A host application without Sanctum has no sanctum guard, and calling Auth::guard() with an undefined guard throws an exception, which would turn a refusal into a 500 error. When the guard is not defined in auth.guards, the API treats the caller as not authenticated and returns the same 404 a request without a token receives. An undefined guard therefore neither grants access nor causes an error.

---

CORS

Only browsers enforce CORS. A server-rendered frontend, curl and Postman ignore it, so the CORS configuration does not control access to content. It determines which other sites' JavaScript, running in a visitor's browser, can read the API responses.

Allowed origins are managed in two places, and both lists are used:

  1. In the admin panel, on Content Manager β†’ Site settings, in the API access section, under Allowed origins. Each row holds one origin, entered as a scheme and host with no trailing slash, for example https://example.com.
  2. In config/cms.php, in cms.api.origins, or in the CMS_API_ORIGINS environment variable as a comma-separated list. This list is for an installation that keeps its allowed origins in version control.

The request's Origin header is compared with each entry case-insensitively and otherwise exactly, so an entry with a trailing slash does not match.

The default is an empty list, which allows no cross-origin browser access. The package does not default to Access-Control-Allow-Origin: *. The endpoint accepts a bearer token, and a wildcard on an endpoint that accepts credentials lets any page a visitor opens read responses made with that visitor's token.

The CorsForApi middleware answers a preflight OPTIONS request itself with status 204, rather than passing it to the router, because OPTIONS never matches a GET route and would otherwise return 405 Method Not Allowed.

CorsForApi runs before the middleware listed in cms.api.middleware. A preflight carries no bearer token, so it has to be answered before auth:sanctum runs; a 401 or 429 response from the configured middleware also receives the CORS headers, so the frontend can read the error. Before version 0.78.16, CorsForApi ran last, and behind auth:sanctum every preflight received a 401 without CORS headers.

Laravel's own CORS middleware

Laravel registers its own global HandleCors middleware, configured by config/cors.php. The framework's default cors.paths is ['api/*', 'sanctum/csrf-cookie'] and its default allowed_origins is ['*']. The delivery API's default prefix, api/cms/v1, matches api/*.

When the delivery API is enabled, the package registers a HandleCors::skipWhen() callback for requests under cms.api.prefix (FilamentCmsServiceProvider::skipLaravelCorsForTheApi()). Laravel's middleware then leaves those requests alone, and CorsForApi alone decides which origins receive CORS headers. Other paths in cors.paths, such as the host application's own api/* routes, are unaffected.

Before version 0.79.0 the package registered no such callback. On a site with Laravel's default CORS configuration, Laravel answered every delivery API preflight with Access-Control-Allow-Origin: * and replaced the origin CorsForApi set on every other response, so the origin list had no effect.

CorsForApi compares origins exactly and has no wildcard. A frontend that relied on Laravel's * loses cross-origin access after the update to 0.79.0 until its origin is added to the list:

  1. In the admin panel, open Site settings.
  2. In the API access section, under Allowed origins, choose Add an origin and enter the frontend's scheme and host with no trailing slash, for example https://www.example.com.
  3. Save the settings.

When the origin is allowed, the response carries these headers:

Header Value
Access-Control-Allow-Origin The request's origin
Vary Origin
Access-Control-Allow-Methods GET, OPTIONS
Access-Control-Allow-Headers Authorization, Content-Type, Accept
Access-Control-Max-Age 86400

When the origin is not allowed, none of these headers is added. Vary: Origin is required because the response differs per origin. Without it, a shared cache in front of the site stores the response for one origin and serves it to another.

---

Headless mode

Headless mode turns an installation into a content source with no public website: the API is enabled and the public frontend routes are disabled.

  1. In the site's .env file, set both values:

Environment file

CMS_API_ENABLED=true
CMS_FRONTEND_ENABLED=false
  1. In a terminal at the site's root, run php artisan config:clear.

CMS_FRONTEND_ENABLED is read by route.frontend in config/cms.php:

PHP

// config/cms.php
'route' => [
    'frontend' => (bool) env('CMS_FRONTEND_ENABLED', true),
],

With route.frontend set to false, the package does not register the page catch-all route, the sitemap, robots.txt, the feed or the tag archives. The package claims no public URL, and the API continues to respond.

Preview is not affected by route.frontend. Preview renders through this package's own layouts and skins, so an editor can preview a page even when several different sites consume the same content and there is no single external frontend to send the preview to.

---

Webhooks

A webhook is an HTTP POST the package sends to an external URL when content changes, so that a statically built site rebuilds the affected pages. Without webhooks, a statically built site continues to serve the previous version of a page after it is published.

Webhook endpoints are managed in two places, and both lists are used:

  1. In the admin panel, on Content Manager β†’ Site settings, in the Webhooks section, under Endpoints. Choose Add a site for each receiving site. Each row has the fields Site (a label), URL, Signing secret (optional) and Enabled. A row with Enabled off receives nothing.
  2. In config/cms.php, in cms.webhooks.urls (the CMS_WEBHOOK_URLS environment variable, comma-separated), signed with the single secret in cms.webhooks.secret (CMS_WEBHOOK_SECRET).

The panel is the usual place to add an endpoint, because adding a receiving site through the panel requires no deploy.

Webhooks are sent only while cms.webhooks.enabled is true, which is the default. When it is false, the service provider does not subscribe the NotifyWebhooks listener, and no job is queued.

Event Sent when
content.published A version is published
content.unpublished A page is unpublished
content.moved A page's path changes. The payload also carries from, to, fromurl and tourl
content.deleted A page is soft-deleted, or a page that was not soft-deleted first is deleted permanently. Permanently deleting a page that is already soft-deleted sends nothing further
content.restored A soft-deleted page is restored, from the panel's Restore action or cmsrestorecontent, and when an earlier version is copied forward as a new draft

content.moved is sent once for each page whose URL changed. Renaming a parent page therefore sends one content.moved for the parent and one for each descendant, so the receiver can rebuild every affected URL.

The payload

The payload identifies what changed but does not contain the page's content. The receiver calls the delivery API for the current version, so a notification that arrives late or out of order still produces the correct result. The payload does carry the change's provenance: the user, the time, the version and the URL.

JSON

{
  "id": "d3b07384-1111-2222-3333-444455556666",
  "event": "content.published",
  "occurred_at": "2026-08-13T16:35:01+00:00",
  "actor": { "id": "15", "name": "Joe McGarvey" },
  "content": {
    "id": "019ff196-3d63-73be-baeb-c0a03d0bac18",
    "type": "page",
    "slug": "about",
    "path": "/about",
    "url": "https://example.com/fr/a-propos",
    "locale": "fr",
    "title": "About Us",
    "is_homepage": false,
    "published_at": "2026-08-11T16:09:43+00:00",
    "version": { "id": "019ff76f-…", "number": 3 }
  }
}
Field Meaning
id A UUID generated once per event and sent unchanged to every endpoint. A receiver uses it to discard a retry it has already processed, and two receiving sites use it to correlate the same change. It is also sent in the X-Cms-Delivery header, alongside X-Cms-Event, so a receiver can route or deduplicate a delivery before parsing the body
content.url The page's absolute public URL, including the locale prefix. Use it to purge or revalidate a page
content.path The page's stored path, which never includes the locale prefix. It identifies the page and is not its address
content.version The id and number of the page's published version. A receiver uses it to distinguish a new change from a delivery it has already handled, and to ignore a delivery that arrives after a newer one. It is null in a content.unpublished payload, because the page no longer has a published version
actor The id and name of the user recorded in the page's publishedby column

url and path are different values. In the example above, path is /about and url is https://example.com/fr/a-propos. A receiver that builds a URL from path on a multilingual site purges the wrong URL. Use content.url.

from_url and to_url on content.moved are absolute URLs built from the stored paths from and to with the page's locale prefix added, the same way content.url is built. Before version 0.78.16 they were built without the prefix and were correct only for pages in the default locale.

actor is read from content.published_by, which VersionManager::publish() sets to the publishing user on every publish. No other operation changes that column. In a content.published payload, actor is the user who published. In every other event's payload, actor is the user who last published the page, not the user who unpublished, moved, deleted or restored it. Both fields are null when the page has never been published, or when it was published with no authenticated user. The payload contains the name and id only, not the email address, so that a personal detail is not sent to every endpoint on every publish. A receiver that needs the email address can look it up by the id.

Every field the earlier payload shape contained is still present at the same location, so a receiver written against the earlier shape continues to work.

Signing and delivery

When an endpoint has a signing secret, the request carries an X-Cms-Signature header containing the HMAC-SHA256 of the raw request body, computed with that endpoint's own secret. Because each endpoint has its own secret, one site's secret leaking does not let anyone forge deliveries to another site. An endpoint with no secret cannot verify that a request came from the CMS, so anyone who knows its URL can trigger its cache purges.

Delivery is queued: the NotifyWebhooks listener implements ShouldQueue. The queued job calls each endpoint in turn, with a timeout of cms.webhooks.timeout seconds per request (default 5). An exception raised for one endpoint, such as a connection failure or a timeout, is caught and logged as a warning with the message "CMS webhook failed", the URL, the event and the exception message, and the job continues with the next endpoint. A failed endpoint therefore neither prevents the other endpoints from being notified nor fails the publish that triggered it. An endpoint that responds with an HTTP error status, such as 500, does not raise an exception and is not logged.

The listener has queued its work only since version 0.25.0. It declared ShouldQueue from the start, but registered itself with $events->listen(Event::class, [$this, 'method']). Laravel's event dispatcher builds a queued listener only from a class name it can resolve later, so an array holding an object instance was wrapped in a closure and run inline. Each endpoint's HTTP timeout was added to the editor's save, which is the delay the queued listener was written to avoid. The listener now returns a subscribe() map, which gives the dispatcher [static::class, 'method'], a class name it can resolve.

Webhooks notify software. Notifications to people are a separate feature, described in Notifications, with a separate list of events: a rebuild runs on every change, while a message is sent only for the changes a person asked to be told about.

---

Reference

Key Default Effect
api.enabled false Registers the API routes
api.prefix api/cms/v1 URL prefix of every API route
api.middleware ['api', 'auth:sanctum'] Middleware on every API route. Replace it when the application does not use Sanctum
api.draftguard sanctum The guard checked for ?version=draft. A guard not defined in auth.guards is treated as not authenticated
api.origins [] Allowed browser origins, used in addition to the Site settings list
route.frontend true false removes every public route except the API
webhooks.enabled true Subscribes the webhook listener
webhooks.urls [] Endpoints set at deploy time, used in addition to the Site settings list
webhooks.secret null The signing secret for the endpoints in webhooks.urls
webhooks.timeout 5 Timeout per request, in seconds

A Postman collection covering every endpoint is kept in the repository at docs/filament-cms-api.postman_collection.json. It is not included in the published package.