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

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_credentialsno
pageblocks_cors_max_age86400

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.

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