API Endpoints
Plugins register REST endpoints via the $this->apiEndpoints property. Each endpoint has a PHP model that extends apiBaseModel. This page covers registration, class naming (path → underscores), and the base pattern for action handlers.
Registration in bootstrap.php
Inside the plugin's install():
$this->apiEndpoints = [
'{"modelPath": "backend/menueditor/model.php", "endpoint": "backend/menueditor"}',
'{"modelPath": "menueditor/model.php", "endpoint": "menueditor"}'
];Entries are JSON strings — one per endpoint. Each string has two fields:
| Field | Purpose |
|---|---|
endpoint | Path under /api/, e.g. backend/menueditor → /api/backend/menueditor |
modelPath | Relative path to model.php, starting from the plugin's api/ folder |
On installAPI(), the core writes one row per entry into the pluginAPI table:
CREATE TABLE pluginAPI (
id INT AUTO_INCREMENT PRIMARY KEY,
endpoint VARCHAR(255) NOT NULL, -- e.g. "backend/menueditor"
modelPath VARCHAR(255) NOT NULL, -- e.g. "backend/menueditor/model.php"
plugin_id INT NOT NULL -- FK → plugins.id
);The router in apiBaseController loads the pluginAPI entries on request and dispatches to the matching model.
Class naming (CRITICAL)
The class name is derived from the path with two substitutions:
/→_-→_
Examples:
| URL | Class in model.php |
|---|---|
/api/auth | class auth extends apiBaseModel |
/api/pages | class pages |
/api/user/orders | class user_orders |
/api/user/status | class user_status |
/api/shop/cart | class shop_cart |
/api/checkout | class checkout |
/api/backend/auth | class backend_auth |
/api/backend/pagebuilder | class backend_pagebuilder |
/api/backend/actions/script | class backend_actions_script |
/api/elearning/my-courses | class elearning_my_courses |
/api/backend/user/password | class backend_user_password |
Dashes must become underscores
/api/elearning/my-courses becomes class elearning_my_courses — underscore, not hyphen. If you write class elearning_my-courses, the loader can't find the class and returns 401 Unauthorized (because the pre-auth check fails before routing even happens).
Three segments = two underscores
The core converts the path in three places inside apiBaseController: checkOriginGlobal(), checkApiAccess(), loadApiEndpoint(). All three expect the same convention. Your own intermediate resolvers have to apply the same conversion.
Directory structure
{plugin-name}/
└── api/
├── menueditor/
│ └── model.php # class menueditor (public API)
└── backend/
└── menueditor/
└── model.php # class backend_menueditorSo the menueditor plugin has two API endpoints: a public API (/api/menueditor) and a backend API (/api/backend/menueditor). Both are registered in bootstrap.php, both have their own model.php classes.
Base template
Every model extends apiBaseModel. The apiAction() method is the entry point — it dispatches on HTTP method or action:
<?php
class backend_menueditor extends apiBaseModel
{
protected array $publicMethods = []; // every method requires auth
protected function apiAction(): void
{
if (empty($_SESSION['backend_loggedin']) || !$_SESSION['backend_loggedin']) {
$this->error('Unauthorized', 401);
return;
}
$method = strtoupper($_SERVER['REQUEST_METHOD']);
switch ($method) {
case 'GET': $this->getMethod(); break;
case 'POST': $this->postMethod(); break;
case 'PATCH': $this->patchMethod(); break;
case 'DELETE': $this->deleteMethod(); break;
default: $this->notSupported(); break;
}
}
private function getMethod(): void
{
$action = $_GET['action'] ?? 'list';
switch ($action) {
case 'list': $this->listMenus(); break;
case 'tree': $this->loadTree(); break;
default: $this->error('Unknown action', 400);
}
}
private function listMenus(): void
{
$query = query("SELECT id, name, slug FROM menueditor_menus ORDER BY id ASC");
$this->success(['menus' => fetch_all($query)]);
}
}publicMethods — bypass auth
The $publicMethods property controls which HTTP methods are reachable without authentication:
protected array $publicMethods = []; // default: every method requires auth
protected array $publicMethods = ['GET']; // GET without auth (e.g. public API)
protected array $publicMethods = ['POST']; // POST without auth (e.g. incoming webhooks)Typical patterns:
| Endpoint | publicMethods | Why |
|---|---|---|
/api/auth | ['POST'] | Login must work without a session |
/api/pages | ['GET'] | Page fetch by anonymous visitors |
/api/backend/* | [] | Every backend endpoint requires auth |
/api/webhook/stripe | ['POST'] | Stripe sends webhooks without a session |
skipOriginCheck — bypass CORS
For incoming webhooks from third-party systems, the CORS origin check must be skipped:
protected bool $skipOriginCheck = true;Only for real webhook receivers (webhook/stripe, webhook/paypal, sw6/webhook) and deliberately public APIs (emailmarketing/subscribe) — in every other case, leave it false (the default) so the global origin protection stays active.
Response helpers
apiBaseModel provides response methods that send the HTTP response and call exit:
$this->success(['menus' => $menus]); // HTTP 200, JSON body
$this->success(null, 204); // HTTP 204 No Content
$this->error('Not found', 404); // HTTP 404
$this->error('Unauthorized', 401); // HTTP 401
$this->error('Forbidden', 403); // HTTP 403
$this->error('Invalid request', 400); // HTTP 400
$this->notFound('Page not found'); // HTTP 404 convenience wrapper
$this->notSupported(); // HTTP 422 Method Not Allowedsuccess() also sets the APCu cache header for anonymous GETs and calls fastcgi_finish_request() so running tasks in the PHP shutdown handler can continue without blocking the response.
Database access
Models use the helpers from include/mysql.php:
$query = query("SELECT * FROM my_articles WHERE id = " . (int) $id . " LIMIT 1");
if (num_rows($query) === 0) {
$this->error('Not found', 404);
return;
}
$row = fetch_assoc($query);
$this->success($row);Always use real_escape_string() for strings from user input, and (int) / intval() for IDs. Details: Plugin Anatomy › Database helpers.
Reading the request body
Decode JSON bodies manually — apiBaseModel does not do it automatically:
private function postMethod(): void
{
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body)) {
$this->error('Invalid JSON body', 400);
return;
}
$name = real_escape_string($body['name'] ?? '');
// ...
}Rate limiting for your own endpoints
Sensitive endpoints (auth, checkout, certificate issuance) should enforce stricter per-IP limits. Limits are maintained in _core/system/api/Ratelimiter.php via the $endpointLimits array:
// _core/system/api/Ratelimiter.php
private static array $endpointLimits = [
'backend/auth' => ['capacity' => 30, 'refillRate' => 0.5, 'ttl' => 300],
'auth' => ['capacity' => 30, 'refillRate' => 0.5, 'ttl' => 300],
'elearning/certificate' => ['capacity' => 5, 'refillRate' => 0.08, 'ttl' => 300],
'elearning/exam' => ['capacity' => 10, 'refillRate' => 0.17, 'ttl' => 300],
'emailmarketing/subscribe' => ['capacity' => 5, 'refillRate' => 0.08, 'ttl' => 300],
'checkout' => ['capacity' => 10, 'refillRate' => 0.17, 'ttl' => 300],
];Keys: capacity = max burst tokens, refillRate = tokens per second, ttl = APCu bucket lifetime in seconds. Prefix matching: backend/auth also matches backend/auth/login. Entries are additive on top of the global limit (frontend 100/min, backend 250/min, API key 60/min) — they only set tighter bounds, never looser.
Example endpoints in the repo
Good templates to learn from:
demoAPI/api/backend/menueditor/model.php— classic backend CRUD with GET/POST/PATCH/DELETE and action dispatchingdemoAPI/api/auth/model.php— public POST with multiple action types (typ=login|register|logout|...)demoAPI/api/pages/model.php— public GET with multi-language + redirect handlingelearning/api/elearning/my-courses/model.php— user-scoped GET with$_SESSIONauthdemoAPI/api/webhook/stripe/model.php—skipOriginCheck=truefor a third-party webhook
Common issues
Dash path → 401 Unauthorized
Endpoint /api/elearning/my-courses expects class elearning_my_courses (two underscores). If you name the class elearning_my-courses, the loader can't find it, the pre-auth check fails, and the client gets 401 — not 404, which makes debugging confusing.
modelPath written wrong
modelPath is the path relative to the plugin's api/ folder, including .php. For /api/backend/menueditor, the model lives at menueditor/api/backend/menueditor/model.php — so modelPath is "backend/menueditor/model.php", not "api/backend/menueditor/model.php".
apiAction() without an auth check
The core does not automatically check $_SESSION['backend_loggedin'] for backend/* endpoints. Every backend endpoint has to run the check itself inside apiAction() or at the start of each action. Public APIs with $publicMethods = ['GET'] are exempt.
$this->success() calls exit
All response helpers (success, error, notFound, notSupported) terminate script execution. No code after $this->success() runs — for deferred shutdown tasks, either trigger fastcgi_finish_request() explicitly or register a register_shutdown_function() before the response.
Request bodies are not auto-decoded
apiBaseModel does not ship a $request helper. Every endpoint has to call json_decode(file_get_contents('php://input'), true) itself — ideally followed by an is_array() validation.
See also
- Plugin Anatomy — the
$this->apiEndpointsproperty ininstall() - Plugins overview › the plugin_backend table — how
pluginAPIandplugin_backendinteract - Migrations — custom tables for endpoint data
- Webhook Events — dispatch events from endpoints
- Scheduled Tasks — trigger endpoints via the queue
- API Reference — interactive OpenAPI documentation of every core endpoint