Skip to content

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 scope

The 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.

bash
curl -H 'Authorization: Bearer pb_…' https://site/api/pb/v1/me

X-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 tokenCovers
cities.readexactly 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:

php
Route::get('api/v1/orders', 'OrderApi@index')->middleware('ApiAuth:orders.read');
Route::get('api/v1/ping',   'PingApi@index')->middleware('ApiAuth'); // any live token

A 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.

php
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:

php
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
ParameterWhat it does
filter[field]=valueequals
filter[field][op]=valueeq ne gt gte lt lte like in nin between null
search=wordacross the searchable fields
sort=-id,nameminus means descending
fields=id,namenarrow the response
with=countryeager-load a relation listed in with
limit, offset, pagepaging; 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 ​

json
{
  "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.

SettingDefault
pageblocks_cors_originsempty (off); * or https://*.example.com
pageblocks_cors_pathsapi/*
pageblocks_cors_methodsGET,POST,PUT,PATCH,DELETE,OPTIONS
pageblocks_cors_headersAuthorization,Content-Type,X-Requested-With,X-API-Key
pageblocks_cors_expose_headersempty
pageblocks_cors_credentialsno
pageblocks_cors_max_age86400

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.

AddressWho may open itWhat it returns
/mgr/pb/docs?ctx=mgrmanager sessionthe Swagger UI page
/mgr/pb/openapi.json?ctx=mgrmanager sessionthe full specification
/api/pb/v1/openapi.jsonAPI tokenthe 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:

bash
npx openapi-typescript https://site/api/pb/v1/openapi.json -o api.d.ts

Swagger 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.php like any other model.

Debugging ​

SymptomCheck
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 bodycore/cache/logs/error.log, full traces are there

© PageBlocks 2019-present