The Joinery platform provides a REST API for programmatic access to data and operations.
https://{site-domain}/api/v1/All API requests require two custom headers:
public_key: {your_public_key}
secret_key: {your_secret_key}API keys are created by an administrator via Admin > API Keys. Each key is associated with a user account. The key inherits that user's identity for object-level authorization checks.
| Property | Description |
|---|---|
public_key | Public identifier sent in requests |
secret_key | Secret verified via bcrypt hash comparison |
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) |
ip_restriction | Comma-separated list of allowed IPs (optional) |
permission | Access level (see Permission Levels below) |
| 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 |
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]"
}
}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": [ ... ]
}POST /api/v1/{ClassName}
Content-Type: application/x-www-form-urlencoded
field1=value1&field2=value2If the model has a CreateNew() static method, it is called first. Otherwise, a new object is created and fields are set from the POST body.
Response:
{
"api_version": "1.0",
"success_message": "New User successful.",
"data": { ... }
}PUT /api/v1/{ClassName}/{id}?field1=value1&field2=value2Fields to update are passed as query string parameters.
Response:
{
"api_version": "1.0",
"success_message": "User update successful.",
"data": { ... }
}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": { ... }
}Any SystemBase model class is available via the API. Class names are case-sensitive and use PascalCase.
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.
{
"api_version": "1.0",
"errortype": "AuthenticationError",
"error": "Error: description of what went wrong",
"data": ""
}| Status | Error Type | Meaning |
|---|---|---|
| 400 | AuthenticationError | Missing headers, invalid key, deleted user |
| 400 | TransactionError | Object not found, validation failure, save error, invalid object name |
| 401 | AuthenticationError | Wrong secret, IP restricted, inactive/expired key |
| 403 | AuthenticationError | Insufficient permission for this operation |
| 426 | SecurityError | HTTPS required |
| 429 | RateLimitError | Rate limit exceeded |
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.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 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.
All API responses include:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: no-referrerActions 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.
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.
POST /api/v1/action/{action_name}
Content-Type: application/json
public_key: {key}
secret_key: {key}
{ "field": "value", ... }Actions require API key write permission (level 2+).
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{
"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": {}
}| 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 |
GET /api/v1/actionsReturns a list of all available actions with descriptions. Useful for API consumers to programmatically determine what actions are available.
Response:
{
"api_version": "1.0",
"success_message": "Available actions",
"data": {
"register": {
"description": "Register a new user account",
"requires_session": false
},
"event_register": {
"description": "Register for an event",
"requires_session": true
}
}
}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.
Management endpoints reuse the existing stg_api_keys table unchanged. The gate is user-level, not key-level:
usr_permission >= 10 (superadmin).apk_permission (1–4 CRUD gradient) is NOT the gate here — it is orthogonal to the management check. A superadmin's key with apk_permission = 1 (read-only CRUD) can call management endpoints; a permission-5 admin's key cannot, regardless of apk_permission.apiv1.php's full auth chain has passed.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.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().
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.
| Status | Error Type | Meaning |
|---|---|---|
| 400 | AuthenticationError | Missing headers, invalid key, deleted user |
| 400 | TransactionError | Object not found, validation failure, save error, invalid object name |
| 401 | AuthenticationError | Wrong secret, IP restricted, inactive/expired key |
| 403 | AuthenticationError | Insufficient permission for this operation |
| 426 | SecurityError | HTTPS required |
| 429 | RateLimitError | Rate limit exceeded |
| 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 |
All API requests are logged for audit purposes. Logs include: feature, action, IP address, user ID, success/failure, HTTP status code, and response time. Secret keys and request bodies are never logged.
Logs are retained for a configurable period (default: 90 days) and automatically cleaned up by a scheduled task.