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 / revokeSessionKeyThe whole security boundary lives in one class, ApiAuth (includes/ApiAuth.php):
| Method | Responsibility |
|---|---|
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. |
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 by | An administrator via Admin > API Keys | The user, via POST /api/v1/auth/login |
| Identity | The user account the admin attached | The user who logged in |
| Secret hashing | Slow hash (phpass) — appropriate for admin-chosen secrets | SHA-256 — the secret is a random 256-bit value, so a slow KDF buys nothing and would cost on every request |
| Permission | Chosen by the admin (1–4) | Always 4; per-record authorization (owner-or-staff by default — see Per-record authorization) is the effective gate |
| Expiry | Optional, set by the admin | Always set, from the api_session_key_lifetime_days setting (default 365) |
| Revocation | Admin API Keys page | auth/logout, the profile App Sessions page, the admin API Keys page (type filter), and automatically on password change |
| Management API | Allowed (with superadmin owner) | Never |
Key Properties
| Property | Description |
|---|---|
public_key | Public identifier sent in requests |
secret_key | Secret, verified against a stored hash (scheme per key type, above) |
type | machine or session |
is_active | Key must be active to authenticate |
start_time | If set, key is rejected before this time (UTC) |
expires_time | If set, key is rejected after this time (UTC) |
last_used_time | Updated by the auth path at most once per hour |
ip_restriction | Comma-separated list of allowed IPs (optional, machine keys) |
permission | Access 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):
| Field | Required | Description |
|---|---|---|
email | Yes | Account email |
password | Yes | Account password |
device_label | No | Stored as the key name, shown in session lists (e.g. "Jeremy's iPhone") |
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.2The 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
| Level | Read | Create/Update | Delete | Description |
|---|---|---|---|---|
| 1 | Yes | No | No | Read-only |
| 2 | No | Yes | No | Write-only |
| 3 | Yes | Yes | No | Read + Write |
| 4+ | Yes | Yes | Yes | Full access |
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 unlessapk_permission == 2write→ allowed whenapk_permission >= 2delete→ allowed whenapk_permission >= 4
Two authorization axes
API authorization decisions involve two distinct axes — keep them separate when reasoning about access:
| Axis | Field | Meaning |
|---|---|---|
| Key capability | apk_permission | What a key may do on the CRUD axis (read / write / delete, non-monotonic — see above). |
| User role | usr_permission | The 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. |
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:
| Surface | Default 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) |
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.
- Resource? Set
$api_readable/$api_writable(both defaultfalse→ 404). Leave both off for credential, config, audit/log, and join tables. - Row scope? The
SystemBasedefault is owner-or-staff (deny) and needs no code: - Standard{prefix}_usr_user_idowner 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) → overrideauthenticate_read/authenticate_writeand throw to deny. - Secret / privileged columns? Add genuine secrets to
$api_unreadable_fieldsand 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. - 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 itsexport_for_api()so the floor holds through the nesting. - AI surface: keep
$ai_excluded_fieldsto relevance/noise trims only — never re-list secrets (the shared floor merges them in).$ai_writable_fieldsnarrows writes under the unwritable floor. - Doing content-visibility gating (min-permission / group / tier on a served file)? That is
is_viewable($session), notauthenticate_read— the latter now means API row ownership only.
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 APIWhen $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_idis 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_resultsreflects 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=ASCAdd a trailing s to the class name for collections.
Pagination Parameters:
| Parameter | Default | Description |
|---|---|---|
page | 0 | Page number (0-based) |
numperpage | 3 | Items per page |
sort | (none) | Database column to sort by |
sdirection | ASC | Sort direction: ASC or DESC |
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=value2Input 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=value2Fields 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
| Status | Error Type | Meaning |
|---|---|---|
| 400 | AuthenticationError | Missing headers, invalid key, deleted user, missing login fields |
| 400 | TransactionError | Object not found, validation failure, save error, invalid object name |
| 401 | AuthenticationError | Wrong secret, bad login credentials, IP restricted, inactive/expired/revoked key |
| 403 | AuthenticationError | Insufficient permission; machine key on auth/logout; session key on management/* |
| 426 | SecurityError | HTTPS required |
| 426 | UpgradeRequired | client_version below the configured minimum for this client_app |
| 429 | RateLimitError | Rate limit exceeded |
Rate Limiting
The API enforces two rate limits per IP address:
| Limit | Threshold | Window |
|---|---|---|
| General requests | 1,000 | Per hour |
| Failed auth attempts | 10 | Per 15 minutes |
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.comPreflight 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-referrerAction 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_editThe 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": { ... }
}redirectis included when the action would have redirected in the web UI (informational — the API consumer decides what to do with it)datacontains any output data from the logic function
{
"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
| Action | Description | Session |
|---|---|---|
register | Register a new user account | No |
password_reset_1 | Request password reset email | No |
password_reset_2 | Set new password via reset code | No |
password_set | Set password on first login | No |
password_edit | Change password (logged in) | Yes |
change_password_required | Forced password change | Yes |
contact_preferences | Update contact preferences | Yes |
account_edit | Update profile fields | Yes |
address_edit | Update address | Yes |
phone_numbers_edit | Update phone numbers | Yes |
change_tier | Change subscription tier | Yes |
survey | Submit survey response | Yes |
booking | Book an appointment | Yes |
cart | Add item to cart | Yes |
cart_clear | Clear cart | Yes |
event_register | Register for an event | Yes |
event_withdraw | Withdraw from event | Yes |
event_waiting_list | Join event waiting list | Yes |
event_sessions | Select event sessions | Yes |
event_sessions_course | Select course sessions | Yes |
orders_recurring_action | Recurring order action | Yes |
Action Discovery Endpoint
GET /api/v1/actionsReturns 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.
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_typemust bemachine. Session keys minted byauth/loginget 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 intests/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 withapk_permission = 1(read-only CRUD) can call management endpoints; a permission-5 admin's key cannot, regardless ofapk_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.
| Endpoint | Description |
|---|---|
health | Liveness probe: {ok: true, version: "…"} — used by JobCommandBuilder::has_api() |
stats | Disk, memory, load, uptime, PostgreSQL liveness, Joinery version, DB list |
version | System version, schema version, per-plugin versions |
databases | List of PostgreSQL databases accessible to the site |
errors/recent | Last N error.log lines matching Fatal/Exception/Error (default 20, cap 200) |
backups/list | Files in /backups/ with size and date |
backups/fetch?path=… | Streams a backup file as application/octet-stream (path must be under /backups/) |
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.php → GET /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
| Status | Error Type | Meaning |
|---|---|---|
| 400 | AuthenticationError | Missing headers, invalid key, deleted user, missing login fields |
| 400 | TransactionError | Object not found, validation failure, save error, invalid object name |
| 401 | AuthenticationError | Wrong secret, bad login credentials, IP restricted, inactive/expired/revoked key |
| 403 | AuthenticationError | Insufficient permission; machine key on auth/logout; session key on management/* |
| 426 | SecurityError | HTTPS required |
| 426 | UpgradeRequired | client_version below the configured minimum for this client_app |
| 429 | RateLimitError | Rate limit exceeded |
Action Error Types
| Status | Error Type | Meaning |
|---|---|---|
| 404 | ActionError | Unknown action name or action not available via API |
| 405 | ActionError | Wrong HTTP method (actions require POST) |
| 422 | ActionError | Business logic error (e.g., feature disabled, invalid state) |
| 422 | ValidationError | Input 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.