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|desc or title|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 Authorization header before it reaches PHP. The Nimbly API validates Bearer tokens via getallheaders()["Authorization"], so without the header, every authenticated API call returns 403 ACCESS_DENIED even with a valid token.

core/cli/setup/htaccess.tpl includes the fix by default (system:setup for new installs; system:repair brings an existing project's .htaccess level with it):

CGIPassAuth On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]

CGIPassAuth On tells Apache to forward the Authorization header to the FPM process. The RewriteRule additionally 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 .htaccess was hand-edited after generation and lost this block, run system:repair --yes to 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.

View this page as Markdown