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
{
$this->requireAuth(); // Require API key
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. The requireAuth() method checks for the X-API-Key header or Bearer token:
private function requireAuth(): void
{
if (!$this->authenticate()) {
$this->error('Unauthorized', 401);
exit;
}
}
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);
Webhook Triggers
Custom endpoints can trigger webhooks after operations. For example, after creating a new resource, notify external systems:
private function triggerWebhook(string $event, array $data): void
{
$webhooks = $this->db->fetchAll(
"SELECT url, secret FROM webhooks WHERE event = ? AND is_active = 1",
[$event]
);
foreach ($webhooks as $webhook) {
$payload = json_encode(['event' => $event, 'data' => $data, 'timestamp' => time()]);
$signature = hash_hmac('sha256', $payload, $webhook['secret']);
// Fire-and-forget using non-blocking socket
$this->asyncPost($webhook['url'], $payload, [
'X-Webhook-Signature: sha256=' . $signature,
'X-Webhook-Event: ' . $event,
]);
}
}
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).