# Public CMS API client types

Source: https://docs.qiick.io/externa/public-cms-api-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](https://docs.qiick.io/externa/public-cms-api). GraphQL: [GraphQL](https://docs.qiick.io/externa/graphql). Runnable requests: [externa-bruno](https://github.com/qiick-io/externa-bruno).

## Downloads

Static files (raw / downloadable from this docs site):

| Artifact    | Path                                                               | Use                                       |
| ----------- | ------------------------------------------------------------------ | ----------------------------------------- |
| TypeScript  | [`/client-types/types.ts`](https://docs.qiick.io/externa/client-types/types.ts)         | Copy into your app or generate clients    |
| OpenAPI 3   | [`/client-types/openapi.yaml`](https://docs.qiick.io/externa/client-types/openapi.yaml) | Import into Postman, Insomnia, codegen    |
| JSON Schema | [`/client-types/schema.json`](https://docs.qiick.io/externa/client-types/schema.json)   | Validate create/update item & file bodies |

**Runtime (your Externa instance):** `GET /api/v1/openapi.json` — same OpenAPI 3 document as JSON, no API key (and outside the Public API origin allowlist). Prefer this when generating clients against a specific install.

**Sync policy:** keep `externa-core` `resources/openapi/public-cms-v1.yaml` identical to docs `public/client-types/openapi.yaml` whenever Public CMS API shapes change ([API 1.x compatibility](https://docs.qiick.io/externa/api-compatibility)). Behaviour SoT remains the [Public CMS API](https://docs.qiick.io/externa/public-cms-api) guide and core controllers.

## TypeScript SDK (`@qiick/externa-sdk`)

> `@qiick/externa-sdk` lives in externa-core under `packages/sdk` (version **0.1.0**, [#119](https://github.com/qiick-io/externa-core/issues/119)) but is **not on npm yet** — `npm install @qiick/externa-sdk` will fail until a maintainer publishes it. Until then, use the types on this page or build the package from a core checkout.

A typed, server-side client for the Public CMS API. Types are generated from `resources/openapi/public-cms-v1.yaml` (the same document as `GET /api/v1/openapi.json`), and core CI fails when they drift.

| Method                             | Endpoint                                                                                   |
| ---------------------------------- | ------------------------------------------------------------------------------------------ |
| `listCollections()`                | `GET /api/v1/collections`                                                                  |
| `getCollection(slug)`              | `GET /api/v1/collections/{slug}`                                                           |
| `listItems(slug, params)`          | `GET …/items` — `sort`, `perPage`, `locale`, `include`, `includeAllTranslations`, `filter` |
| `getItem(slug, id)`                | `GET …/items/{id}`                                                                         |
| `listFiles(params)`, `getFile(id)` | `GET /api/v1/files`, `GET /api/v1/files/{id}`                                              |

Errors throw `ExternaApiError`. The 0.1.0 client covers reads only (no create/update/delete or batch helpers).

```ts
import { createClient } from '@qiick/externa-sdk'

const externa = createClient({
  baseUrl: process.env.EXTERNA_URL!,
  apiKey: process.env.EXTERNA_API_KEY!, // ek_… — server-side only
})

const { data: items, meta } = await externa.listItems('posts', {
  sort: '-published_at,title',
  perPage: 10,
})
```

Keep the API key out of browser bundles: call the client from a server route, worker, or CLI.

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

```ts
/**
 * 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`

```json
{
  "data": {
    "title": "New post",
    "status": "draft"
  }
}
```

### Update item — `PATCH /api/v1/collections/{slug}/items/{id}`

```json
{
  "data": {
    "title": "Updated title",
    "status": "published"
  }
}
```

M2M fields accept junction objects (bare ints still work on write):

```json
{
  "data": {
    "related_posts": [{ "related_item_id": 12, "meta": { "sort": 1 } }]
  }
}
```

### Update file metadata — `PATCH /api/v1/files/{id}`

```json
{
  "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`](https://docs.qiick.io/externa/client-types/schema.json) (`CreateCollectionItemRequest`, `UpdateCollectionItemRequest`, the 1.4.0 batch bodies `BatchCreateCollectionItemsRequest` / `BatchUpdateCollectionItemsRequest` / `BatchDeleteCollectionItemsRequest`, `UpdateFileRequest`, plus response `$defs`). `types.ts` also exports the batch response types (`BatchWriteItemsResponse`, `BatchDeleteItemsResponse`) and `ItemSortQuery`.

## Main response envelopes

**List collections**

```ts
type CollectionsListResponse = { data: CollectionSummary[] }
```

**Get collection**

```ts
type CollectionShowResponse = { data: CollectionResource }
```

**List items / files**

```ts
type ItemsListResponse = {
  data: CollectionItemResource[]
  meta: PaginationMeta
}
type FilesListResponse = { data: FileResource[]; meta: PaginationMeta }
```

**Show / create / update item or file**

```ts
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`](https://docs.qiick.io/externa/public-cms-api#expand-with-include).

**Errors** (Laravel-style):

```json
{ "message": "…" }
```

Validation (`422`) may include `"errors": { "field": ["…"] }`.

## OpenAPI

Full path coverage for collections, items, and files (including content/transforms): [`/client-types/openapi.yaml`](https://docs.qiick.io/externa/client-types/openapi.yaml). Same document from Core: `GET /api/v1/openapi.json` ([core #25](https://github.com/qiick-io/externa-core/issues/25)).

Import examples:

```bash
# From your Externa Core instance (preferred for codegen against that install)
curl -fsSL https://YOUR_EXTERNA_ORIGIN/api/v1/openapi.json -o externa-public-cms.openapi.json

# From this docs site (YAML)
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.:

```text
https://www.example.com
https://app.example.com
http://localhost:3000
```

3. Save. Origins must be `http`/`https` with host (and optional non-default port). **No path, query, fragment, or credentials** — `https://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](https://docs.qiick.io/externa/project-settings) and [Public CMS API — CORS](https://docs.qiick.io/externa/public-cms-api#cors-and-config).

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

```ts
// 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](https://github.com/qiick-io/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](https://docs.qiick.io/externa/public-cms-api) — auth, matrices, endpoint reference, CORS
- [GraphQL](https://docs.qiick.io/externa/graphql) — same origin gate + guards, different transport
- [Field types](https://docs.qiick.io/externa/field-types) — value shapes for `data`
- [Project settings](https://docs.qiick.io/externa/project-settings) — `public_api_allowed_origins`, locales, transforms
