HTTP reference
Public CMS API client types
TypeScript interfaces, OpenAPI 3, and JSON Schema for integrating with the headless Public CMS API (/api/v1). Shapes match CollectionItemResource, Api\V1\FileResource, and the V1 collection/item/file controllers.
Related
Setup, auth, and endpoint examples: Public CMS API. GraphQL: GraphQL. Runnable requests: externa-bruno.
Downloads
Static files (raw / downloadable from this docs site):
| Artifact | Path | Use |
|---|---|---|
| TypeScript | /client-types/types.ts | Copy into your app or generate clients |
| OpenAPI 3 | /client-types/openapi.yaml | Import into Postman, Insomnia, codegen |
| JSON Schema | /client-types/schema.json | Validate create/update item & file bodies |
Source of truth for behaviour remains the Public CMS API guide and externa-core resources — keep these artifacts in sync when response shapes change.
Covered types
| Area | Types / schemas |
|---|---|
| Collections | CollectionSummary, CollectionResource, CollectionFieldSummary |
| Items | CollectionItemResource, CreateCollectionItemRequest, UpdateCollectionItemRequest, list meta |
| Files | FileResource, UpdateFileRequest, expanded item file fields (ExpandedFileField) |
| Envelopes | DataEnvelope, PaginatedEnvelope, ApiErrorEnvelope |
| Helpers | PaginationMeta, MiniUser, M2mJunctionValue, FileFieldValue, nested include shapes (ExpandedM2mRelationEntry, ExpandedM2aRelationEntry) |
Item data is Record<string, unknown> / additionalProperties because field schemas are per-collection. Tighten locally from your collection field definitions.
TypeScript (copy-paste)
/**
* Externa Public CMS API (`/api/v1`) — client TypeScript types.
* Full file: /client-types/types.ts
*/
export interface PaginationMeta {
current_page: number
last_page: number
per_page: number
total: number
}
export interface MiniUser {
id: number
name: string
email: string | null
}
export interface CollectionSummary {
id: number
name: string
slug: string
is_singleton: boolean
}
export interface CollectionFieldSummary {
id: number
name: string
type: string
settings: Record<string, unknown> | null
}
export interface CollectionResource {
id: number
name: string
slug: string
is_singleton: boolean
fields: CollectionFieldSummary[]
}
export interface M2mJunctionValue {
related_item_id: number
meta?: Record<string, unknown>
}
/** Nested item payload when a relation field is listed in `?include=` (depth 1). */
export interface NestedCollectionItemResource {
id: number
collection_id: number
data: Record<string, unknown>
created_at: string | null
updated_at: string | null
user_created_id: number | null
user_updated_id: number | null
user_created: MiniUser | null
user_updated: MiniUser | null
}
/** M2M entry after `include=<field>` when the related item is readable. */
export interface ExpandedM2mRelationEntry {
item: NestedCollectionItemResource
meta: Record<string, unknown>
}
/** M2A entry after `include=<field>` when the related item is readable. */
export interface ExpandedM2aRelationEntry {
related_collection_id: number
related_item_id: number
item: NestedCollectionItemResource
}
export interface ExpandedFileField {
id: number
filename: string
title: string | null
mime_type: string | null
width: number | null
height: number | null
filesize: number | null
url: string
transforms: Record<string, string>
}
/** Public file expanded without file:read — machine-readable denial (no URLs). */
export type FileFieldAccessDenied = { id: number; access: 'denied' }
/** @deprecated Prefer FileFieldAccessDenied — legacy id-only shape. */
export type FileFieldIdOnly = FileFieldAccessDenied
/**
* Value of an image/file field on a Public API item response.
* With `include=files`: ExpandedFileField | FileFieldAccessDenied | null.
* Without `include=files`: raw stored id(s) (`number` / `number[]`).
* Private files without read_private are `null` (or omitted from `files` arrays).
* If you see `access: "denied"`, grant Files → Read on the role.
*/
export type FileFieldValue =
ExpandedFileField | FileFieldAccessDenied | number | number[] | null
/**
* Collection item resource (`CollectionItemResource`).
* `data` keys match collection field names.
* Lean by default on REST; expand with `?include=files|users|<relationField>`.
* Without `include=users`, `user_created` / `user_updated` are `null`.
*/
export interface CollectionItemResource {
id: number
collection_id: number
data: Record<string, unknown>
created_at: string | null
updated_at: string | null
user_created_id: number | null
user_updated_id: number | null
user_created: MiniUser | null
user_updated: MiniUser | null
}
export interface CreateCollectionItemRequest {
data: Record<string, unknown>
}
export interface UpdateCollectionItemRequest {
data: Record<string, unknown>
}
export interface FileResource {
id: number
parent_id: number | null
type: 'file' | 'folder'
access: 'public' | 'private'
filename: string
title: string | null
description: string | null
mime_type: string | null
extension: string | null
filesize: number | null
width: number | null
height: number | null
focal_point_x: number | null
focal_point_y: number | null
url: string | null
transforms: Record<string, string>
created_at: string | null
updated_at: string | null
}
export interface UpdateFileRequest {
title?: string | null
description?: string | null
location?: string | null
download_name?: string | null
focal_point_x?: number | null
focal_point_y?: number | null
name?: string
}
export interface DataEnvelope<T> {
data: T
}
export interface PaginatedEnvelope<T> {
data: T[]
meta: PaginationMeta
}
export type CollectionsListResponse = DataEnvelope<CollectionSummary[]>
export type CollectionShowResponse = DataEnvelope<CollectionResource>
export type ItemsListResponse = PaginatedEnvelope<CollectionItemResource>
export type ItemShowResponse = DataEnvelope<CollectionItemResource>
export type FilesListResponse = PaginatedEnvelope<FileResource>
export type FileShowResponse = DataEnvelope<FileResource>
export interface ApiErrorEnvelope {
message: string
errors?: Record<string, string[]>
}
Create / update payloads
Create item — POST /api/v1/collections/{slug}/items
{
"data": {
"title": "New post",
"status": "draft"
}
}
Update item — PATCH /api/v1/collections/{slug}/items/{id}
{
"data": {
"title": "Updated title",
"status": "published"
}
}
M2M fields accept junction objects (bare ints still work on write):
{
"data": {
"related_posts": [{ "related_item_id": 12, "meta": { "sort": 1 } }]
}
}
Update file metadata — PATCH /api/v1/files/{id}
{
"title": "Hero",
"description": "Updated via Public CMS API",
"focal_point_x": 0.35,
"focal_point_y": 0.6
}
Uploads use multipart/form-data (file required; optional parent_id, name) — see OpenAPI.
JSON Schema definitions: /client-types/schema.json (CreateCollectionItemRequest, UpdateCollectionItemRequest, UpdateFileRequest, plus response $defs).
Main response envelopes
List collections
type CollectionsListResponse = { data: CollectionSummary[] }
Get collection
type CollectionShowResponse = { data: CollectionResource }
List items / files
type ItemsListResponse = {
data: CollectionItemResource[]
meta: PaginationMeta
}
type FilesListResponse = { data: FileResource[]; meta: PaginationMeta }
Show / create / update item or file
type ItemShowResponse = { data: CollectionItemResource }
type FileShowResponse = { data: FileResource }
Delete — HTTP 204, empty body.
include query
Opt-in expand on item list and show (GET …/items, GET …/items/{id}):
?include=files,users,author,categories
| Token | Effect |
|---|---|
files | Expand image / file / files (and block file slots) |
users | Fill user_created / user_updated mini-users |
| field name | Depth-1 hydrate for relation / m2a fields with that name |
Without include, file fields stay as IDs and mini-users are null. Nested relation shapes: NestedCollectionItemResource, ExpandedM2mRelationEntry ({ item, meta }), ExpandedM2aRelationEntry ({ related_collection_id, related_item_id, item }). Unknown tokens ignored. Full behaviour: Public CMS API — Expand with include.
Errors (Laravel-style):
{ "message": "…" }
Validation (422) may include "errors": { "field": ["…"] }.
OpenAPI
Full path coverage for collections, items, and files (including content/transforms): /client-types/openapi.yaml.
Import example:
# codegen / tooling that accepts OpenAPI 3
curl -fsSL https://YOUR_DOCS_HOST/client-types/openapi.yaml -o externa-public-cms.openapi.yaml
Origin allowlist
Project setting public_api_allowed_origins (Settings → Project → Public API) gates browser access to /api/v1/* and /api/graphql, and drives effective CORS when the list is non-empty.
UI setup
- Open Settings → Project (
can-manage-project-settings). - Under Public API → Allowed origins, enter one origin per line (or comma/whitespace-separated), e.g.:
https://www.example.com
https://app.example.com
http://localhost:3000
- Save. Origins must be
http/httpswith host (and optional non-default port). No path, query, fragment, or credentials —https://www.example.com/blogis rejected.
Leave the field empty to disable the gate (typical local/OSS DX). CORS then falls back to env CORS_ALLOWED_ORIGINS (default *). See Project settings and Public CMS API — CORS.
Behavior
Applies only when the allowlist is non-empty. Middleware: EnforcePublicApiOrigin (REST + GraphQL). Matching Origin alone does not bypass collection/file grants — those still apply.
| Client | Origin header | Outcome when allowlist is set |
|---|---|---|
| Browser from an allowed site | Matches allowlist | Continues (anonymous public role or Bearer key as usual) |
| Browser / tool with wrong Origin | Present but not in list | 403 { "message": "Origin not allowed." } |
| Bruno, curl, SSR, server jobs | Absent | 403 { "message": "Origin required or API key." } unless a valid, active Bearer API key is sent |
| Same, with valid API key | Absent | Continues (then IP allowlist / role grants on the key still apply) |
When the allowlist is non-empty, ConfigurePublicApiCors overrides cors.allowed_origins for api/* to that list (preflight only echoes an allowed Origin). When empty, env CORS_ALLOWED_ORIGINS applies unchanged.
Browser vs Bruno / API key
// Browser (Origin sent automatically by the browser)
const res = await fetch(`${baseUrl}/api/v1/collections`, {
credentials: 'omit',
})
// Server / Bruno / curl — no Origin → send API key when allowlist is on
const res = await fetch(`${baseUrl}/api/v1/collections`, {
headers: { Authorization: `Bearer ${process.env.EXTERNA_API_KEY}` },
})
In externa-bruno, set api_key via collection .env (EXTERNA_API_KEY). Folder Bearer auth covers most Public API requests; List Collections Anonymous uses auth: none and will 403 when the allowlist is active unless you add a key or send a matching Origin header for experiments.
Security honesty
Origin and CORS are browser policy tools. A scraper or script can spoof the Origin header. The gate raises the bar against “any random website can call your Public API from a visitor’s browser,” but it is not strong authentication.
For stronger protection, combine:
- A non-public role + API key (required for non-browser clients when the allowlist is on)
- Optional IP allowlist on the API key
- Collection/file grants (and field ACL /
item_filter) as usual
Do not treat an empty allowlist + CORS_ALLOWED_ORIGINS=* as production-hardened CORS.
Related
- Public CMS API — auth, matrices, endpoint reference, CORS
- GraphQL — same origin gate + guards, different transport
- Field types — value shapes for
data - Project settings —
public_api_allowed_origins, locales, transforms