The delivery API
This page 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 page 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.
- In a terminal at the site's root, confirm that
laravel/sanctumis installed withcomposer show laravel/sanctum, and install it if it is not. The default middleware list includesauth:sanctum. - In the site's
.envfile, setCMS_API_ENABLED=true. - In the same terminal, run
php artisan config:clear. - Mint a token for the consuming application, as described under Minting a token below.
The api group in config/cms.php holds these settings:
'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:
- the delivery API
- the MCP HTTP transport
- the API Tokens screen in the panel, which is registered only when the class
Laravel\Sanctum\Sanctumexists
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 below.
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
In the panel:
- In the admin panel, open Content Manager β API Tokens.
- Choose New token.
- 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.
- The modal Copy this token now shows the token once. Copy it.
In a terminal at the site's root:
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
The API has four read endpoints and a preflight route. Every path is relative to cms.api.prefix, which defaults to api/cms/v1. No endpoint writes.
| Method | Path | Returns |
|---|---|---|
| GET | /content | Published, routable content, paginated |
| GET | /content/{path} | One page. ?version=draft for 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 | path_prefix | Returns every page whose path is under the given path |
| /content | locale | Filters by locale. When absent, every locale is returned |
| /content | page, per_page | per_page defaults to 25 and is clamped to 1 to 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 to 6. Default 3 |
| /search | q, locale | The search text, and an optional locale filter |
| /search | limit | Clamped to 1 to 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.
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, such as ?type=snippet, 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.
The block payload
Each block in a page's blocks array carries its type, its region and its stored fields:
{
"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.
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
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 when the path does not exist, when the page has no versions, and when the caller has no token or is not authenticated on draft_guard, so the response does not reveal which case applies.
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:
- 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. - In
config/cms.php, incms.api.origins, or in theCMS_API_ORIGINSenvironment variable as a comma-separated list, 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 ['*']. With the API at its default prefix, api/cms/v1, every delivery API URL matches api/*, and Laravel's middleware, which runs before any route middleware, applies its own settings:
- It answers preflight requests itself, with
Access-Control-Allow-Origin: *, so they never reachCorsForApi. - On every other cross-origin request, it sets
Access-Control-Allow-Originto*after the response is built, replacing the valueCorsForApiset.
The origin list described above therefore has no effect on a site that keeps Laravel's default CORS paths.
To have the delivery API's origin list apply, take its prefix out of Laravel's CORS paths:
- In a terminal at the site's root, publish Laravel's CORS config if
config/cors.phpdoes not exist yet:
php artisan config:publish cors
- In
config/cors.php, remove'api/*'frompaths, or replace it with patterns that do not matchapi/cms/*. - In the same terminal, run
php artisan config:clear.
When the origin is allowed, the response carries these headers:
Access-Control-Allow-Origin: the request's originVary: OriginAccess-Control-Allow-Methods: GET, OPTIONSAccess-Control-Allow-Headers: Authorization, Content-Type, AcceptAccess-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.
- In the site's
.envfile, set both values shown below. - In a terminal at the site's root, run
php artisan config:clear.
CMS_API_ENABLED=true
CMS_FRONTEND_ENABLED=false
'route' => [
'frontend' => (bool) env('CMS_FRONTEND_ENABLED', true),
],
CMS_FRONTEND_ENABLED is read by route.frontend. 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:
- 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. Adding a site here requires no deploy.
- In
config/cms.php, incms.webhooks.urls(CMS_WEBHOOK_URLS, comma-separated), signed with the single secret incms.webhooks.secret(CMS_WEBHOOK_SECRET).
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. Five events are sent:
| 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, from_url and to_url |
| content.deleted | A page is soft-deleted |
| content.restored | A soft-deleted page is restored |
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.
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 carries the change's provenance: the user, the time, the version and the URL.
{
"id": "9d1f4c2a-6b0e-4e1a-9f7d-3c8b5a2e1d40",
"event": "content.published",
"occurred_at": "2026-08-13T21:00:00+00:00",
"actor": { "id": 3, "name": "Joe McGarvey" },
"content": {
"id": "019fe3ce-c962-7055-81ae-d3bad3eb1bf8",
"type": "page",
"slug": "about",
"path": "/about",
"url": "https://example.com/about",
"locale": "en",
"title": "About",
"is_homepage": false,
"published_at": "2026-08-13T21:00:00+00:00",
"version": { "id": "019ffd02-90b3-7237-947a-a233d24b6d59", "number": 12 }
}
}
idis 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 theX-Cms-Deliveryheader, alongsideX-Cms-Event, so a receiver can route or deduplicate a delivery before parsing the body.content.urlis the page's absolute public URL, including the locale prefix. Use it to purge or revalidate a page.content.pathis the page's stored path, which never includes the locale prefix. A receiver that builds a URL frompathon a multilingual site purges the wrong URL.content.versionholds 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 isnullin acontent.unpublishedpayload, because the page no longer has a published version.actorholds the id and name of the user recorded in the page'spublished_bycolumn.
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 can look the email address 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.
| 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.draft_guard | 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 |