REST API
The headless layer: an external application — Nuxt, Next.js, a mobile app, an integration — talks to the site over HTTP and gets JSON. Such a client has no session and nowhere to echo a CSRF token back from, so it presents a bearer token instead.
Nothing is exposed by default. Until a token is issued and at least one object is published, every endpoint answers 401 and CORS stays silent. An ordinary server-rendered PageBlocks site lives as if this layer did not exist.
Endpoints
GET /api/pb/v1/health is the API alive; no token needed
GET /api/pb/v1/me what this token is and what it may do
GET /api/pb/v1/objects which objects this token can reach
GET /api/pb/v1/objects/{name} list
GET /api/pb/v1/objects/{name}/{id} one record
POST /api/pb/v1/objects/{name} create ┐ only if the object allows it
PATCH /api/pb/v1/objects/{name}/{id} update │ and the token carries the
DELETE /api/pb/v1/objects/{name}/{id} delete ┘ write scopeThe prefix is api/pb/, not api/, so it cannot collide with the site's own routes.
Tokens
Issued in the manager: PageBlocks → API tokens. The full token is shown once, at creation; the server stores only its sha256. Lost it — use Reissue in the row menu; the old one dies immediately.
curl -H 'Authorization: Bearer pb_…' https://site/api/pb/v1/meX-API-Key: pb_… is accepted too, for clients behind a proxy that strips Authorization.
Scopes are strings like cities.read. A route demands a specific scope; the token either carries it or not:
| In the token | Covers |
|---|---|
cities.read | exactly that scope |
cities.* | every scope in the cities group |
* | everything |
For the generic endpoints the scope name is derived from the object name — cities.read to read, cities.write to write. In your own routes you name it explicitly:
Route::get('api/v1/orders', 'OrderApi@index')->middleware('ApiAuth:orders.read');
Route::get('api/v1/ping', 'PingApi@index')->middleware('ApiAuth'); // any live tokenA token can carry an expiry date and a MODX user to act as. Unchecking Enabled revokes it without deleting the row.
Publishing an object: two ways
A checkbox in the constructor
Table window → API tab: switch it on, tick the fields, add write operations if needed. This is the path for constructor content tables.
The rest follows by itself: search uses the fields already marked searchable, required fields become validation rules on write, and the name in the URL is a transliteration of the table name (overridable).
Two safety catches that look like "it doesn't work" but are deliberate:
- no fields ticked means the object is not published, even with the switch on — an accidental click must not expose a whole table;
- operations are read-only by default — publishing an object and allowing writes to it are two separate decisions.
Only published rows are served.
A declaration in core/App/api.php
For everything that is not a constructor table: your own models, orders, users, resources. The file is site-owned; a component upgrade does not touch it.
return [
'cities' => [
'model' => City::class,
'fields' => ['id', 'city_name', 'alias'],
'filterable' => ['id', 'alias', 'city_name'],
'searchable' => ['city_name', 'alias'],
'sortable' => ['id', 'city_name'],
'with' => ['country'],
'per_page' => 20,
'max_per_page' => 100,
'default_sort' => 'id',
'query' => fn(Builder $q) => $q->whereNotNull('published_at'),
// Writing: without this the object is read-only.
'operations' => ['list', 'show', 'create', 'update'],
'writable' => ['city_name', 'alias'],
'rules' => ['city_name' => 'required|string'],
'scopes' => ['read' => 'cities.read', 'write' => 'cities.write'],
],
];A name in the file overrides a constructor table of the same name — the file is treated as the more deliberate statement of intent.
Satellite components register in code, on the OnPageBlocksApiRegister event:
ApiRegistry::register('orders', [...]);When neither fits
The registry covers uniform CRUD. Aggregation, joining several models, business rules — that is your own route and your own controller, with ApiAuth attached by hand. The two coexist.
Query parameters
?filter[model_id]=7&filter[city_name][like]=Kis&sort=-id&fields=id,city_name&limit=20&offset=40| Parameter | What it does |
|---|---|
filter[field]=value | equals |
filter[field][op]=value | eq ne gt gte lt lte like in nin between null |
search=word | across the searchable fields |
sort=-id,name | minus means descending |
fields=id,name | narrow the response |
with=country | eager-load a relation listed in with |
limit, offset, page | paging; limit is clamped by max_per_page |
A field outside the whitelist is ignored silently
So is an unknown operator. This is the number one source of confusion: "the filter does not work" almost always means the field is not declared in filterable.
Filtering is only allowed on published fields — otherwise the contents of a hidden field could be recovered by brute force through yes/no answers.
In like and search the characters % and _ are escaped: the client sends a word, not a pattern.
Response format
{
"data": [ { "id": 1, "city_name": "Moscow" } ],
"meta": { "total": 48748, "count": 20, "limit": 20, "offset": 0, "has_more": true },
"links": { "self": "…", "next": "…", "prev": "…" }
}A single record is {"data": {…}}. An error is {"error": "not_found", "message": "…"}; a 422 adds details with a per-field breakdown.
This is not the manager's {success, total, results}. That one belongs to the ExtJS grids and changes with them; this one is a contract with somebody else's application.
Status codes: 401 missing or bad token, 403 insufficient scope, 404 no such object or record, 405 operation not allowed, 422 validation failed, 429 rate limit exceeded.
CORS
Off while pageblocks_cors_origins is empty.
| Setting | Default |
|---|---|
pageblocks_cors_origins | empty (off); * or https://*.example.com |
pageblocks_cors_paths | api/* |
pageblocks_cors_methods | GET,POST,PUT,PATCH,DELETE,OPTIONS |
pageblocks_cors_headers | Authorization,Content-Type,X-Requested-With,X-API-Key |
pageblocks_cors_credentials | no |
pageblocks_cors_max_age | 86400 |
These settings do not appear by themselves
They are declared in the package build, which only runs when the package is built — on a site they have to be created by the installer. A missing setting reads as "off", so a forgotten step does not look like an error: the Access-Control-* headers are simply absent, the browser blocks the request, and the server log shows a clean 200.
Preflight (OPTIONS) is handled before routing — otherwise it would die with a 405, because OPTIONS is not registered where GET is.
Rate limiting
120 requests per minute per IP on the service endpoints and on objects. Over the limit the answer is 429; X-RateLimit-* headers are on every response.
OpenAPI and Swagger
PageBlocks → API documentation is a Swagger UI with the endpoint list, parameter fields and a try it button. Press Authorize and paste a token for requests to go through.
The specification is generated from the same registry that serves the requests, so it cannot go stale: an object appears in it exactly when the checkbox is ticked or the line is added to App/api.php.
| Address | Who may open it | What it returns |
|---|---|---|
/mgr/pb/docs?ctx=mgr | manager session | the Swagger UI page |
/mgr/pb/openapi.json?ctx=mgr | manager session | the full specification |
/api/pb/v1/openapi.json | API token | the specification trimmed to the token's scopes |
ctx=mgr is required: the route context is taken from the request, and without the parameter the manager address is simply not found.
Hand an external developer the JSON rather than a link to the page — it opens in Postman and Insomnia and feeds client generators:
npx openapi-typescript https://site/api/pb/v1/openapi.json -o api.d.tsSwagger UI's own files load from a CDN. With no network, or a blocked CDN, you get an explanation and the specification address instead of a blank page; the API itself keeps working — only the viewer is missing.
The servers address takes its scheme from the current request rather than from site_url: after a move to https that setting is very often still http://, and the try it button would run into a redirect or mixed-content blocking.
Not there yet
- relations deeper than one level;
- webhooks and
Idempotency-Key; - generic endpoints for MODX resources — publish them through
App/api.phplike any other model.
Debugging
| Symptom | Check |
|---|---|
| Is it me or the server? | GET /api/pb/v1/health — no token needed |
| Is my token good? | GET /api/pb/v1/me — shows the scopes |
| Is the object published? | GET /api/pb/v1/objects — lists only what you may reach |
404 unknown_object on something that "definitely exists" | almost always an unticked checkbox or an empty field list |
| 500 with an empty body | core/cache/logs/error.log, full traces are there |