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
PUT|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 (PRO)
Issuing tokens and the API documentation page below are paid; the API menu item is visible in the free edition on purpose, so you can see what is behind it. The endpoints themselves are of no use without a token, so in practice this whole page describes the paid build. See Free and PRO.
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_expose_headers | empty |
pageblocks_cors_credentials | no |
pageblocks_cors_max_age | 86400 |
cors_paths defaults to api/* deliberately: it keeps CORS off the HTML side of the site even once an origin is listed.
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 (PRO)
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 |