Reference
REST API
Resource endpoints, authentication, authorization, responses and uploads.
The Nimbly API has routes for every resource. Access is still controlled by role permissions, bearer tokens, or an explicit public [#api-allow#] route for narrow cases such as public forms.
Endpoints
GET /api/v1/{resource} → list all records
POST /api/v1/{resource} → create a new record
GET /api/v1/{resource}/{uuid} → get a single record
PUT /api/v1/{resource}/{uuid} → update a record (partial update - only supplied fields are changed)
DELETE /api/v1/{resource}/{uuid} → delete a record
DELETE /api/v1/{resource} → delete all records (requires `api_delete_resource_{resource}` or `(all)`)
A list takes these query parameters:
limit,offset: one page of the list,?limit=20&offset=40.sort: as in[#data#], e.g.date|descortitle|text|asc, several separated by commas.filter: as in[#data#], e.g.status:new||todo,published:1.search: records whose title contains the text; in a resource without a title field, records with the text in any field.
With any of the first four the answer has total, the number of records before the cut, next to count, the number returned. A page is answered without reading every record. Sorting or filtering on a field the API never returns (hidden, encrypt) is refused with HTTP 400.
Collection deletion is a distinct destructive capability. Record deletion
permissions such as api_delete_{resource}, API wildcards, ordinary
delete-{resource}, and manage-{resource} never grant it. A denied collection
deletion returns HTTP 403 and names the required
api_delete_resource_{resource} permission in needs.
Authentication
Use this for all external API access. Any site, script or service that reads or writes through the API signs in as a Nimbly user with API access and gets a token here. Don't invent shared secrets, per-record keys or other parallel auth. Give the calling party its own user, with a role holding only the feature it needs. In a custom endpoint, check that feature with
api_access('<feature>')(core/modules/api/lib/api.php), then scope the data to the signed-in user ($_SESSION['username']).
Obtain a Bearer token by posting credentials:
curl -X POST "/api/v1/auth/token" \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "yourpassword"}'
Response:
{
"token": "anSojGGKOveDuIypVGbx...",
"token_created": 1741265734,
"token_expires": 1741308617,
"code": 200,
"success": true,
"status": "ok"
}
Tokens expire after 10 minutes. Refresh before expiry with a GET to the same endpoint:
PHP-FPM and the Authorization header On servers running PHP-FPM (rather than mod_php), Apache strips the
Authorizationheader before it reaches PHP. The Nimbly API validates Bearer tokens viagetallheaders()["Authorization"], so without the header, every authenticated API call returns403 ACCESS_DENIEDeven with a valid token.
core/cli/setup/htaccess.tplincludes the fix by default (system:setupfor new installs;system:repairbrings an existing project's.htaccesslevel with it):CGIPassAuth On RewriteCond %{HTTP:Authorization} ^(.*) RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]
CGIPassAuth Ontells Apache to forward the Authorization header to the FPM process. TheRewriteRuleadditionally exposes it as$_SERVER['HTTP_AUTHORIZATION']for any code that reads that directly. Both lines are needed for full compatibility. Any project that exposes the Nimbly API to external clients on a PHP-FPM host requires this - if.htaccesswas hand-edited after generation and lost this block, runsystem:repair --yesto restore it.
curl -X GET "/api/v1/auth/token" \
-H "Authorization: Bearer YOUR_VALID_TOKEN"
Include the token in every API request:
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Response format
All successful responses return success: true and status: ok:
{
"articles": {
"abc123": {
"uuid": "abc123",
"title": "Hello world",
"published": "1"
}
},
"count": 1,
"message": "RESOURCE_CREATED",
"code": 201,
"success": true,
"status": "ok",
"memory_usage": "403Kb",
"execution_time": "0.010s"
}
| Field | Description |
|---|---|
count |
Number of records returned or affected |
message |
RESOURCE_CREATED (201), RESOURCE_UPDATED (200), RESOURCE_DELETED (200) |
code |
HTTP status code |
success |
true on success, false on error |
execution_time |
Server processing time |
Error responses
{
"message": "ACCESS_DENIED",
"needs": "api_delete_users,api_delete_(any),api_(any)",
"code": 403,
"success": false,
"status": "error"
}
Common error codes:
| Code | Message | Description |
|---|---|---|
| 400 | INVALID_DATA |
Missing required fields or invalid data |
| 401 | UNAUTHORIZED |
Missing or invalid token |
| 403 | ACCESS_DENIED |
Role lacks the required permission |
| 404 | RESOURCE_NOT_FOUND |
Record or resource does not exist |
| 409 | CONFLICT |
Record already exists |
| 500 | INTERNAL_ERROR |
Server-side failure |
Authorization
Access is role-based. When a request is denied, the needs field lists the permission patterns that would grant access:
"needs": "api_delete_users_123,api_delete_users,api_(any)_users,api_delete_(any),api_(any),delete-users,manage-users"
User roles should use canonical resource permissions: view-<resource> for GET, create-<resource> for POST, edit-<resource> for PUT/PATCH, delete-<resource> for DELETE, plus import-<resource> and export-<resource> for import/export routes. Legacy api_* tokens are still accepted for compatibility. Roles and their permissions are managed in the admin under /nb-admin/roles/.
Custom UUID
Supply a uuid field in the POST body to use a specific identifier instead of an auto-generated one:
curl -X POST "/api/v1/profiles" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"uuid": "my-id", "name": "Test User"}'
Useful when the caller already has a meaningful identifier (e.g. an external system ID) to use as the record key.
File upload
Files are uploaded to the special .files resource as multipart/form-data:
curl -X POST "/api/v1/.files" \
-H "Authorization: Bearer TOKEN" \
-F "file=@photo.jpg"
Response includes the file UUID, which is the MD5 checksum of the file content:
{
"files": {
"uuid": "44556fd2a0d9463b6506e6e101e20bfe",
"name": "photo.jpg",
"type": "image/jpeg",
"size": 2048
},
"message": "RESOURCE_CREATED",
"code": 201
}
Re-uploading an identical file returns RESOURCE_CREATED with the same UUID - the upload is idempotent by checksum.
File metadata: .files_meta
Every uploaded file has a corresponding metadata record in .files_meta/{uuid}. This is a separate resource from .files (which stores the raw binary). The metadata record contains:
| Field | Description |
|---|---|
uuid |
File UUID - MD5 checksum of the file content |
name |
Original filename (e.g. KH-Menukaart.pdf) |
title |
Admin-set display title (e.g. KetelHuis Menukaart) - may be empty |
type |
MIME type (e.g. application/pdf, image/jpeg) |
size |
File size in bytes |
When displaying a file reference stored as a UUID (e.g. in page settings or a resource field), always read the display name and type from .files_meta, not from the UUID itself. In templates, nb.media_library.unfiltered contains the full .files_meta records loaded at page init - use this for client-side display without extra API calls.
Prefer title over name as the user-facing display label; fall back to name when title is empty.
Image serving
Uploaded images are served at /img/{uuid}/{spec}:
| Spec | Example | Description |
|---|---|---|
{W}x{H}f |
300x300f |
Fit into box, aspect ratio preserved |
{W}x{H}c |
300x200c |
Crop from center to exact dimensions |
{W}w |
300w |
Resize to width |
{H}h |
300h |
Resize to height |
/img/44556fd2a0d9463b6506e6e101e20bfe/500x500c
Images are served as WebP. Upscaling is not allowed.
Scalability
Nimbly loads resources into memory for fast access. By default, all records live in one flat directory. Above ~10,000 records filesystem performance starts to degrade.
For high-volume resources, enable splitdir in .meta:
{
"fields": { ... },
"splitdir": true
}
With splitdir enabled, records are stored in a two-level directory tree based on the first characters of their UUID (e.g. ab/cd/<uuid>). This keeps directory sizes small and maintains performance at large scale. The data library handles the path transparently - no changes needed in templates or code.
OpenAPI description
The OpenAPI JSON describes the existing generic resource, token and file-upload interfaces. It is also available at /reference/api/openapi.json. Applications define their record fields and any additional endpoints.
Additional implemented operations
PUT /api/v1/{resource} accepts updates for multiple records as an object keyed by UUID. POST /api/v1/{resource}/{uuid} creates a record with an explicit identifier. The generic handlers implement GET, POST, PUT and DELETE; a role mapping for PATCH does not itself establish a PATCH handler.
Successful resource reads are objects keyed by resource name, with records keyed by UUID. File upload uses a files metadata object. Response keys therefore depend on the interface and application resource.
Validation and conditional reads
Field validation failures in record creation and individual updates can return HTTP 422 with INVALID_DATA. Malformed requests can return HTTP 400. Existing-record conflicts use RESOURCE_EXISTS. Read handlers support conditional modification checks and can return HTTP 304.
Check code, success and status and handle the actual response rather than depending on a sample timestamp or assuming all failures use one error code. The events tutorial demonstrates authorized reads and denied access against the isolated example.