shazzoo / content-catalog-plugin
Authenticated API access to Content Studio block definitions and page content.
Package info
github.com/Shazzoo/content-catalog-plugin
Type:content-studio-plugin
pkg:composer/shazzoo/content-catalog-plugin
Requires
- php: ^8.3
- shazzoo/content-studio-core: ^1.2
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-02 11:38:20 UTC
README
A Content Studio plugin that gives external tools (an AI agent, an import script) authenticated JSON access to a site's blocks, pages and plugin data. It can read everything below and write pages and plugin resources.
Setup
- Install the package and activate Content Catalog API under Plugins in the admin. Activating runs the migrations.
- Open Plugins → Content Catalog API, turn the API on and rotate an API key. The key is shown once; only a hash is stored.
- Optionally set Turn off after a number of hours. The API switches itself off when that time has passed.
The same page lists every request: time, operation, the last four characters
of the key, IP address and the purpose the caller gave.
Authentication and limits
Send the key as a bearer token:
GET /api/content-catalog HTTP/1.1 Authorization: Bearer <api key> Accept: application/json
- While the API is off or has no key, every endpoint returns
404. - A missing or wrong key returns
401. - Each IP address gets 60 requests per minute. After that the API returns
429. - Responses carry
Cache-Control: no-store, private.
Every request may include a purpose (query string for GET, body for
writes). It ends up in the request log and helps you tell callers apart.
Endpoints
| Method | Path | What it does |
|---|---|---|
GET |
/api/content-catalog |
Blocks, templates, pages and plugins in one response |
GET |
/api/content-catalog/blocks |
Block catalog: every block type and its fields |
GET |
/api/content-catalog/templates |
Page templates of the active theme and their settings fields |
GET |
/api/content-catalog/templates/{key} |
One template |
GET |
/api/content-catalog/pages |
All pages with their blocks |
GET |
/api/content-catalog/pages/{id} |
One page |
POST |
/api/content-catalog/pages |
Create a page |
PUT |
/api/content-catalog/pages/{id} |
Replace a page (full payload) |
PATCH |
/api/content-catalog/pages/{id} |
Update the fields you send |
GET |
/api/content-catalog/plugins |
Active plugins with their blocks and resources |
GET |
/api/content-catalog/plugins/{plugin} |
One active plugin, by slug |
GET |
/api/content-catalog/plugins/{plugin}/resources/{resource} |
All records of a resource |
GET |
/api/content-catalog/plugins/{plugin}/resources/{resource}/{id} |
One record |
POST |
/api/content-catalog/plugins/{plugin}/resources/{resource} |
Create a record |
PUT |
/api/content-catalog/plugins/{plugin}/resources/{resource}/{id} |
Replace a record (full payload) |
PATCH |
/api/content-catalog/plugins/{plugin}/resources/{resource}/{id} |
Update the fields you send |
POST |
/api/content-catalog/media |
Upload an image to the media library |
Every response wraps its payload in data. Lists are not paginated, except resources declared with per_page.
Blocks
GET /blocks returns each block type with its fields:
{
"data": [
{
"type": "cases.cases",
"label": "Cases (plugin)",
"group": "Plugins",
"fields": [
{ "name": "source", "type": "select", "required": false, "default": "all",
"options": { "all": "Alle gepubliceerde cases", "categories": "Cases uit gekozen categorieën", "selected": "Zelf gekozen cases" } },
{ "name": "limit", "type": "select", "default": "all", "options": { "all": "Alle cases", "3": "3 cases" } }
]
}
]
}
Use these definitions to build the fields of a block when writing a page or
a blocks field of a resource.
Templates
GET /templates lists the page templates of the active theme. settings
holds the fields of the template's settings, in the same shape as block fields:
{
"data": [
{
"key": "met-cta",
"label": "Shazzoo met CTA",
"description": "Als Shazzoo Standaard, met het CTA-blok onderaan.",
"settings": [
{ "name": "show_page_title", "type": "toggle", "required": false, "default": false },
{ "name": "title", "type": "text", "label": "Kop", "required": false, "default": "Een half uur kost u niets" }
]
}
]
}
Use these fields to fill template_settings on a page, or a resource field
of the template_settings type.
Pages
A page as the API returns it:
{
"id": 12,
"title": "Cases",
"slug": "cases",
"translation_key": "5b0e…",
"locale": "nl",
"translations": [
{ "id": 31, "locale": "en", "slug": "cases", "title": "Cases" }
],
"is_active": true,
"template_key": "default",
"template_settings": {},
"seo_title": null,
"seo_description": null,
"seo": { "title": "Wat er bij onze klanten draait", "description": "…" },
"header": [],
"updated_at": "2026-09-23T09:14:56.000000Z",
"blocks": [
{ "position": 0, "uuid": "8f7c…", "type": "section-intro", "fields": { "title": "…" } }
]
}
Empty field values (null, "", []) are left out of fields.
Writing a page
POST /api/content-catalog/pages Content-Type: application/json { "title": "Cases per categorie", "slug": "cases-legacy", "locale": "nl", "is_active": false, "purpose": "import legacy cases page", "blocks": [ { "type": "cases.cases", "fields": { "source": "categories", "categories": ["Legacy"] } } ] }
| Field | POST / PUT |
PATCH |
Notes |
|---|---|---|---|
title |
required | optional | max 255 |
locale |
required | optional | one of the languages switched on in the global settings |
blocks |
required | optional | replaces all blocks when sent |
slug |
optional | optional | letters, digits, - and _; / nests it (products/core-cms); unique per locale |
translation_of |
optional | optional | id of the same page in another language, see Languages |
translation_key |
optional | optional | UUID; null unlinks the page. Not together with translation_of |
is_active |
optional | optional | boolean |
template_key |
optional | optional | a template key from GET /templates |
template_settings |
optional | optional | object, checked against the template's settings fields |
seo_title, seo_description, seo, header |
optional | optional | |
if_updated_at |
— | optional | see Conflicts |
Each block is { "type", "fields", "uuid"? }. Keep the uuid from a read to
update a block in place; leave it out and a new one is generated. Block fields
are checked against the block catalog:
- the type must exist, and every field must be defined for it
- required fields without a default must be present
togglefields must betrueorfalseselectandradiovalues must be one of the option keys. A list is accepted when every value in it is an option key (multi-selects).repeateritems are checked against the repeater's own fields
Fields you leave out get the block's default value.
template_settings is checked the same way against the settings fields of
the page's template: the template_key in the request, else the page's
current template, else default. Settings are stored as sent; the template
supplies its own defaults for keys that are missing.
Languages
A page exists per language. Pages in different languages are the same page
when they share a translation_key: the language switch on the site links
them, and they get each other as hreflang alternates in the page head and in
the sitemap.
Link a page by sending translation_of with the id of its version in another
language; it takes over that page's translation_key:
POST /api/content-catalog/pages { "title": "Services", "slug": "services", "locale": "en", "translation_of": 2, "blocks": [ … ] }
The response lists the linked versions under translations. A group has one
page per language, so a link is refused (422) when the page to link to is in
the same language, or when its group already has a page in this language.
Send "translation_key": null to unlink a page; it gets a group of its own.
The sitemap is rebuilt after every page change, so a new link shows up there
without running app:generate-sitemap-xml.
Plugins
GET /plugins lists active plugins. Each entry has key, slug, name,
description, version, source, the plugin's blocks and its resources.
Internal details such as the provider class and install path are not exposed.
A resource in that list:
{
"key": "cases",
"label": "Cases",
"endpoint": "/api/content-catalog/plugins/cases/resources/cases",
"fields": [
{ "name": "title", "type": "text", "required": true },
{ "name": "is_published", "type": "toggle", "required": false, "default": true }
],
"items_count": 9,
"items": [ { "id": 1, "title": "…" } ]
}
A resource whose table does not exist yet is listed with no items, and its
endpoints return 404 until the plugin's migrations have run.
Resources
A resource is a plugin model the API can list and write, such as employees, contact forms or cases.
Reading
GET …/resources/{resource} returns all records in the resource's order;
GET …/resources/{resource}/{id} returns one. A record contains id, every
declared field and updated_at when the model has timestamps:
{
"id": 3,
"image_id": 41,
"image": { "id": 41, "url": "https://example.test/storage/avery.jpg", "alt": "Avery Johnson" },
"name": "Avery Johnson",
"role": "Software Engineer",
"skills": ["Laravel", "AI"],
"updated_at": "2026-09-23T12:00:00.000000Z"
}
A media field returns its id and, next to it, the media item under the field
name without _id (image_id → image). A blocks field returns blocks in
the same shape as page blocks.
Writing
Send the declared fields as a flat JSON object:
POST /api/content-catalog/plugins/cases/resources/cases Content-Type: application/json { "title": "Offertes uit een klantvraag", "category": "AI-toepassing", "has_page": true, "slug": "offertes-uit-een-klantvraag", "content": [ { "type": "text-section", "fields": { "title": "Waar het begon", "body": "…", "aside": "none" } } ], "purpose": "add case" }
POSTreturns201with the new record;PUTandPATCHreturn200.- A resource declared with
"creatable": falsereturns405onPOST. POSTandPUTneed every required field without a default.PATCHonly checks the fields you send.- Fields you leave out on
POSTget their declared default. - A field that is not declared is rejected. Only declared fields are ever written.
if_updated_atworks as for pages.
Declaring resources in a plugin
A plugin exposes resources by listing them under api_resources in its
plugin.json. The plugin does not need a dependency on this package.
{
"key": "shazzoo/cases",
"slug": "cases",
"name": "Cases",
"provider": "Shazzoo\\Cases\\CasesServiceProvider",
"api_resources": [
{
"key": "cases",
"label": "Cases",
"model": "Shazzoo\\Cases\\Models\\CaseStudy",
"order_by": "sort_order",
"fields": [
{ "name": "title", "type": "text", "required": true },
{ "name": "slug", "type": "text", "unique": true },
{ "name": "is_published", "type": "toggle", "default": true },
{ "name": "content", "type": "blocks" }
]
}
]
}
| Key | Required | Meaning |
|---|---|---|
key |
yes | Resource key in the URL |
model |
yes | Eloquent model class |
fields |
yes | Fields the API reads and writes (see below) |
label |
no | Display name; defaults to the key in headline case |
order_by |
no | Column to sort lists by; defaults to the primary key |
creatable |
no | false for a resource that can only be edited, such as a settings row. Defaults to true |
order_direction |
no | desc to list newest first. Defaults to asc |
per_page |
no | Paginate the list: ?page=2, with meta (current_page, last_page, per_page, total) next to data. The plugin overview then shows only items_count, not the records. Defaults to no pagination |
writable |
no | false for a read-only resource: POST, PUT and PATCH return 405. Defaults to true |
Each field has a name (the model attribute) and a type. Optional keys:
required, default, unique (unique in the model's table), options and
multiple for selects, max for text, template_from for template settings,
and for json fields:
merge: a write changes only the keys it sends and keeps the others. Send a key withnullto empty ithidden_keys: keys the API never returns and refuses to write (422)
| Type | Accepts |
|---|---|
text |
string, max 255 unless max is set |
textarea |
string |
number |
number |
toggle |
true / false |
select |
an option key, or a list of option keys when multiple is true |
tags |
list of strings |
media |
id of an item in the media library |
repeater |
list of objects |
json |
any object or list |
blocks |
list of blocks, validated like page blocks and stored as page content |
template |
a template key from GET /templates |
template_settings |
object, checked against the settings of the template named in the field template_from (default template_key). Not checked while that field is empty |
Declare only fields the model can store. The model needs casts for tags,
repeater, json, blocks and template_settings fields (array) and for
toggles (boolean).
Leave out fields that should stay private, such as a form's recipient address:
the API never reads or writes undeclared columns.
A declaration with a missing key, model or field type is skipped and logged as a warning. Resources of inactive plugins are not available.
Languages. A field named locale only accepts a language that is switched
on in the global settings. A resource with both locale and translation_key
fields (cases, legal documents, employees) links its records across languages
like pages: send translation_of with the id of the same record in another
language, and each record lists the others under translations
(id, locale, slug and title or name). The same rules apply: one
record per language in a group.
Built-in resources
Plugins released before api_resources existed get a declaration from this
package:
| Plugin | Resource | Fields |
|---|---|---|
shazzoo/contact-form |
contact_forms |
name, key (unique), locale, subject_prefix, button_label, success_message, privacy_note, fields |
shazzoo/contact-form |
submissions |
contact_form_id, name, email, subject, page_url, locale, data (json, the answers), read_at. Read only, newest first, 50 per page |
shazzoo/employees |
employees |
locale, translation_key, image_id (media), name, role, skills (tags), sort_order |
shazzoo/content-catalog-api |
navigations |
title, slug (unique), translation_key, locale, items (json) |
shazzoo/content-catalog-api |
settings |
settings (json, merged, script fields hidden). Edit only |
shazzoo/strategy-engine-plugin |
settings |
index_template_key, index_template_settings, article_template_key, article_template_settings. Edit only (creatable: false) |
Menus and the global settings belong to core; this plugin exposes them under
its own slug, e.g. /api/content-catalog/plugins/content-catalog-api/resources/navigations.
Menu items and settings such as global_homepage refer to page ids, which
differ between sites: map them when copying content from one site to another.
The three custom script settings (custom_head_scripts,
custom_body_start_scripts, custom_body_end_scripts) put raw code on every
page and can only be changed in the admin.
The recipient of a contact form and the IP address of a submission are
deliberately not exposed. Of the Strategy
Engine settings only the templates are: the overview and article pages use
the chosen template with these settings. The AI and route settings stay out
of the API. A plugin that
declares its own api_resources replaces its built-in declaration.
Media
Upload an image as multipart/form-data to use its id in a media field or
a block:
POST /api/content-catalog/media Content-Type: multipart/form-data file=@team.jpg alt=Het team op kantoor purpose=employee photo
fileis required: JPG, PNG, WebP or GIF, up to the media library'smax_size. SVG is refused.altandtitleare optional.- Returns
201withid,url,alt,widthandheight. - Returns
404when the site has no media library.
Conflicts
Send the updated_at you read as if_updated_at on PUT or PATCH. If the
page or record changed since then, the API returns 409 and writes nothing.
Fetch it again and retry.
Errors
| Status | When |
|---|---|
401 |
Missing or wrong API key |
404 |
API off, unknown page, template, plugin, resource or record, or a resource without a table |
405 |
POST to a resource that cannot be created |
409 |
if_updated_at does not match |
422 |
Validation failed; errors maps each field path to its messages |
429 |
More than 60 requests per minute from one IP address |
Validation errors use Laravel's format, with dotted paths into blocks:
{
"message": "The selected block type is invalid.",
"errors": {
"blocks.0.type": ["The selected block type is invalid."],
"blocks.1.fields.categories": ["The selected value is invalid."]
}
}