HTTP reference
MarkdownPublic 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.
Two permission systems
| 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 companion are at the end. Copyable TypeScript / OpenAPI / JSON Schema: Public CMS API client types. Minimal frontends: Headless starter (Next.js + Nuxt). Ops: Performance tuning. Related admin concepts: Roles & permissions, Effective permissions. 1.x stability / deprecation: API & GraphQL compatibility.
Bruno collection
Ready-to-run requests for every Public CMS + GraphQL path: github.com/qiick-io/externa-bruno. Install Bruno, 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.
- Sign in as an admin who can edit roles (
can-edit-roles). - Open Roles → edit the
publicrole (system role; cannot be deleted, renamed, or assigned to users). - In Collection access, set Read to allowed (✓) for the collections you want public. Leave Create / Update / Delete as denied (✕).
- 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. - Save.
- Call without a key:
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'
- Confirm mutations fail:
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) fromRoleSeeder - Tables
collection_permissions,file_permissions, andapi_keys - Spatie permissions
can-show-api-keys/can-manage-api-keys(viapermissions:sync)
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:
- Roles → Create a normal role (e.g.
api-website,api-partner). - You may leave Spatie admin permissions empty — they do not affect
/api/v1. - Set Collection access for the collections/actions that key should have.
- Set Files access if the key should list/upload/stream files.
- 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.
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.testor staging) - Paths always under
/api/v1/... - Clone externa-bruno: open the folder in Bruno, env Local (or a copy for staging),
.env→EXTERNA_API_KEY, then setcollection_slug/item_id/file_idon 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
ApiAccesswithactor = public
Bearer token → API key
- Hash the secret and look up
api_keys - Reject if missing, revoked, or expired → 401
- Enforce optional IP allowlist → 401 if client IP not listed
- Enforce rate limit (key override or
API_KEY_RATE_LIMIT, default 60/min) → 429 (API key rate limit exceeded.) - Reject if linked role is
super-admin→ 401 - Bind
ApiAccesswithactor = api_keyand that role - 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.
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.
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):
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
curl -sS http://externa-core.test/api/v1/collections \
-H 'Accept: application/json'
# optional: -H "Authorization: Bearer ek_YOUR_SECRET"
{
"data": [
{
"id": 1,
"name": "Posts",
"slug": "posts",
"is_singleton": false
}
]
}
With no read grants: "data": [] (still 200, not 403).
Get collection
curl -sS http://externa-core.test/api/v1/collections/posts \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"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) |
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
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):
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.
{
"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
curl -sS http://externa-core.test/api/v1/collections/posts/items/1 \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"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 and the query table below).
Create item
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"}}'
{
"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. Nested blocks payloads: Field types — Blocks.
Update item
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), 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
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), 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 |
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":""}}]}'
{
"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 carryidinstead ofitem. - 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 per item. There are no batch webhook event types. - An empty list or more than
API_MAX_BATCH_SIZEentries 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 |
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
curl -sS 'http://externa-core.test/api/v1/files?page=1&per_page=15' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"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.
Query: parent_id, search, page, per_page (1–100). Private rows are omitted without read_private (still 200).
Get file
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
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.
Upload file
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
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
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.
Breaking: files are no longer expanded by default
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).
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) so depth-1 query count stays flat as parent N grows. Prefer lean includes for payload size — Performance tuning.
Default shape (no include)
data: raw stored values — files = id or[id, …]; relations = int /[{ related_item_id, meta }]/ m2a linksuser_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 fromfilesarrays — no id or url - Soft-deleted / missing files →
null(or omitted fromfilesarrays)
`access: denied`
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
# 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"
{
"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. 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 |
sort | list | Up to 3 comma-separated keys, - = desc (1.4.0) — Sort |
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 |
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 |
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 |
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 |
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.
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 — classic Bruno layout (bruno.json + folders). Docs above are the source of example payloads; Bruno executes them.
- Install Bruno, clone / open the
externa-brunofolder as a collection - Select environment Local (
environments/Local.bru; defaultbase_url=http://externa-core.test) - Copy
.env.sample→.env, setEXTERNA_API_KEY(andEXTERNA_WEBHOOK_SECRETif needed) - Set request vars
collection_slug/item_id/file_idas needed - Staging / remote: duplicate Local in Bruno, set
base_url(no trailing slash) and the matching key in.env - Folder PublicApi inherits Bearer auth from
folder.bru(except List Collections Anonymous, which usesauth: none) - If Allowed origins is set in project settings, Bruno calls without an Origin header require a valid
api_key(anonymous requests will 403) - 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)
Each request includes a docs panel describing method, auth, required permission, and expected status codes. Full open notes: externa-bruno README.
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.
# 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 |
| Pest | tests/Feature/Api/PublicApiOriginGateTest.php |
Related pages
- Public CMS API client types — TypeScript, OpenAPI 3, JSON Schema downloads
- Roles & permissions — editing roles and the Collection access matrix
- Effective permissions — admin Spatie model (separate from this API)
- Outbound webhooks — Externa → your URL (HMAC); not the same as API keys
- Collections API — session admin routes under
/collections(not/api/v1) - Routing overview — how
api.phpis registered - 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):
{
"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.
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 PublicApi/GraphQL/. Pest: tests/Feature/Api/GraphqlCollectionApiTest.php.
M2M junction metadata
Many-to-many values are objects (breaking change from bare ints):
{ "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 and Collection items.
Advanced list filters
Same dialect as admin items index / GraphQL filter arg — see Collections API. Example: ?filter[status][_eq]=published. Bruno: externa-bruno List Items Advanced Filter.
System-column filters (core 1.4.0)
From #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 |
?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, filter on a field of a related item with a dotted key relationField.relatedField:
?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). Multi-relations match exact related ids and check every linked item.
- Depth is capped at 1:
a.b.creturns 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.
Sort (core 1.4.0)
From #224, REST ?sort= takes up to 3 comma-separated keys; - means descending:
?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.