Skip to content

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():

php
$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:

FieldPurpose
endpointPath under /api/, e.g. backend/menueditor/api/backend/menueditor
modelPathRelative path to model.php, starting from the plugin's api/ folder

On installAPI(), the core writes one row per entry into the pluginAPI table:

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

URLClass in model.php
/api/authclass auth extends apiBaseModel
/api/pagesclass pages
/api/user/ordersclass user_orders
/api/user/statusclass user_status
/api/shop/cartclass shop_cart
/api/checkoutclass checkout
/api/backend/authclass backend_auth
/api/backend/pagebuilderclass backend_pagebuilder
/api/backend/actions/scriptclass backend_actions_script
/api/elearning/my-coursesclass elearning_my_courses
/api/backend/user/passwordclass 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_menueditor

So 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
<?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:

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

EndpointpublicMethodsWhy
/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:

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

php
$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 Allowed

success() 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:

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:

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

php
// _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 dispatching
  • demoAPI/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 handling
  • elearning/api/elearning/my-courses/model.php — user-scoped GET with $_SESSION auth
  • demoAPI/api/webhook/stripe/model.phpskipOriginCheck=true for 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