REST API Documentation

Overview

The Joinery platform provides a REST API for programmatic access to data and operations.

  • Base URL: https://{site-domain}/api/v1/
  • Format: JSON (all requests and responses)
  • Methods: GET (read), POST (create), PUT (update), DELETE (soft delete)
  • HTTPS Required: All requests must use HTTPS

Architecture

Every API request flows through the front controller (api/apiv1.php), which runs transport preconditions (HTTPS, CORS, rate limits, client-version handshake), establishes the caller's identity once, then routes to a handler:

api/apiv1.php
  ├─ transport preconditions (HTTPS, CORS, rate limits, version handshake)
  ├─ $principal = ApiAuth::authenticate($headers, $source_ip)   → key + user, or 4xx
  └─ route by URL:
       /{Class}, /{Class}s   → CRUD      → ApiAuth::authorize([capability]) → model authenticate_read/write
       /action/*, /form/*    → ApiLogicEndpoint → ApiAuth::authorize([capability]) → run logic / build form
       /management/*         → ManagementApiRouter → ApiAuth::authorize([machine + superadmin])
       /auth/*               → ApiAuthEndpoint (thin shell) → ApiAuth::attemptLogin / revokeSessionKey

The whole security boundary lives in one class, ApiAuth (includes/ApiAuth.php):

MethodResponsibility
authenticate($headers, $source_ip)Resolve + validate the API key, load its user, return the principal (or exit 4xx). The single authentication path for every request.
authorize($contract, $api_entry, $user_permission, $label)The one authorization decision point. $contract is a small array — capability (read/write/delete), optional requires_machine_key, min_user_permission. Called by the CRUD verbs, the logic endpoint, and the management router.
attemptLogin() / revokeSessionKey()Credential-lifecycle decisions that the thin ApiAuthEndpoint (the /auth/* HTTP shell) delegates to.
The handler classes around it are dispatch, not auth: ApiLogicEndpoint runs an action's two faces (POST execute, GET form definition), and ManagementApiRouter resolves control-plane handler files. They consume the principal and call authorize() — they don't make auth decisions themselves. See Two authorization axes for the apk_permission/usr_permission distinction and the declarative auth block.

Authentication

All authenticated API requests use the same two custom headers:

public_key: {your_public_key}
secret_key: {your_secret_key}

There is one auth story with two key types, distinguished by apk_type in the shared apk_api_keys table. The request path is identical for both — only provisioning and secret hashing differ.

Machine keys (machine)Session keys (session)
Provisioned byAn administrator via Admin > API KeysThe user, via POST /api/v1/auth/login
IdentityThe user account the admin attachedThe user who logged in
Secret hashingSlow hash (phpass) — appropriate for admin-chosen secretsSHA-256 — the secret is a random 256-bit value, so a slow KDF buys nothing and would cost on every request
PermissionChosen by the admin (1–4)Always 4; per-record authorization (owner-or-staff by default — see Per-record authorization) is the effective gate
ExpiryOptional, set by the adminAlways set, from the api_session_key_lifetime_days setting (default 365)
RevocationAdmin API Keys pageauth/logout, the profile App Sessions page, the admin API Keys page (type filter), and automatically on password change
Management APIAllowed (with superadmin owner)Never
A password change revokes all of the user's session keys (the lost-phone path); machine keys owned by the same user are untouched. Session keys are not IP-restricted — devices roam networks by design; the App Sessions view's device label and last-used time are the compensating visibility.

Key Properties

PropertyDescription
public_keyPublic identifier sent in requests
secret_keySecret, verified against a stored hash (scheme per key type, above)
typemachine or session
is_activeKey must be active to authenticate
start_timeIf set, key is rejected before this time (UTC)
expires_timeIf set, key is rejected after this time (UTC)
last_used_timeUpdated by the auth path at most once per hour
ip_restrictionComma-separated list of allowed IPs (optional, machine keys)
permissionAccess level (see Permission Levels below)

Auth Endpoints

Session-key provisioning and lifecycle live under /api/v1/auth/*. HTTPS enforcement and both rate limiters apply to all of them.

POST /api/v1/auth/login — unauthenticated

The one place passwords transit the API. Body (JSON or form):

FieldRequiredDescription
emailYesAccount email
passwordYesAccount password
device_labelNoStored as the key name, shown in session lists (e.g. "Jeremy's iPhone")
Success returns the key pair — the only time the secret plaintext is ever returned — plus expiry and the same user/tier summary auth/session returns:

{
    "api_version": "1.0",
    "success_message": "Login successful",
    "data": {
        "public_key": "sess_…",
        "secret_key": "…64 hex chars…",
        "expires_time": "2027-06-11 17:00:00",
        "user": { "user_id": 5, "display_name": "…", "email": "…",
                  "permission": 0, "tier": { "name": "…", "tier_level": 2, "features": {} } }
    }
}

Store the two strings and send them as the standard key headers on every subsequent request. Failed logins return 401 AuthenticationError (identical response for unknown email, deleted user, and wrong password) and count toward the failed-auth rate limit.

GET /api/v1/auth/session — key-authenticated (either type)

Returns the user summary: user id, name, email, permission, subscription tier, and tier feature flags. The "who am I / what may I do" call on app launch.

POST /api/v1/auth/logout — session-key-authenticated

Revokes the presented key (soft delete) and nothing else. Machine keys get 403 here — they are revoked from the admin page, not by themselves.

Client Versioning

Apps send two headers on every request:

client_app: scrolldaddy-ios
client_version: 1.4.2

The api_min_client_versions setting holds a JSON map of client_app → minimum version (semver). If the request names an app with a configured minimum and its client_version compares below it (or is missing), every endpoint — including auth/login — responds HTTP 426 with errortype UpgradeRequired; the client renders this as a blocking upgrade screen with a store link. Requests without client headers (machine integrations, curl) are wholly unaffected.

Permission Levels

LevelReadCreate/UpdateDeleteDescription
1YesNoNoRead-only
2NoYesNoWrite-only
3YesYesNoRead + Write
4+YesYesYesFull access
Note: Permission level 2 grants write access but blocks read operations (GET requests).

This axis is non-monotonic — level 2 is write-only, so it is not a simple "higher = more" scale. Authorization is therefore expressed as a capability (read / write / delete), not a minimum level:

  • read → allowed unless apk_permission == 2
  • write → allowed when apk_permission >= 2
  • delete → allowed when apk_permission >= 4

Two authorization axes

API authorization decisions involve two distinct axes — keep them separate when reasoning about access:

AxisFieldMeaning
Key capabilityapk_permissionWhat a key may do on the CRUD axis (read / write / delete, non-monotonic — see above).
User roleusr_permissionThe owning user's role floor (e.g. 5 = staff, 10 = superadmin). This is the value passed to per-record authenticate_read/write as current_user_permission, and the floor the management plane gates on.
Both axes live in one class, ApiAuth (includes/ApiAuth.php), which owns the whole security boundary: ApiAuth::authenticate() resolves the principal from request headers, and ApiAuth::authorize() enforces every endpoint's authorization against a small contract — a capability, an optional requires_machine_key, and a min_user_permission floor.

Declaring endpoint authorization

Action, form, and management endpoints may declare their authorization contract in their descriptor's optional auth block. Each field falls back to the router's default (which equals that surface's standard requirement) when omitted, so most endpoints declare nothing:

function catalog_logic_api() {
    return [
        'description' => 'List blockable categories',
        'auth' => [
            'capability'           => 'read',   // 'read' | 'write' | 'delete' | null (no apk_permission check)
            'requires_session'     => true,     // run under session simulation as the key's user
            'requires_machine_key' => false,    // require apk_type = machine
            'min_user_permission'  => 0,        // usr_permission floor
        ],
    ];
}

Resolution order for each field: explicit auth value → router default → ApiAuth::authorize built-in default. Surface defaults:

SurfaceDefault contract
Action (POST /api/v1/action/*)capability: write
Form (GET /api/v1/form/*)capability: read
CRUD verbs (/api/v1/{Class}…)read for GET, write for POST/PUT, delete for DELETE
Management (/api/v1/management/*)requires_machine_key: true, min_user_permission: 10 (no apk_permission check)
A management handler's auth block may tighten the default (e.g. raise the user floor or add a capability) but cannot loosen it — the machine-key + superadmin default is enforced before the handler resolves so unknown paths still fail closed.

CRUD Endpoints

The CRUD surface has three independent authorization layers, each safe by default: resource exposure (is this class an endpoint at all?), row scope (may this caller touch this row?), and field floors (which columns may be read / written?). The apk_permission gradient (1–4) sits above all three and decides which HTTP verbs a key may use.

Exposing a model: checklist

A new model is a CRUD resource only when you opt it in. Both API surfaces (REST and the AI model surface) read the same declarations below, so you configure each fact once.

  1. Resource? Set $api_readable / $api_writable (both default false → 404). Leave both off for credential, config, audit/log, and join tables.
  2. Row scope? The SystemBase default is owner-or-staff (deny) and needs no code: - Standard {prefix}_usr_user_id owner column → nothing to write. - No owner column → automatically staff-only; nothing to write. - Public catalog content → set $api_public_read = true (you override to open, never to close). - Non-standard ownership (e.g. sender/recipient) → override authenticate_read / authenticate_write and throw to deny.
  3. Secret / privileged columns? Add genuine secrets to $api_unreadable_fields and privileged-but-readable columns to $api_unwritable_fields. Skip anything matching /_(password|secret|key|token|hash)$/i — the regex floor catches those automatically. Both lists are honored by REST and AI.
  4. Custom export_as_array() injecting derived keys? export_for_api() is fail-closed: it emits declared columns (minus the floor) plus only the keys you list in $api_derived_fields. Any computed/embed key not declared there is dropped. Embed a child model via its export_for_api() so the floor holds through the nesting.
  5. AI surface: keep $ai_excluded_fields to relevance/noise trims only — never re-list secrets (the shared floor merges them in). $ai_writable_fields narrows writes under the unwritable floor.
  6. Doing content-visibility gating (min-permission / group / tier on a served file)? That is is_viewable($session), not authenticate_read — the latter now means API row ownership only.
The rest of this section is the reference detail behind each step. For a complete annotated reference model that declares every property above (and the deletion/validation conventions), see docs/example_class.php — the canonical data-model template.

Resource exposure (opt-in)

A model is a CRUD resource only if it opts in, with two static booleans:

class Foo extends SystemBase {
    public static $api_readable = true;   // exposed to GET /{Class}/{id} and GET /{Class}s
    public static $api_writable = true;   // exposed to POST / PUT / DELETE /{Class}
}

Both default false on SystemBase. Read and write are separate, so a model can be read-only ($api_readable = true; $api_writable = false;). A class that is not exposed for a given verb is indistinguishable from one that does not exist — the request gets a 404.

> Plugin models are not exposed via CRUD. The CRUD surface enumerates core > data/*_class.php models only — a plugin's own data classes are unreachable through > /api/v1/{Class}. Plugins expose behaviour through Action Endpoints > instead.

Per-record authorization

Exposure decides which classes are endpoints; row scope decides which rows a caller may touch. Before returning or mutating a row, the API calls authenticate_read($data) (on GET) or authenticate_write($data) (on POST/PUT/DELETE), passing the acting user's identity:

$data = ['current_user_id' => <acting user id>, 'current_user_permission' => <their level>];

The SystemBase default is owner-or-staff (deny). A caller may touch a row only if they own it — the conventional {prefix}_usr_user_id column equals current_user_id — or they are staff (current_user_permission >= 5). A model with no owner column falls to staff-only. The contract is throw-to-deny: the method throws SystemAuthenticationError to refuse, and returns nothing to allow. (An explicit false return is also treated as denial.) It composes with both read shapes: a single GET /{Class}/{id} of an unauthorized row returns an error, while a collection GET /{Class}s simply skips rows the caller isn't authorized to see.

You override authenticate_read only to make a resource public — there is a declarative flag for the common case:

public static $api_public_read = true;   // catalog content: world-readable over the API

When $api_public_read is true, the read surface skips the per-record scope and the collection owner-filter — the rows are the same for everyone (Events, Products, Posts, Pages). When false (the default), reads are owner-or-staff. Audit/log/credential tables simply stay unexposed ($api_readable = false).

Ownership integrity. Three rules keep the owner column itself trustworthy on writes:

  • PUT authorizes the loaded row before applying input — you may only update a row you already own (the check runs against the row as stored, not as mutated by the request).
  • The owner column is unwritable{prefix}_usr_user_id is dropped from CRUD input, so ownership can never be reassigned through a field write.
  • POST stamps the owner server-side — created rows are owned by the caller by construction; a supplied owner in the body is ignored. For non-staff callers, the collection query is owner-filtered in SQL, so num_results reflects only the caller's rows (no count disclosure).

Field floors (read and write)

Row scope decides which rows; the field floors decide which columns of a row may leave the server (read) or be set over the API (write). Both floors are single definitions *shared with the AI model surface, so "secret" and "privileged" mean the same thing everywhere.

Read — the unreadable floor. A column is never exported if either its name matches SystemBase::CREDENTIAL_FIELD_PATTERN/_(password|secret|key|token|hash)$/i, so a new *_password / *_secret / *_key / *_token / *_hash column is protected the moment it is added — or it is listed in the model's $api_unreadable_fields (the explicit list for genuine secrets whose names don't match the pattern):

// data/users_class.php
public static $api_unreadable_fields = array(
    'usr_authhash', 'usr_remember_tokens', 'usr_totp_backup_codes',
);

CRUD reads return export_for_api(), which is fail-closed (an allowlist) — the same paradigm as the AI read surface, which selects only $field_specifications columns. A key is emitted only if it is either a declared column that survives the unreadable floor, or a key on the model's $api_derived_fields allowlist. Anything else an export_as_array() override injects — a computed / derived key — is dropped by construction, so a derived secret cannot leak under a name the credential pattern did not anticipate:

// data/users_class.php — computed keys export_as_array() injects that may leave over the API
public static $api_derived_fields = array(
    'key', 'display_name', 'usr_day_since_register', 'usr_days_since_last_email',
    'contact_preferences', 'phone', 'address',
);

A derived key is exposed only by deliberate opt-in (default: none) — the same shape as resource exposure. The allowlist is still subject to the unreadable floor, so a credential-named derived key cannot be allowlisted back into the open. (Internal/admin/webhook code that needs the full row keeps calling export_as_array(), which is unchanged.) An override that embeds a child model exports it via the child's export_for_api(), so the floor holds through nested embeds, and the parent emits the embed key only when it is on $api_derived_fields.

Write — the unwritable floor. The exact mirror. A column is never written over the API if either its name matches the same credential pattern, or it is listed in the model's $api_unwritable_fields — the explicit list for privileged, non-credential columns:

// data/users_class.php — usr_permission is readable but must never be set via the API
public static $api_unwritable_fields = array(
    'usr_permission', 'usr_is_disabled', 'usr_disabled_time',
    'usr_email_is_verified', 'usr_password_recovery_disabled',
);

Unwritable fields are silently dropped from POST/PUT input (strong-params style) — the column keeps its stored value (PUT) or model default (POST), and a full-object round-trip still works. This is what makes a model like User safely writable: the dangerous column (usr_permission) is blocked at the field layer, not the whole model.

The AI surface composes over the same floors: $ai_excluded_fields trims reads for relevance/noise on top of the unreadable floor, and $ai_writable_fields is an allowlist that narrows writes under the unwritable floor. Neither can re-expose a floored field.

Read Single Object

GET /api/v1/{ClassName}/{id}

Example: GET /api/v1/User/123

Response:

{
    "api_version": "1.0",
    "success_message": "User found.",
    "data": {
        "usr_user_id": 123,
        "usr_first_name": "Jane",
        "usr_last_name": "Doe",
        "usr_email": "[email protected]"
    }
}

List Objects (Collection)

GET /api/v1/{ClassName}s?page=0&numperpage=10&sort=field&sdirection=ASC

Add a trailing s to the class name for collections.

Pagination Parameters:

ParameterDefaultDescription
page0Page number (0-based)
numperpage3Items per page
sort(none)Database column to sort by
sdirectionASCSort direction: ASC or DESC
Any additional query parameters are passed as filter options to the Multi class. Check the specific Multi class to see which filter keys it accepts.

Example: GET /api/v1/Users?page=0&numperpage=20&sort=usr_id&sdirection=DESC

Response:

{
    "api_version": "1.0",
    "success_message": "",
    "num_results": 100,
    "page": 0,
    "numperpage": 20,
    "data": [ ... ]
}

> ⚠️ Raw CRUD writes are not recommended. POST/PUT/DELETE write a model's columns > directly — they bypass all business-logic validation, side effects, and workflow. Creating > an Order this way produces an order with no payment, cart, or receipt; registering for an > Event skips capacity and waitlist checks. Use the corresponding action endpoint (checkout, > event signup, registration, account edit) for anything with a workflow* — it is the supported > write path. CRUD write is a raw escape hatch for simple records, not the front door. > > Credential and privileged columns (anything matching the credential pattern, or listed in a > model's $api_unwritable_fields — e.g. usr_permission) are silently dropped from CRUD > writes and can only be changed through the action that owns them. Do not rely on CRUD write to > set them.

Create Object

POST /api/v1/{ClassName}
Content-Type: application/x-www-form-urlencoded

field1=value1&field2=value2

Input is sanitized at the API boundary before anything is created — unwritable fields (the write floor) and the owner column are dropped, and the owner is stamped from the session. If the model defines a CreateNew() static factory it is called with that sanitized input; otherwise a new object is created and the sanitized fields are set from the POST body.

Response:

{
    "api_version": "1.0",
    "success_message": "New User successful.",
    "data": { ... }
}

Update Object

PUT /api/v1/{ClassName}/{id}?field1=value1&field2=value2

Fields to update are passed as query string parameters. The row is authorized as stored before any input is applied (you may only update a row you already own), and unwritable fields plus the owner column are dropped from the update.

Response:

{
    "api_version": "1.0",
    "success_message": "User update successful.",
    "data": { ... }
}

Soft Delete Object

DELETE /api/v1/{ClassName}/{id}

Sets the delete timestamp on the object. Does not permanently remove data.

Response:

{
    "api_version": "1.0",
    "success_message": "Deletion successful.",
    "data": { ... }
}

Available Models

Any core SystemBase model class is available via the API (plugin models are not — see Per-record authorization). Class names are case-sensitive and use PascalCase. Which rows a key may read or change within a model is governed by that model's authenticate_read/authenticate_write — see Per-record authorization.

Common models include: User, Product, Event, EventRegistrant, EventSession, Order, OrderItem, Group, GroupMember, Post, Page, Email, Message, File, CouponCode, SubscriptionTier, Location, Video, Comment, Survey, SurveyAnswer, Question, QuestionOption, MailingList, MailingListRegistrant.

Error Handling

Error Response Format

{
    "api_version": "1.0",
    "errortype": "AuthenticationError",
    "error": "Error: description of what went wrong",
    "data": ""
}

Error Types and HTTP Status Codes

StatusError TypeMeaning
400AuthenticationErrorMissing headers, invalid key, deleted user, missing login fields
400TransactionErrorObject not found, validation failure, save error, invalid object name
401AuthenticationErrorWrong secret, bad login credentials, IP restricted, inactive/expired/revoked key
403AuthenticationErrorInsufficient permission; machine key on auth/logout; session key on management/*
426SecurityErrorHTTPS required
426UpgradeRequiredclient_version below the configured minimum for this client_app
429RateLimitErrorRate limit exceeded

Rate Limiting

The API enforces two rate limits per IP address:

LimitThresholdWindow
General requests1,000Per hour
Failed auth attempts10Per 15 minutes
When exceeded, the API returns HTTP 429 with a RateLimitError. Wait for the time window to pass before retrying.

HTTPS Requirement

All API requests must use HTTPS. Requests over plain HTTP are rejected with HTTP 426 (Upgrade Required).

This can be disabled for development by setting api_require_https to false in the site settings.

CORS

CORS is disabled by default. To enable it, set api_allowed_origins in site settings to a comma-separated list of allowed origins:

https://example.com,https://app.example.com

Preflight OPTIONS requests are handled automatically when CORS is configured.

Security Headers

All API responses include:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: no-referrer

Action Endpoints

Actions execute multi-step business logic (registration, event signup, payments, etc.) rather than raw CRUD operations. All logic functions that have been opted in via a companion _api() function are available.

Making a Logic Function Available via API

Add a companion function to your logic file:

// In logic/your_action_logic.php

function your_action_logic_api() {
    return [
        'requires_session' => true,   // default: true
        'description' => 'What this action does',
    ];
}

That's it — no registry file or mapping needed.

Action Request Format

POST /api/v1/action/{action_name}
Content-Type: application/json
public_key: {key}
secret_key: {key}

{ "field": "value", ... }

Sessioned actions require API key write permission (level 2+) and run under session simulation as the key's user.

Plugin Actions

Plugin actions are addressed as {plugin}/{action}, where {plugin} is the plugin directory name:

POST /api/v1/action/dns_filtering/device_edit
GET  /api/v1/form/dns_filtering/device_edit

The name resolves directly to plugins/{plugin}/logic/{action}_logic.php (no theme chain — themes do not override plugin logic) and follows the same _logic_api() opt-in contract as core actions. Only active plugins resolve; an inactive or unknown plugin returns the same Unknown action 404 as a missing action, so responses do not reveal which plugins are installed. The namespace makes collisions structurally impossible — a plugin action can never shadow a core action or another plugin's.

Request logs and error messages use the full namespaced name (e.g. action dns_filtering/device_edit). See Plugin Developer Guide for the plugin-side conventions.

Sessionless actions (requires_session => false: register, password_reset_1, password_reset_2, …) are dispatched without key headers — a first-launch client has no credentials yet. HTTPS enforcement and both rate limiters apply unchanged, and failures log like other auth-adjacent traffic. The matching rule for fetching those actions' form definitions is in the Form Definition Endpoint section.

Action Response Formats

Success (HTTP 200):

{
    "api_version": "1.0",
    "success_message": "Action 'register' completed successfully.",
    "redirect": "/page/register-thanks",
    "data": { ... }
}

  • redirect is included when the action would have redirected in the web UI (informational — the API consumer decides what to do with it)
  • data contains any output data from the logic function
Validation error (HTTP 422):
{
    "api_version": "1.0",
    "errortype": "ValidationError",
    "error": "Please correct the errors below",
    "validation_errors": {
        "field_name": "Error message for this field"
    },
    "data": {}
}

Action error (HTTP 422):

{
    "api_version": "1.0",
    "errortype": "ActionError",
    "error": "This feature is turned off",
    "data": {}
}

Available Actions

ActionDescriptionSession
registerRegister a new user accountNo
password_reset_1Request password reset emailNo
password_reset_2Set new password via reset codeNo
password_setSet password on first loginNo
password_editChange password (logged in)Yes
change_password_requiredForced password changeYes
contact_preferencesUpdate contact preferencesYes
account_editUpdate profile fieldsYes
address_editUpdate addressYes
phone_numbers_editUpdate phone numbersYes
change_tierChange subscription tierYes
surveySubmit survey responseYes
bookingBook an appointmentYes
cartAdd item to cartYes
cart_clearClear cartYes
event_registerRegister for an eventYes
event_withdrawWithdraw from eventYes
event_waiting_listJoin event waiting listYes
event_sessionsSelect event sessionsYes
event_sessions_courseSelect course sessionsYes
orders_recurring_actionRecurring order actionYes
Plugin action surfaces are documented with their plugin (e.g. the DNS filtering surface in plugins/dns_filtering/docs/overview.md) and appear in the discovery endpoint below.

Action Discovery Endpoint

GET /api/v1/actions

Returns a list of all available actions with descriptions. Useful for API consumers to programmatically determine what actions are available. Actions from active plugins are listed under their namespaced name ({plugin}/{action}) with the same fields.

Response:

{
    "api_version": "1.0",
    "success_message": "Available actions",
    "data": {
        "register": {
            "description": "Register a new user account",
            "requires_session": false,
            "has_form": true
        },
        "event_register": {
            "description": "Register for an event",
            "requires_session": true,
            "has_form": false
        }
    }
}

has_form indicates whether the action exposes a server-driven form definition (below).

Form Definition Endpoint

GET /api/v1/form/{action_name}

Returns the action's form as a JSON definition — fields, labels, prefilled values, validation rules, visibility rules — built by the action's form builder function and rendered through FormWriterV2JSON. Native apps render the definition with a generic form renderer and submit through the normal action endpoint; the schema reference, builder convention, and supported field types are documented in docs/formwriter.md.

A form is served iff the action's logic file defines both {action_name}_logic_api() and {action_name}_logic_form() (reflected in the discovery endpoint's has_form flag).

Authentication mirrors the action's requires_session declaration:

  • Sessioned forms require the standard key headers; the definition is prefilled with the acting user's data. Like other reads, write-only keys (permission 2) get 403.
  • Sessionless forms (register, password_reset_1, password_reset_2) are served without key headers — a first-launch client has no credentials yet. HTTPS enforcement and both rate limiters apply unchanged.
Query parameters are passed to the builder as request context (e.g. GET /api/v1/form/password_reset_2?act_code=... round-trips the reset code into the form's hidden field).

Response:

{
    "api_version": "1.0",
    "success_message": "Form definition for 'account_edit'",
    "data": {
        "schema_version": 1,
        "form": {
            "name": "account_edit",
            "submit_to": "/api/v1/action/account_edit",
            "submit_label": "Submit"
        },
        "fields": [
            {"type": "text", "name": "usr_first_name", "label": "First Name",
             "value": "Jeremy", "maxlength": 255},
            {"type": "drop", "name": "usr_timezone", "label": "Your Time Zone",
             "value": "America/Chicago", "options": {"America/Chicago": "America/Chicago"}}
        ]
    }
}

Submissions go to POST /api/v1/action/{action_name} with a JSON body whose keys match the web form's POST exactly; validation failures return the standard 422 response with the field-keyed validation_errors map.

Errors: unknown action, action without a form builder → 404; non-GET method → 405; missing/invalid key on a sessioned form → standard authentication errors; a builder using a non-serializable construct → 500 ActionError.

Management API (Read-Only)

The /api/v1/management/* namespace is a separate read-only surface used by the server_manager control plane to observe managed nodes (stats, version, backup files, error log). It is not part of the public CRUD API: endpoints don't map to SystemBase models and have their own convention.

Authorization

Management endpoints reuse the existing apk_api_keys table unchanged. Two gates, both checked before the endpoint is resolved:

  • Machine keys only. The key's apk_type must be machine. Session keys minted by auth/login get 403 here regardless of who owns them — a superadmin logging into a phone app must not hold a control-plane credential. This boundary is pinned by a dedicated test in tests/functional/api/session_keys_test.php; treat that test as load-bearing.
  • Superadmin owner. The key's owning user must have usr_permission >= 10.
  • apk_permission (1–4 CRUD gradient) is NOT a gate here — it is orthogonal to the management check. A superadmin's machine key with apk_permission = 1 (read-only CRUD) can call management endpoints; a permission-5 admin's key cannot, regardless of apk_permission.
  • All other existing auth checks (active, not deleted, not expired, IP restriction, secret verification) apply unchanged — management dispatch only happens after apiv1.php's full auth chain has passed.

Endpoints

All under /api/v1/management/, all GET, all return the standard success envelope except backups/fetch which streams a binary file.

EndpointDescription
healthLiveness probe: {ok: true, version: "…"} — used by JobCommandBuilder::has_api()
statsDisk, memory, load, uptime, PostgreSQL liveness, Joinery version, DB list
versionSystem version, schema version, per-plugin versions
databasesList of PostgreSQL databases accessible to the site
errors/recentLast N error.log lines matching Fatal/Exception/Error (default 20, cap 200)
backups/listFiles in /backups/ with size and date
backups/fetch?path=…Streams a backup file as application/octet-stream (path must be under /backups/)
Discovery: GET /api/v1/management lists every endpoint with its method and description. Parallels /api/v1/actions.

Adding a management endpoint

Convention-based, mirrors the action-endpoints layout. A single file defines two functions:

// includes/management_api/my_thing_handler.php

function my_thing_handler($request) {
    return ['value' => 42];   // non-null array → router wraps with api_success()
}

function my_thing_handler_api() {
    return [
        'method' => 'GET',
        'description' => 'What this endpoint does',
    ];
}

$request is an associative array: method, path, query ($_GET), body (decoded JSON for non-GET), headers. Handlers should use $request rather than touching $_GET/$_POST directly. For streaming endpoints (backups/fetch), write bytes yourself and return null — the router will not append an envelope.

Nested paths mirror subdirectories: includes/management_api/backups/list_handler.phpGET /api/v1/management/backups/list → function backups_list_handler().

Writes? Not here.

The management API is permanently read-only. Mutating operations (backups, restores, upgrades, installs, deletions) stay on SSH — SSH is the more deliberate transport for state changes, and a compromised read-only key cannot do damage. If you find yourself wanting to add a write endpoint, extend SSH instead.

Error Types

CRUD Error Types

StatusError TypeMeaning
400AuthenticationErrorMissing headers, invalid key, deleted user, missing login fields
400TransactionErrorObject not found, validation failure, save error, invalid object name
401AuthenticationErrorWrong secret, bad login credentials, IP restricted, inactive/expired/revoked key
403AuthenticationErrorInsufficient permission; machine key on auth/logout; session key on management/*
426SecurityErrorHTTPS required
426UpgradeRequiredclient_version below the configured minimum for this client_app
429RateLimitErrorRate limit exceeded

Action Error Types

StatusError TypeMeaning
404ActionErrorUnknown action name or action not available via API
405ActionErrorWrong HTTP method (actions require POST)
422ActionErrorBusiness logic error (e.g., feature disabled, invalid state)
422ValidationErrorInput validation failed — check validation_errors for field-level detail

Request Logging

All API requests are logged for audit purposes. Logs include: feature, action, IP address, user ID, success/failure, HTTP status code, response time, and — once authentication has passed — the API key type (rql_api_key_type), so audit queries can separate machine from session traffic. Secret keys, passwords, and request bodies are never logged.

Logs are retained for a configurable period (default: 90 days) and automatically cleaned up by a scheduled task.