How to add custom endpoints to the JekCMS REST API — from authentication middleware to pagination, filtering, and webhook triggers.
API Architecture Overview
The JekCMS REST API is a single PHP file (api/v1/index.php) with a class-based router. All requests go through .htaccess rewrite rules to this entry point, which parses the URL, authenticates the request, and routes to the appropriate handler method.
// .htaccess
RewriteRule ^api/v1/(.*)$ api/v1/index.php [QSA,L]
// api/v1/index.php
class Api
{
public function handleRequest(): void
{
$method = $_SERVER['REQUEST_METHOD'];
$path = trim($_SERVER['PATH_INFO'] ?? '', '/');
$segments = explode('/', $path);
$resource = $segments[0] ?? '';
$id = $segments[1] ?? null;
$action = $segments[2] ?? null;
// Route to handler
match ($resource) {
'posts' => $this->handlePosts($method, $id, $action),
'media' => $this->handleMedia($method, $id),
'categories' => $this->handleCategories($method, $id),
'custom' => $this->handleCustom($method, $action),
default => $this->error('Unknown resource', 404),
};
}
}
Creating a Custom Endpoint
To add a new endpoint, you add a handler method and a route case. For example, adding a /api/v1/stats endpoint that returns site statistics:
// Add to the match statement:
'stats' => $this->handleStats($method),
// Add the handler method:
private function handleStats(string $method): void
{
if (!$this->authenticate()) { $this->error('Unauthorized', 401); return; } // X-API-Key check
if ($method !== 'GET') {
$this->error('Method not allowed', 405);
return;
}
$stats = [
'total_posts' => $this->db->fetch("SELECT COUNT(*) as c FROM posts")['c'],
'published_posts' => $this->db->fetch("SELECT COUNT(*) as c FROM posts WHERE status='published'")['c'],
'total_views' => $this->db->fetch("SELECT SUM(views) as v FROM posts")['v'] ?? 0,
'total_media' => $this->db->fetch("SELECT COUNT(*) as c FROM media")['c'],
'categories' => $this->db->fetchAll("SELECT name, slug, (SELECT COUNT(*) FROM posts WHERE category_id=c.id) as post_count FROM categories c ORDER BY name"),
];
$this->success($stats);
}
Authentication
All custom endpoints should use API key authentication. jekcms's real API calls a single authenticate() method directly — there's no separate requireAuth() wrapper in the core, it's just one method that checks for the X-API-Key header or Bearer token:
private function authenticate(): bool
{
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearers+(.+)/', $authHeader, $matches)) {
$apiKey = $matches[1];
}
if (!$apiKey) return false;
$token = $this->db->fetch(
"SELECT * FROM api_tokens WHERE token = ? AND is_active = 1",
[$apiKey]
);
return $token !== false;
}
Pagination
private function paginate(string $query, array $params, int $page, int $perPage): array
{
$countQuery = preg_replace('/SELECT .+ FROM/i', 'SELECT COUNT(*) as total FROM', $query);
$countQuery = preg_replace('/ORDER BY .+$/i', '', $countQuery);
$total = $this->db->fetch($countQuery, $params)['total'];
$offset = ($page - 1) * $perPage;
$query .= " LIMIT {$perPage} OFFSET {$offset}";
$items = $this->db->fetchAll($query, $params);
return [
'items' => $items,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => (int)$total,
'total_pages' => ceil($total / $perPage),
],
];
}
Filtering
Query parameters map to WHERE clauses with parameterized queries (never string concatenation):
$where = ['1=1'];
$params = [];
if (!empty($_GET['category'])) {
$where[] = 'c.slug = ?';
$params[] = $_GET['category'];
}
if (!empty($_GET['status'])) {
$where[] = 'p.status = ?';
$params[] = $_GET['status'];
}
$query = "SELECT p.* FROM posts p LEFT JOIN categories c ON p.category_id = c.id WHERE " . implode(' AND ', $where);
Triggering Outbound Notifications
jekcms's core doesn't ship a generic outbound-webhook table or dispatch system — the built-in webhook support is for receiving events (n8n publishing into jekcms via api/v1/webhook/*), not for notifying arbitrary external systems when something happens on your site. If your custom endpoint needs to notify an external service, that's something you'd build yourself: store the target URL and a shared secret wherever makes sense for your use case (a plugin's own table, or a row in api_tokens if you're extending that), sign the payload the same way jekcms's own webhook code does, and fire the request. Here's roughly what that looks like:
private function notifyExternal(string $url, string $secret, array $data): void
{
$payload = json_encode(['data' => $data, 'timestamp' => time()]);
$signature = hash_hmac('sha256', $payload, $secret);
// Fire-and-forget using non-blocking socket
$this->asyncPost($url, $payload, [
'X-Webhook-Signature: sha256=' . $signature,
]);
}
The async approach ensures webhook delivery does not slow down the API response. If the external system is down, the webhook fails silently — we log the failure for debugging but do not retry (retries belong in a queue system).