# 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:

```bash
curl -X POST "/api/v1/auth/token" \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "yourpassword"}'
```

Response:
```json
{
  "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):
>
> ```apache
> 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.

```bash
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`:

```json
{
  "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

```json
{
  "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:

```json
"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:

```bash
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`:

```bash
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:

```json
{
  "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`:

```json
{
  "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](/en/docs/reference/api/openapi.json) describes the existing generic resource, token and file-upload interfaces. It is also available at [/reference/api/openapi.json](/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](/en/docs/learn/events/) demonstrates authorized reads and denied access against the isolated example.
