# Public CMS API

Source: https://docs.qiick.io/externa/public-cms-api

Externa exposes a **headless Public CMS API** under `/api/v1` so websites, apps, and integrations can read (and optionally write) collection data **without** an admin session. Access is controlled by a separate permission model from Spatie admin permissions.

> | Surface                                       | What it controls                                                               | How                                                                                                         |
> | --------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
> | **Admin UI** (`/settings/roles`, `/users`, …) | Who can use the backoffice                                                     | Spatie `PermissionEnum` + `EffectivePermissionResolver`                                                     |
> | **Public CMS API** (`/api/v1/...`)            | Who can create/read/update/delete **collection items** and **files** over HTTP | Tables `collection_permissions` (role × collection × action) and `file_permissions` (role × action, global) |
> 
> Having `can-edit-collections` or `can-show-files` in the admin does **not** grant `/api/v1` access. You must configure **Collection access** and/or **Files access** on a role (usually `public` or a custom role bound to an API key).

Full setup walkthrough is below. Endpoint tables **with request/response examples** and the [Bruno](https://github.com/qiick-io/externa-bruno) companion are at the end. Copyable TypeScript / OpenAPI / JSON Schema: [Public CMS API client types](https://docs.qiick.io/externa/public-cms-api-types). Minimal frontends: [Headless starter](https://docs.qiick.io/externa/headless-starter) (Next.js + Nuxt). Ops: [Performance tuning](https://docs.qiick.io/externa/performance-tuning). Related admin concepts: [Roles & permissions](https://docs.qiick.io/externa/roles-and-permissions), [Effective permissions](https://docs.qiick.io/externa/permissions). **1.x stability / deprecation:** [API & GraphQL compatibility](https://docs.qiick.io/externa/api-compatibility).

> **Bruno collection**
> Ready-to-run requests for every Public CMS + GraphQL path: **[github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno)**. Install [Bruno](https://www.usebruno.com/), open the repo folder as a collection, select env **Local**, copy `.env.sample` → `.env` (`EXTERNA_API_KEY`). Point `base_url` at local or staging. Docs below remain the source of example payloads; Bruno executes them.

## Mental model

```
Request to /api/v1/...
        │
        ▼
┌───────────────────────┐
│ ResolveApiAccess      │
│  • Bearer ek_… ?      │──yes──► validate key → role on api_keys.role_id
│  • no Bearer          │──no───► actor = public role
└───────────────────────┘
        │
        ▼
┌───────────────────────┐
│ CollectionPermission  │  Missing grant = DENY
│ Guard / FilePermission│  Actions: create | read | read_private | update | delete
│ Guard (cached)        │  Files are global; private visibility is per-file
└───────────────────────┘
        │
        ▼
 Controller runs (or 403)
```

| Actor                  | How you authenticate         | Permissions come from                                   |
| ---------------------- | ---------------------------- | ------------------------------------------------------- |
| **Anonymous / public** | No `Authorization` header    | System role `public`                                    |
| **API key**            | `Authorization: Bearer ek_…` | The **role** assigned to that key (never `super-admin`) |

Default deny: if there is **no** row in `collection_permissions` for `(role_id, collection_id, action)`, or in `file_permissions` for `(role_id, action)`, the action is forbidden.

## Quick start (typical website read)

Goal: a public site can `GET` collections/items for `posts`, but cannot create/update/delete.

1. Sign in as an admin who can edit roles (`can-edit-roles`).
2. Open **Roles** → edit the **`public`** role (system role; cannot be deleted, renamed, or assigned to users).
3. In **Collection access**, set **Read** to allowed (✓) for the collections you want public. Leave Create / Update / Delete as denied (✕).
4. Optionally open **Files access** and grant **Read** if the site needs `/api/v1/files*` or expanded image URLs on items. Grant **Read private** only for roles that should see files marked private in the file manager.
5. Save.
6. Call without a key:

```bash
curl -sS http://externa-core.test/api/v1/collections \
  -H 'Accept: application/json'

curl -sS http://externa-core.test/api/v1/collections/posts/items \
  -H 'Accept: application/json'
```

7. Confirm mutations fail:

```bash
curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"data":{}}'
# → 403
```

## Setup guide (full)

### 1. Ensure migrations and seeds ran

You need:

- Role `public` (`is_system`, `is_assignable = false`) from `RoleSeeder`
- Tables `collection_permissions`, `file_permissions`, and `api_keys`
- Spatie permissions `can-show-api-keys` / `can-manage-api-keys` (via `permissions:sync`)

```bash
php artisan migrate
php artisan permissions:sync
php artisan db:seed --class=RoleSeeder   # if public role is missing
```

### 2. Configure anonymous (public) access

| Step              | Where                                                     | What                                                                                              |
| ----------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Edit role         | Admin → **Roles** → **public**                            | **Collection access** and **Files access** are editable (no Spatie permission checkboxes)         |
| Collection matrix | Per collection columns Create / Read / Update / Delete    | ✓ = allowed, ✕ = denied                                                                           |
| Files row         | Global Create / Read / **Read private** / Update / Delete | Applies to all `/api/v1/files*`; `read_private` unlocks files with effective visibility `private` |
| Save              | Persist `collection_permissions` / `file_permissions`     | Guard caches are flushed on sync                                                                  |

> **Public role locks**
> Public role cannot be renamed or deleted, assigned to users or groups, or given Spatie admin permissions. It is only the anonymous API actor.

### 3. Create a role for partners / backends (optional)

For write access or private read without opening the internet to everyone:

1. **Roles** → Create a normal role (e.g. `api-website`, `api-partner`).
2. You may leave Spatie admin permissions empty — they do not affect `/api/v1`.
3. Set **Collection access** for the collections/actions that key should have.
4. Set **Files access** if the key should list/upload/stream files.
5. Save.

### 4. Issue an API key

| Step        | Detail                                                                                                                                                    |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| UI          | Settings → Access → **API Keys** (`/settings/api-keys`)                                                                                                   |
| Permissions | View list: `can-show-api-keys`. Create/revoke: `can-manage-api-keys`                                                                                      |
| Fields      | Name, **role** (not `super-admin`), optional IP allowlist, optional rate limit / expiry, optional scopes (`collection_slugs`, `read_only`, `files_scope`) |
| Secret      | Shown **once** as `ek_…` — store it; Externa only keeps a hash                                                                                            |

Scopes only **narrow** the role (effective = role ACL ∩ key scopes). Rate limits, cost budget, payload caps: [API abuse controls & key scopes](https://docs.qiick.io/externa/api-abuse-controls).

```bash
curl -sS http://externa-core.test/api/v1/collections \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

### 5. Point your client / Bruno

- Base URL: your app URL (e.g. `http://externa-core.test` or staging)
- Paths always under `/api/v1/...`
- Clone **[externa-bruno](https://github.com/qiick-io/externa-bruno)**: open the folder in Bruno, env **Local** (or a copy for staging), `.env` → `EXTERNA_API_KEY`, then set `collection_slug` / `item_id` / `file_id` on requests

## How authentication is resolved

Middleware: `App\Http\Middleware\ResolveApiAccess` on all `routes/api.php` v1 routes.

### No Bearer token → public

- Loads role named `public`
- If missing → **503** (`Public API role is not configured.`)
- Binds `ApiAccess` with `actor = public`

### Bearer token → API key

1. Hash the secret and look up `api_keys`
2. Reject if missing, revoked, or expired → **401**
3. Enforce optional **IP allowlist** → **401** if client IP not listed
4. Enforce **rate limit** (key override or `API_KEY_RATE_LIMIT`, default 60/min) → **429** (`API key rate limit exceeded.`)
5. Reject if linked role is `super-admin` → **401**
6. Bind `ApiAccess` with `actor = api_key` and that role
7. Touch `last_used_at`

Route group also uses Laravel `throttle:api` (60/min by user or IP) and `ApiRequestCostMiddleware` (optional cost budget / body cap). Full table and 429 examples: [API abuse controls](https://docs.qiick.io/externa/api-abuse-controls).

## Collection access matrix

Stored in `collection_permissions`:

| Column          | Meaning                                                  |
| --------------- | -------------------------------------------------------- |
| `role_id`       | Spatie/`roles` id (`public` or custom)                   |
| `collection_id` | Target collection                                        |
| `action`        | `create` \| `read` \| `update` \| `delete`               |
| `allowed`       | Must be true to grant (rows are created only for grants) |

Service: `CollectionPermissionGuard` (cached per role; flushed when the role matrix is saved via `CollectionPermissionSync`).

UI: role create/edit → **Collection access** table. Toggle buttons show **✓** (allowed) or **✕** (denied).

## Files access matrix

Stored in `file_permissions` (global per role — **not** per folder):

| Column    | Meaning                                                      |
| --------- | ------------------------------------------------------------ |
| `role_id` | Spatie/`roles` id (`public` or custom)                       |
| `action`  | `create` \| `read` \| `read_private` \| `update` \| `delete` |
| `allowed` | Must be true to grant (rows are created only for grants)     |

Service: `FilePermissionGuard` / `FilePermissionSync`. UI: role create/edit → **Files access**.

> **Public role pattern**
> For a typical marketing site on the **`public`** role: **Read** = ✓, **Read private** = ✕. Public assets stream from `/api/v1/files*`; on items they expand only with `?include=files`. Private files return **403** on `/api/v1/files/{id}` (and content/transform) and are omitted from list / item expanders. Grant **Read private** only on a custom API-key role when a trusted backend needs private assets.

### Public vs private files

Each file/folder has an `access` override (`files.access`): `public`, `private`, or `null` (inherit from nearest ancestor). Effective visibility walks parents; root with no override is **public**.

| Role grants             | Public file                                              | Private file (effective)                                                  |
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------- |
| no `read`               | Files API 403; item field `{ "id", "access": "denied" }` | Files API 403; item field **`null`** / omitted (no id leak)               |
| `read` only             | List / show / content / full expand                      | Filtered from list; show/content **403**; item field **`null`** / omitted |
| `read` + `read_private` | Same as public                                           | Full access + expand                                                      |

Admin file manager (Spatie `can.manage.files`) always sees all files; use the **Visibility** control on the detail panel (Inherit / Public / Private). Private folders apply to children unless a child overrides.

**Storage:** effective-private local files use the `private_assets` disk (not `/storage/assets/...`). Public CMS clients always receive gated `/api/v1/files/{id}/content` URLs (never a static public object URL for private files). Operators: run `php artisan files:migrate-private-disk` after upgrade if legacy private bytes remain on `assets` — [Files configuration](https://docs.qiick.io/externa/files-configuration).

## Endpoint reference

Base path: `/api/v1`. Controllers under `App\Http\Controllers\Api\V1\`. Examples use `http://externa-core.test` and a sanitized key `ek_YOUR_SECRET` — replace with your Herd host and real key.

Auth header when using an API key (omit for anonymous / `public` role):

```http
Authorization: Bearer ek_YOUR_SECRET
Accept: application/json
```

### Collections

| Method | Path                         | Required action   | Notes                                                 |
| ------ | ---------------------------- | ----------------- | ----------------------------------------------------- |
| `GET`  | `/api/v1/collections`        | _(implicit read)_ | Returns only collections where the actor has **read** |
| `GET`  | `/api/v1/collections/{slug}` | `read`            | Includes field schema summary                         |

#### List collections

```bash
curl -sS http://externa-core.test/api/v1/collections \
  -H 'Accept: application/json'
# optional: -H "Authorization: Bearer ek_YOUR_SECRET"
```

```json
{
  "data": [
    {
      "id": 1,
      "name": "Posts",
      "slug": "posts",
      "is_singleton": false
    }
  ]
}
```

With no read grants: `"data": []` (still **200**, not 403).

#### Get collection

```bash
curl -sS http://externa-core.test/api/v1/collections/posts \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

```json
{
  "data": {
    "id": 1,
    "name": "Posts",
    "slug": "posts",
    "is_singleton": false,
    "fields": [
      {
        "id": 10,
        "name": "title",
        "type": "text",
        "settings": {}
      },
      {
        "id": 11,
        "name": "status",
        "type": "select",
        "settings": {}
      }
    ]
  }
}
```

### Items

| Method   | Path                                     | Required action | Success                                                               |
| -------- | ---------------------------------------- | --------------- | --------------------------------------------------------------------- |
| `GET`    | `/api/v1/collections/{slug}/items`       | `read`          | `200` + `data` + pagination `meta`                                    |
| `GET`    | `/api/v1/collections/{slug}/items/{id}`  | `read`          | `200`                                                                 |
| `POST`   | `/api/v1/collections/{slug}/items`       | `create`        | `201` — body `{ "data": { …field values } }`                          |
| `PATCH`  | `/api/v1/collections/{slug}/items/{id}`  | `update`        | `200` — **partial** `{ "data": { … } }` (omitted fields/locales kept) |
| `DELETE` | `/api/v1/collections/{slug}/items/{id}`  | `delete`        | `204`                                                                 |
| `POST`   | `/api/v1/collections/{slug}/items/batch` | `create`        | `200` — per-entry results (1.4.0, see [Batch writes](#batch-writes))  |
| `PATCH`  | `/api/v1/collections/{slug}/items/batch` | `update`        | `200` — per-entry results                                             |
| `DELETE` | `/api/v1/collections/{slug}/items/batch` | `delete`        | `200` — per-entry results                                             |

#### List items

```bash
curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=15' \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Advanced filter (same dialect as admin / GraphQL):

```bash
curl -sS 'http://externa-core.test/api/v1/collections/posts/items?filter[status][_eq]=published&page=1&per_page=15' \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Also: `filter[title][_contains]=hello`, `filter[status][_in]=published,draft`, `filter[body][_nnull]=1`. See [Advanced list filters](#advanced-list-filters).

```json
{
  "data": [
    {
      "id": 1,
      "collection_id": 3,
      "data": { "title": "Hello", "status": "published" },
      "created_at": "2026-07-20T12:00:00+00:00",
      "updated_at": "2026-07-20T12:00:00+00:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "per_page": 15,
    "total": 1
  }
}
```

#### Get item

```bash
curl -sS http://externa-core.test/api/v1/collections/posts/items/1 \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

```json
{
  "data": {
    "id": 1,
    "collection_id": 3,
    "data": {
      "title": "Hello",
      "status": "published"
    },
    "created_at": "2026-07-20T12:00:00+00:00",
    "updated_at": "2026-07-20T12:00:00+00:00"
  }
}
```

Optional query: `locale`, `include_all_translations`, `include` (see [Expand with `include`](#expand-with-include) and the query table below).

#### Create item

```bash
curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET" \
  -d '{"data":{"title":"New post","status":"draft"}}'
```

```json
{
  "data": {
    "id": 42,
    "collection_id": 3,
    "data": {
      "title": "New post",
      "status": "draft"
    },
    "created_at": "2026-07-20T12:00:00+00:00",
    "updated_at": "2026-07-20T12:00:00+00:00"
  }
}
```

Status **201**. M2M junction values use `{ "related_item_id": 12, "meta": { "sort": 1 } }` — see [M2M junction metadata](#m2m-junction-metadata). Nested blocks payloads: [Field types — Blocks](https://docs.qiick.io/externa/field-types#blocks-field).

#### Update item

```bash
curl -sS -X PATCH http://externa-core.test/api/v1/collections/posts/items/42 \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET" \
  -d '{"data":{"title":"Updated title","status":"published"}}'
```

Response **200** — same item shape as Get item.

From externa-core **1.3.0** ([PR #293](https://github.com/qiick-io/externa-core/pull/293)), `PATCH` is a **partial update**: omitted fields and locales are kept (merged over the assembled current values). Value sync runs in a DB transaction. To clear a field, send `null` explicitly — clients that relied on PATCH wiping omitted fields must change.

#### Delete item

```bash
curl -sS -X DELETE http://externa-core.test/api/v1/collections/posts/items/42 \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Response **204** — empty body (soft-delete).

#### Batch writes

From externa-core **1.4.0** ([#125](https://github.com/qiick-io/externa-core/issues/125)), create, update, or soft-delete up to `API_MAX_BATCH_SIZE` items (default **100**) in one request:

| Method   | Body                                              | Required action |
| -------- | ------------------------------------------------- | --------------- |
| `POST`   | `{ "items": [ { "data": { … } }, … ] }`           | `create`        |
| `PATCH`  | `{ "items": [ { "id": 42, "data": { … } }, … ] }` | `update`        |
| `DELETE` | `{ "ids": [42, 43] }` (distinct)                  | `delete`        |

```bash
curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items/batch \
  -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET" \
  -d '{"items":[{"data":{"title":"One"}},{"data":{"title":""}}]}'
```

```json
{
  "data": [
    {
      "index": 0,
      "ok": true,
      "item": { "id": 51, "collection_id": 3, "data": { "title": "One" } }
    },
    {
      "index": 1,
      "ok": false,
      "error": {
        "message": "The title field is required.",
        "status": 422,
        "errors": { "title": ["The title field is required."] }
      }
    }
  ],
  "meta": { "total": 2, "succeeded": 1, "failed": 1 }
}
```

- Each entry is **atomic on its own**; there is no all-or-nothing transaction across entries. The response is **200** even when some entries fail; check `ok` / `meta.failed`. Delete results carry `id` instead of `item`.
- The collection action is checked once for the request (**403** when missing); item-level rules (item filter, field rules, writable fields) are checked per entry.
- Entries run through the same create/update actions as single writes (validation, draft versioning, per-item activity log) and fire the existing `item.*` [webhooks](https://docs.qiick.io/externa/webhooks) per item. There are no batch webhook event types.
- An empty list or more than `API_MAX_BATCH_SIZE` entries returns **422**. With the cost budget on, a batch costs one mutation (`API_COST_MUTATION`), not one per entry.

GraphQL has no batch mutation.

### Files

| Method   | Path                                  | Required action                      | Notes                                                                                         |
| -------- | ------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- |
| `GET`    | `/api/v1/files`                       | `read`                               | List: `parent_id`, `search`, `page`, `per_page`. Private files omitted without `read_private` |
| `GET`    | `/api/v1/files/{id}`                  | `read` (+ `read_private` if private) | Metadata + content/transform URLs                                                             |
| `GET`    | `/api/v1/files/{id}/content`          | `read` (+ `read_private` if private) | Stream original bytes                                                                         |
| `GET`    | `/api/v1/files/{id}/transforms/{key}` | `read` (+ `read_private` if private) | Project preset or `size-{n}` — [Image transforms](https://docs.qiick.io/externa/image-transforms)                  |
| `POST`   | `/api/v1/files`                       | `create`                             | Multipart upload (`file`)                                                                     |
| `PATCH`  | `/api/v1/files/{id}`                  | `update`                             | Metadata / focal / rename                                                                     |
| `DELETE` | `/api/v1/files/{id}`                  | `delete`                             | Soft delete                                                                                   |

#### List files

```bash
curl -sS 'http://externa-core.test/api/v1/files?page=1&per_page=15' \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

```json
{
  "data": [
    {
      "id": 12,
      "parent_id": null,
      "type": "file",
      "access": "public",
      "filename": "hero.jpg",
      "title": "Hero",
      "description": null,
      "mime_type": "image/jpeg",
      "extension": "jpg",
      "filesize": 204800,
      "width": 1920,
      "height": 1080,
      "focal_point_x": 0.5,
      "focal_point_y": 0.5,
      "url": "http://externa-core.test/api/v1/files/12/content",
      "transforms": {
        "thumbnail": "http://externa-core.test/api/v1/files/12/transforms/thumbnail",
        "card": "http://externa-core.test/api/v1/files/12/transforms/card"
      },
      "created_at": "2026-07-20T12:00:00+00:00",
      "updated_at": "2026-07-20T12:00:00+00:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "per_page": 15,
    "total": 1
  }
}
```

`transforms` keys match Settings → Files & Media presets (defaults: `thumbnail`, `small`, `medium`, `card`). Full guide: [Image transforms](https://docs.qiick.io/externa/image-transforms).

Query: `parent_id`, `search`, `page`, `per_page` (1–100). Private rows are **omitted** without `read_private` (still **200**).

#### Get file

```bash
curl -sS http://externa-core.test/api/v1/files/12 \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Response **200** — `{ "data": { … } }` same shape as a list row. Private file without `read_private` → **403**.

#### Get file content / transform

```bash
curl -sS -OJ http://externa-core.test/api/v1/files/12/content \
  -H "Authorization: Bearer ek_YOUR_SECRET"

curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/thumbnail \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Streams bytes (`Content-Type` from the file / transform). Same auth rules as Get file. Presets, `size-{n}`, focal point, and caching: [Image transforms](https://docs.qiick.io/externa/image-transforms).

#### Upload file

```bash
curl -sS -X POST http://externa-core.test/api/v1/files \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET" \
  -F 'file=@/path/to/image.jpg' \
  -F 'parent_id=' \
  -F 'name='
```

Multipart field `file` (required). Optional `parent_id`, `name`. Response **201** — `{ "data": { …FileResource } }` (same shape as Get file).

#### Update file

```bash
curl -sS -X PATCH http://externa-core.test/api/v1/files/12 \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET" \
  -d '{"title":"Hero","description":"Updated via Public CMS API","focal_point_x":0.35,"focal_point_y":0.6}'
```

Allowed metadata: `title`, `description`, `location`, `download_name`, `focal_point_x` / `focal_point_y` (0–1), `name`. Response **200** — updated FileResource.

#### Delete file

```bash
curl -sS -X DELETE http://externa-core.test/api/v1/files/12 \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Response **204** — empty body (soft delete).

### Expand with `include`

Public REST item list/show responses are **lean by default**. File objects, mini-users, and related items are expanded only when you opt in with `?include=…` (CSV, case-sensitive). GraphQL is unchanged (still expands files). Admin Inertia keeps raw IDs.

> Clients that previously relied on automatic file expansion on `/api/v1/.../items` must add **`include=files`**. Without it, `image` / `file` / `files` values stay as stored IDs (or arrays of IDs).

```http
GET /api/v1/collections/{slug}/items?include=files,author,categories
GET /api/v1/collections/{slug}/items/{id}?include=files
```

| Token / value | Effect                                                                                                                                                        |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `files`       | Expand all `image` / `file` / `files` fields (and file slots inside `blocks`) via `FileFieldExpander`                                                         |
| `users`       | Populate `user_created` / `user_updated` mini-objects (otherwise those keys are `null`; `user_*_id` always present)                                           |
| _field name_  | For relation fields (`relation`, `many_to_one`, `one_to_many`, `many_to_many`, `relation_many`, `relation_tree`, `m2a`): hydrate related items at **depth 1** |

Unknown tokens and non-relation field names are **ignored** (no `400`). Nested hydrated items do **not** re-apply relation includes; they may still apply `files` / `users` if those tokens are in the same `include` list.

From externa-core **1.3.0**, item **list** endpoints batch-prewarm relation targets once per response (`RelationIncludeHydrator::prewarm`, [core#112](https://github.com/qiick-io/externa-core/issues/112)) so depth-1 query count stays flat as parent N grows. Prefer lean `include`s for payload size — [Performance tuning](https://docs.qiick.io/externa/performance-tuning).

#### Default shape (no `include`)

- `data`: raw stored values — files = id or `[id, …]`; relations = int / `[{ related_item_id, meta }]` / m2a links
- `user_created` / `user_updated`: `null`

#### With `include`

| `include`      | Result                                                                                     |
| -------------- | ------------------------------------------------------------------------------------------ |
| `files`        | File fields become objects (same rules as below)                                           |
| `author` (M2O) | `data.author` becomes a nested item resource (no further relation expand)                  |
| `tags` (M2M)   | Each entry becomes `{ "item": { … }, "meta": { … } }` (unreadable related → link only)     |
| `m2a_field`    | Each entry keeps `related_collection_id` / `related_item_id` and adds `item` when readable |

**File expansion** (only with `include=files`):

- With **file:read** on a **public** file: object(s) with `id`, `filename`, `title`, `mime_type`, `width`, `height`, `filesize`, `url`, `transforms`
- Without **file:read** on a **public** file: `{ "id": N, "access": "denied" }` (no URLs) — **breaking** vs older `{ "id": N }` only
- **Private** file without **file:read_private**: `null` (single) or omitted from `files` arrays — **no id or url**
- Soft-deleted / missing files → `null` (or omitted from `files` arrays)

> If a public file field expands to `{ "id": N, "access": "denied" }`, the actor’s role is missing **Files → Read**. Grant that action on the role (Settings → Roles) and retry. Private files still redact to `null` without **Read private** — they never return an id.

**Permissions:** related items hydrate only when readable for the actor (`CollectionPermissionEnforcer`). Unreadable → leave raw id / junction / m2a link (no nested payload).

#### Examples

```bash
# Lean list (file fields are IDs)
curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=15' \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"

# Expand files + author relation + mini-users
curl -sS 'http://externa-core.test/api/v1/collections/posts/items/1?include=files,author,users' \
  -H 'Accept: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

```json
{
  "data": {
    "id": 1,
    "collection_id": 3,
    "data": {
      "title": "Hello",
      "cover": {
        "id": 12,
        "filename": "hero.jpg",
        "title": "Hero",
        "mime_type": "image/jpeg",
        "width": 1920,
        "height": 1080,
        "filesize": 204800,
        "url": "http://externa-core.test/api/v1/files/12/content",
        "transforms": {
          "thumbnail": "http://externa-core.test/api/v1/files/12/transforms/thumbnail",
          "card": "http://externa-core.test/api/v1/files/12/transforms/card"
        }
      },
      "author": {
        "id": 7,
        "collection_id": 2,
        "data": { "name": "Ada" },
        "created_at": "2026-07-20T12:00:00+00:00",
        "updated_at": "2026-07-20T12:00:00+00:00",
        "user_created_id": null,
        "user_updated_id": null,
        "user_created": null,
        "user_updated": null
      }
    },
    "created_at": "2026-07-20T12:00:00+00:00",
    "updated_at": "2026-07-20T12:00:00+00:00",
    "user_created_id": 1,
    "user_updated_id": 1,
    "user_created": { "id": 1, "name": "Admin", "email": "admin@example.com" },
    "user_updated": { "id": 1, "name": "Admin", "email": "admin@example.com" }
  }
}
```

TypeScript / OpenAPI shapes: [Public CMS API client types](https://docs.qiick.io/externa/public-cms-api-types#include-query). Bruno: List / Get Item with `include` query.

### Query parameters (items)

| Param                      | Used on     | Purpose                                                                                                                                                                                                  |
| -------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `page`, `per_page`         | list        | Pagination (`per_page` clamped 1–100, default 15)                                                                                                                                                        |
| `filter[...]`              | list        | Field filters (same idea as admin item query service), plus system columns and depth-1 relational keys (1.4.0) — [Advanced list filters](#advanced-list-filters)                                         |
| `sort`                     | list        | Up to 3 comma-separated keys, `-` = desc (1.4.0) — [Sort](#sort-core-1-4-0)                                                                                                                              |
| `locale`                   | list / show | Locale for flattened translatable fields. Must be an **enabled** project content locale; otherwise **422**. Missing/empty slots walk the [locale fallback chain](https://docs.qiick.io/externa/configuration#locale-fallback) |
| `include_all_translations` | list / show | When true, include enabled locales only in `data` (orphans for disabled locales are omitted from the response but kept in DB); no value fallback walk                                                    |
| `include`                  | list / show | CSV opt-in expand: `files`, `users`, and/or relation **field names** (depth 1). See [Expand with `include`](#expand-with-include)                                                                        |

With `?locale=` (or the resolved active locale) and `include_all_translations` unset/false, each top-level translatable field is a single scalar: first usable value along requested → enabled `fallback_content_locales` → `APP_FALLBACK_LOCALE` (if enabled) → `null`. Empty strings are treated as missing. Nested block fields stay as per-locale maps.

### Permission → behaviour cheat sheet

| Grants on role    | List collections    | Get collection / items | Create  | Update  | Delete  |
| ----------------- | ------------------- | ---------------------- | ------- | ------- | ------- |
| _(none)_          | `[]` empty list     | **403**                | **403** | **403** | **403** |
| `read` only       | includes collection | **200**                | **403** | **403** | **403** |
| `read` + `create` | yes                 | yes                    | **201** | **403** | **403** |
| `read` + `update` | yes                 | yes                    | **403** | **200** | **403** |
| `read` + `delete` | yes                 | yes                    | **403** | **403** | **204** |
| all four          | full CRUD           | yes                    | yes     | yes     | yes     |

> **List vs show**
> `GET /collections` never 403s for missing read: it simply omits collections you cannot read. `GET /collections/{slug}` and item routes **do** 403 when read is missing.

## HTTP errors

| Status | Typical cause                                                                                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Bad/revoked/expired key, IP not allowlisted, key on `super-admin`                                                                                                                     |
| `403`  | Role lacks the required collection or file action; or origin gate (`Origin not allowed.` / `Origin required or API key.`) when `public_api_allowed_origins` is set                    |
| `404`  | Unknown collection slug or item id                                                                                                                                                    |
| `422`  | Validation failed on create/update payload; invalid `sort`, system-column filter, or relational filter depth (1.4.0)                                                                  |
| `413`  | `API_MAX_BODY_BYTES` set and `Content-Length` over the cap (`Request body too large.`)                                                                                                |
| `429`  | API key rate limit exceeded, route `throttle:api`, or optional cost budget exceeded (`API cost budget exceeded.` + `Retry-After`) — [API abuse controls](https://docs.qiick.io/externa/api-abuse-controls) |
| `503`  | `public` role missing from DB                                                                                                                                                         |

## CORS and config

| Item           | Detail                                                                                                                                             |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Routes         | `routes/api.php`, prefix `api` (`bootstrap/app.php`)                                                                                               |
| CORS (env)     | `config/cors.php` — paths `api/*`; origins from `CORS_ALLOWED_ORIGINS` (default `*`) when project allowlist is empty                               |
| CORS (project) | Non-empty `public_api_allowed_origins` → effective CORS for `api/*` = that list (`ConfigurePublicApiCors`)                                         |
| Origin gate    | Non-empty allowlist → `EnforcePublicApiOrigin` on `/api/v1/*` and `/api/graphql` (browser Origin must match; no Origin → valid API key required)   |
| Rate default   | `config/api.php` ← `API_KEY_RATE_LIMIT` (default `60`); see also cost / GraphQL / payload caps — [API abuse controls](https://docs.qiick.io/externa/api-abuse-controls) |

```env
API_KEY_RATE_LIMIT=60
CORS_ALLOWED_ORIGINS=https://www.example.com,https://staging.example.com
```

Configure the project allowlist in **Settings → Project → Public API** (not only via env). Full behavior, Bruno notes, and security limits: [Client types — Origin allowlist](https://docs.qiick.io/externa/public-cms-api-types#origin-allowlist).

## Admin UI surfaces

| Screen                        | Path                        | Purpose                                                                                             |
| ----------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------- |
| Role edit → Files access      | `/settings/roles/{id}/edit` | Global file API create/read/`read_private`/update/delete                                            |
| Role edit → Collection access | `/settings/roles/{id}/edit` | Grant/deny create/read/update/delete per collection                                                 |
| API Keys                      | `/settings/api-keys`        | Create keys (secret once), revoke                                                                   |
| File manager search           | `/files`                    | Search is **server-side only** (`GET /files/list?search=`); load-more stays enabled while searching |
| File picker                   | drawer                      | Same BE search; supports load-more pagination                                                       |

Admin Spatie permissions for the API Keys screen:

```
can-show-api-keys
can-manage-api-keys
```

## Bruno collection (externa-bruno)

Ready-to-use companion: **[https://github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno)** — classic Bruno layout (`bruno.json` + folders). Docs above are the source of example payloads; Bruno executes them.

1. Install [Bruno](https://www.usebruno.com/), clone / open the `externa-bruno` folder as a collection
2. Select environment **Local** (`environments/Local.bru`; default `base_url` = `http://externa-core.test`)
3. Copy `.env.sample` → `.env`, set `EXTERNA_API_KEY` (and `EXTERNA_WEBHOOK_SECRET` if needed)
4. Set request vars `collection_slug` / `item_id` / `file_id` as needed
5. **Staging / remote:** duplicate **Local** in Bruno, set `base_url` (no trailing slash) and the matching key in `.env`
6. Folder **PublicApi** inherits Bearer auth from `folder.bru` (except **List Collections Anonymous**, which uses `auth: none`)
7. If **Allowed origins** is set in project settings, Bruno calls without an Origin header **require** a valid `api_key` (anonymous requests will 403)
8. Subfolders: **Files**, **GraphQL**, **Webhooks** (`Verify Webhook Signature` — sample payload / signing helper; default `__webhook_receiver_docs_only__` URL is a placeholder and **404s** on externa-core; see [Outbound webhooks](https://docs.qiick.io/externa/webhooks))

Each request includes a **docs** panel describing method, auth, required permission, and expected status codes. Full open notes: [externa-bruno README](https://github.com/qiick-io/externa-bruno#open-in-bruno).

## Tests

- Pest feature suites: `PublicCollectionApiTest`, `PublicFilesApiTest`, `PublicApiOriginGateTest`
- Cover anonymous deny/allow per action, API key gating, origin allowlist, file content/transform 403 without read, item `?include=` (files / relations / users), upload warm job, and focal cover.

```bash
# Use PHP 8.4 if your shell `php` is older (Herd):
php84 artisan test --filter=PublicCollectionApiTest
php84 artisan test --filter=PublicFilesApiTest
php84 artisan test --filter=PublicApiOriginGateTest
```

## Source map

| Concern         | Location                                                                                               |
| --------------- | ------------------------------------------------------------------------------------------------------ |
| Routes          | `routes/api.php`                                                                                       |
| Auth middleware | `app/Http/Middleware/ResolveApiAccess.php`                                                             |
| Origin / CORS   | `EnforcePublicApiOrigin`, `ConfigurePublicApiCors`, `Support\Api\PublicApiOrigin`                      |
| Guard / sync    | `CollectionPermissionGuard` / `CollectionPermissionSync`, `FilePermissionGuard` / `FilePermissionSync` |
| Controllers     | `app/Http/Controllers/Api/V1/*`                                                                        |
| Models          | `ApiKey`, `CollectionPermission`, `FilePermission`, `Role` (`isPublic`, system flags)                  |
| Admin API keys  | `app/Http/Controllers/Admin/ApiKeyController.php`                                                      |
| Config          | `config/api.php`, `config/cors.php`; project key `public_api_allowed_origins`                          |
| Bruno           | [github.com/qiick-io/externa-bruno](https://github.com/qiick-io/externa-bruno)                         |
| Pest            | `tests/Feature/Api/PublicApiOriginGateTest.php`                                                        |

## Related pages

- [Public CMS API client types](https://docs.qiick.io/externa/public-cms-api-types) — TypeScript, OpenAPI 3, JSON Schema downloads
- [Roles & permissions](https://docs.qiick.io/externa/roles-and-permissions) — editing roles and the Collection access matrix
- [Effective permissions](https://docs.qiick.io/externa/permissions) — admin Spatie model (separate from this API)
- [Outbound webhooks](https://docs.qiick.io/externa/webhooks) — Externa → your URL (HMAC); not the same as API keys
- [Collections API](https://docs.qiick.io/externa/collections-api) — **session** admin routes under `/collections` (not `/api/v1`)
- [Routing overview](https://docs.qiick.io/externa/routing) — how `api.php` is registered
- [Testing](https://docs.qiick.io/externa/testing) — Pest layout including `Feature/Api`

## Field-level rules & item filters

`collection_permissions.rules` (JSON) on each Collection access grant (admin Roles UI → **Fields & item filter**):

```json
{
  "fields": {
    "title": { "read": true, "create": true, "update": true },
    "status": { "read": true, "create": false, "update": false }
  },
  "item_filter": {
    "logic": "and",
    "rules": [{ "field": "status", "operator": "equals", "value": "published" }]
  }
}
```

| Rule                                | Effect                                                                     |
| ----------------------------------- | -------------------------------------------------------------------------- |
| Empty `fields` map                  | All fields allowed (backward compatible)                                   |
| `fields.*.read: false`              | Stripped from list/show responses                                          |
| `fields.*.create` / `update: false` | Rejected on write (403/422 path via guard)                                 |
| `item_filter`                       | Hides non-matching items on list/show (404) and blocks update/delete (403) |

Operators for `item_filter`: `equals`, `not_equals`, `empty`, `not_empty` (AND logic only). Applied for Public REST, GraphQL, and non–super-admin admin item access via `CollectionPermissionGuard` / `CollectionPermissionRules`.

Pest: `tests/Feature/Api/CollectionPermissionRulesTest.php`.

## GraphQL (`/api/graphql`)

Dedicated guide: **[GraphQL](https://docs.qiick.io/externa/graphql)**.

Summary: Lighthouse endpoint at **`/api/graphql` only** (route name `graphql`; no `/graphql` app route). Same `ResolveApiAccess` + collection guards as REST. Queries: `collections`, `collection(slug)`, `items`, `item`. Mutations: `createItem`, `updateItem`, `deleteItem` (soft-delete). Filter dialect matches REST/admin (`perPage` camelCase). Bruno: [externa-bruno](https://github.com/qiick-io/externa-bruno) `PublicApi/GraphQL/`. Pest: `tests/Feature/Api/GraphqlCollectionApiTest.php`.

## M2M junction metadata

Many-to-many values are objects (breaking change from bare ints):

```json
{ "related_item_id": 12, "meta": { "sort": 1 } }
```

Bare ints are still accepted on write and normalized to `{ related_item_id, meta: {} }`. Optional `settings.junction_fields` on the field defines the meta mini-schema (admin field settings → Junction fields JSON). See [Field types](https://docs.qiick.io/externa/field-types) and [Collection items](https://docs.qiick.io/externa/collection-items).

## Advanced list filters

Same dialect as admin items index / GraphQL `filter` arg — see [Collections API](https://docs.qiick.io/externa/collections-api#advanced-list-filters). Example: `?filter[status][_eq]=published`. Bruno: [externa-bruno](https://github.com/qiick-io/externa-bruno) `List Items Advanced Filter`.

### System-column filters (core 1.4.0)

From [#333](https://github.com/qiick-io/externa-core/issues/333), REST and GraphQL item lists also filter on the item's own columns:

| Key                                        | Operators                                              | Value                                                                        |
| ------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `id`                                       | `_eq`, `_neq`, `_in`, `_gt`, `_gte`, `_lt`, `_lte`     | Positive integer (`_in`: CSV or array)                                       |
| `created_at`, `updated_at`, `published_at` | `_eq`, `_gt`, `_gte`, `_lt`, `_lte`, `_null`, `_nnull` | ISO 8601 (`2026-01-01` = start of that day, UTC); `published_at` may be null |

```text
?filter[published_at][_gte]=2026-01-01&filter[id][_in]=1,2,3
```

A system key wins over a collection field with the same name. An unsupported operator or a non-ISO value returns **422**.

### Relational filters (depth 1, core 1.4.0)

From [#124](https://github.com/qiick-io/externa-core/issues/124), filter on a field of a related item with a dotted key `relationField.relatedField`:

```text
?filter[author.name][_eq]=Ada
?filter[tags.slug][_in]=news,tech
```

- Works on many-to-one, one-to-many, and many-to-many relation fields, on SQLite, MySQL/MariaDB, and PostgreSQL ([#436](https://github.com/qiick-io/externa-core/issues/436)). Multi-relations match exact related ids and check every linked item.
- **Depth is capped at 1**: `a.b.c` returns **422** (`Relational filter depth may not exceed 1`).
- The related collection must be readable by the caller (same gate as `?include=`), and the role's item filter on the related collection applies. An unreadable related collection matches **nothing** instead of leaking values.
- Unknown relation or related field names are ignored, like unknown plain field names.
- Each dotted key adds `API_COST_FILTER_RELATION` (default **2**) to the request cost when the cost budget is on — [API abuse controls](https://docs.qiick.io/externa/api-abuse-controls).

### Sort (core 1.4.0)

From [#224](https://github.com/qiick-io/externa-core/issues/224), REST `?sort=` takes up to **3** comma-separated keys; `-` means descending:

```text
?sort=-published_at,title
```

| Accepted keys                                            | Not sortable                                                                                         |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| System: `id`, `created_at`, `updated_at`, `published_at` | Relation fields, array-storage fields, `image`, `files`, `map`, `m2a`, `wysiwyg`, `markdown`, `code` |
| Other collection fields the role can read                |                                                                                                      |

Nulls sort last, ties break on `id` descending, and the default (no `sort`) stays `id` descending. Invalid tokens, unknown fields, non-sortable fields, fields hidden by field rules, or more than 3 keys return **422**. GraphQL takes the same keys as `sort: [String!]`. Item payloads now include `published_at`, and new indexes on `(collection_id, published_at | created_at | updated_at)` back these sorts.
