Setting Up MODX as a Headless CMS via REST API
MODX as a headless CMS is a non-trivial task: there is no JSON API out of the box. Solutions: custom connector, the modREST package, or full implementation via snippets with header('Content-Type: application/json').
Option 1: Custom JSON Connector
Create a resource with content type application/json and snippet processor:
// Snippet: ApiProducts
// Resource: /api/products/ (contentType: application/json, published, cacheable: no)
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
$action = $_GET['action'] ?? 'list';
$id = (int)($_GET['id'] ?? 0);
$limit = min((int)($_GET['limit'] ?? 20), 100);
$offset = (int)($_GET['offset'] ?? 0);
switch ($action) {
case 'get':
echo json_encode(getProduct($modx, $id));
break;
case 'list':
default:
echo json_encode(getProducts($modx, $limit, $offset));
break;
}
function getProducts($modx, $limit, $offset): array {
$c = $modx->newQuery('modResource');
$c->where(['parent' => 5, 'published' => 1, 'deleted' => 0]);
$c->limit($limit, $offset);
$c->sortby('menuindex', 'ASC');
$total = $modx->getCount('modResource', $c);
$resources = $modx->getCollection('modResource', $c);
$items = [];
foreach ($resources as $resource) {
$items[] = [
'id' => $resource->id,
'title' => $resource->get('pagetitle'),
'slug' => $resource->get('alias'),
'description' => $resource->get('introtext'),
'price' => (float)$resource->getTVValue('price'),
'image' => $resource->getTVValue('product_image'),
'url' => $modx->makeUrl($resource->id, '', '', 'full'),
];
}
return [
'total' => $total,
'limit' => $limit,
'offset' => $offset,
'items' => $items,
];
}
Access: GET /api/products/?limit=10&offset=0.
Option 2: Full REST API via Class
// core/components/myapi/processors/products/getlist.class.php
class ProductsGetListProcessor extends modProcessor {
public function process(): string {
$limit = min((int)$this->getProperty('limit', 20), 100);
$offset = (int)$this->getProperty('offset', 0);
$search = $this->getProperty('search', '');
$c = $this->modx->newQuery('modResource');
$c->where(['parent' => 5, 'published' => 1]);
if ($search) {
$c->where(['pagetitle:LIKE' => "%{$search}%"]);
}
$total = $this->modx->getCount('modResource', $c);
$c->limit($limit, $offset);
$collection = $this->modx->getCollection('modResource', $c);
$list = [];
foreach ($collection as $resource) {
$list[] = $this->prepareResource($resource);
}
return $this->outputArray($list, $total);
}
private function prepareResource($resource): array {
return [
'id' => $resource->id,
'title' => $resource->get('pagetitle'),
'price' => $resource->getTVValue('price'),
];
}
}
API Authentication
// Check API key in header
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';
$validKey = $modx->getOption('myapi.secret_key');
if (!hash_equals($validKey, $apiKey)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// JWT verification (with firebase/php-jwt library via Composer)
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$token = str_replace('Bearer ', '', $_SERVER['HTTP_AUTHORIZATION'] ?? '');
try {
$decoded = JWT::decode($token, new Key($modx->getOption('jwt_secret'), 'HS256'));
$userId = $decoded->sub;
} catch (Exception $e) {
http_response_code(401);
echo json_encode(['error' => 'Invalid token']);
exit;
}
CORS Configuration
// CORS Plugin
// Event: OnHandleRequest
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header('Access-Control-Allow-Origin: https://frontend.yourdomain.com');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key');
header('Access-Control-Max-Age: 86400');
http_response_code(204);
exit;
}
if (strpos($_SERVER['REQUEST_URI'], '/api/') === 0) {
header('Access-Control-Allow-Origin: https://frontend.yourdomain.com');
}
Webhooks on Content Change
// Plugin: ContentWebhook
// Event: OnDocFormSave
$webhookUrl = $modx->getOption('webhook_url');
if (empty($webhookUrl)) return;
$payload = json_encode([
'event' => $mode === modSystemEvent::MODE_NEW ? 'created' : 'updated',
'id' => $resource->id,
'alias' => $resource->get('alias'),
'published' => (bool)$resource->get('published'),
]);
// Asynchronous send (fire and forget)
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 3,
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
Timeline
Basic JSON API for reading content (3–5 endpoints) — 3–4 days. Full CRUD API with authentication and webhooks — 7–10 days.







