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

ArtifactPathUse
TypeScript/client-types/types.tsCopy into your app or generate clients
OpenAPI 3/client-types/openapi.yamlImport into Postman, Insomnia, codegen
JSON Schema/client-types/schema.jsonValidate 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

AreaTypes / schemas
CollectionsCollectionSummary, CollectionResource, CollectionFieldSummary
ItemsCollectionItemResource, CreateCollectionItemRequest, UpdateCollectionItemRequest, list meta
FilesFileResource, UpdateFileRequest, expanded item file fields (ExpandedFileField)
EnvelopesDataEnvelope, PaginatedEnvelope, ApiErrorEnvelope
HelpersPaginationMeta, 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
TokenEffect
filesExpand image / file / files (and block file slots)
usersFill user_created / user_updated mini-users
field nameDepth-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

  1. Open Settings → Project (can-manage-project-settings).
  2. 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
  1. Save. Origins must be http/https with host (and optional non-default port). No path, query, fragment, or credentialshttps://www.example.com/blog is 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.

ClientOrigin headerOutcome when allowlist is set
Browser from an allowed siteMatches allowlistContinues (anonymous public role or Bearer key as usual)
Browser / tool with wrong OriginPresent but not in list403 { "message": "Origin not allowed." }
Bruno, curl, SSR, server jobsAbsent403 { "message": "Origin required or API key." } unless a valid, active Bearer API key is sent
Same, with valid API keyAbsentContinues (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.

Previous
Public CMS API