REST API

Complete reference for all API endpoints.

CMS exposes a REST API at /api for all CRUD operations, authentication, and media uploads.

By default, all endpoints require authentication. The auth-flow routes (/api/auth/request, /api/auth/create, /api/auth/logout, /api/health) always pass through so the login flow can bootstrap.

To make entity data publicly accessible — e.g. for a website fetching content without per-request credentials — define a role with public: true in your config. Unauthenticated requests then run as that role, and its permissions decide what's reachable. A typical setup grants read with a filter: { status: "published" } so anonymous callers see only published rows. See Permissions for the full shape.

API reference

The CMS can self-document its API at runtime based on the loaded config. Opt-in via config.openapi: true — off by default because building the spec at boot adds startup cost and TypeScript consumers using @cms/client already get typed access.


          // cms.config.ts
export default config({
  openapi: true,
  // ...
});
        

When enabled:

| Endpoint | Description | |---|---| | GET /openapi.json | OpenAPI 3.1 spec (machine-readable) | | GET /docs | Interactive API explorer (Scalar UI) |

The spec includes all entity routes, auth, assets, and API key endpoints, with request/response schemas derived from the config. Use it to import into Postman/Insomnia, generate typed clients in other languages (Python, Go, Ruby), or browse available endpoints.

When disabled, both endpoints return 404. Re-enable any time without code changes.

Entity endpoints

Every entity gets these endpoints automatically:


          GET    /api/:entity
GET    /api/:entity/:id
POST   /api/:entity
PUT    /api/:entity/:id
DELETE /api/:entity/:id
        

List records


          GET /api/posts?filter[status]=published&sort=-createdAt&limit=10
        

Response (200):


          {
  "data": [
    { "id": "...", "title": "...", "status": "published" }
  ],
  "meta": {
    "total": 42,
    "limit": 10,
    "offset": 0
  }
}
        

Get single record


          GET /api/posts/uuid-here?resolve[author]=name,bio
        

Response (200):


          {
  "data": {
    "id": "uuid-here",
    "title": "My Post",
    "author": {
      "id": "author-uuid",
      "name": "Pedro",
      "bio": "Design engineer"
    }
  }
}
        

Returns 404 if the record doesn't exist.

Create record


          POST /api/posts
Content-Type: application/json

{
  "title": "New Post",
  "slug": "/blog/new-post",
  "status": "draft"
}
        

Response (201):


          {
  "data": {
    "id": "new-uuid",
    "title": "New Post",
    "slug": "/blog/new-post",
    "status": "draft",
    "createdAt": "2025-06-01T00:00:00Z",
    "updatedAt": "2025-06-01T00:00:00Z"
  }
}
        

Update record


          PUT /api/posts/uuid-here
Content-Type: application/json

{
  "title": "Updated Title"
}
        

Response (200): Returns the full updated record. Only send the fields you want to change.

Delete record


          DELETE /api/posts/uuid-here
        

Response (200):


          {
  "data": { "id": "uuid-here" }
}
        

Deletes the record and any related junction table rows.

Globals endpoints

Globals are mounted in the same /api namespace as entities, but expose only two endpoints (no ID, no list, no delete):


          GET /api/:global
PUT /api/:global
        

Get global


          GET /api/site-settings
        

Response (200):


          {
  "data": {
    "id": "uuid",
    "siteName": "My Site",
    "siteDescription": "...",
    "logo": "media-uuid",
    "createdAt": "...",
    "updatedAt": "..."
  }
}
        

Returns { "data": null } if the global has required fields and has never been saved.

Update global


          PUT /api/site-settings
Content-Type: application/json

{ "siteName": "New Name" }
        

Response (200): Returns the full updated record. Performs an upsert — creates the row if it doesn't exist yet.

Search endpoint


          GET /api/search?q=<query>&limit=<n>
        

Searches across all entities the caller has read permission for.

| Parameter | Type | Default | Description | |---|---|---|---| | q | string | — | Required. The search term (substring match, case-insensitive). | | limit | integer | 5 | Max results per entity. Clamped to 1–20. |

Which fields are searched:

  • Top-level text, slug, and select fields with searchable: true and column-backed storage.
  • text fields nested inside a JSONB object() field when the nested field itself is marked searchable: true.

Only fields explicitly marked with searchable: true are searched — for nested fields inside object() fields, that means the nested field itself, not the parent object() field. The option defaults to false.

Response (200):


          {
  "query": "hello",
  "results": [
    {
      "entity": "posts",
      "global": false,
      "document": {
        "id": "uuid",
        "_entity": "posts",
        "createdAt": "2025-06-01T00:00:00Z",
        "updatedAt": "2025-06-01T00:00:00Z",
        "title": "Hello world"
      }
    }
  ]
}
        

Each document includes id, _entity, createdAt, updatedAt, and all searchable fields the caller's role can see. Results per entity are ordered by updatedAt descending.

Permissions: Role-based access is fully honoured — entities the caller cannot read are excluded from results, permission filters are applied, and fields the caller's role excludes are omitted from each document.

Authentication endpoints


          POST   /api/auth/request
POST   /api/auth/create
POST   /api/auth/logout
GET    /api/auth/me
POST   /api/auth/refresh
        

Authentication uses a two-step email OTP flow — there are no passwords.

Request login code


          POST /api/auth/request
Content-Type: application/json

{ "email": "admin@example.com" }
        

Response (200): Always returns 200 — the server sends a one-time code to the email if the user exists, but the response is the same either way to prevent user enumeration.

Create session


          POST /api/auth/create
Content-Type: application/json

{
  "authRequestId": "uuid-from-email",
  "code": "123456"
}
        

Response (200):


          {
  "data": {
    "user": {
      "id": "user-uuid",
      "email": "admin@example.com",
      "name": "Admin",
      "role": "admin"
    }
  }
}
        

Sets a cms_session HTTP-only cookie. Returns 401 if the code is invalid or expired.

API key authentication

For headless use, pass a Bearer token instead of a session cookie:


          Authorization: Bearer <token>
        

If a Bearer token is present, it must be valid — the server returns 401 immediately on an invalid token. There is no fallback to the session cookie.

Logout


          POST /api/auth/logout
        

Clears the session cookie.

Current user


          GET /api/auth/me
        

Returns the authenticated user and their role. Returns 401 if not authenticated.

Refresh session


          POST /api/auth/refresh
        

Extends the session expiration.

Translation endpoint

For translatable entities:


          POST /api/pages/uuid-here/translate
Content-Type: application/json

{ "locale": "pt" }
        

Creates a translation of the record in the target locale.

Asset upload


          POST /api/assets/upload
Content-Type: multipart/form-data

file: <binary>
alt: "Optional description"  (stored only for images)
        

The file is routed to the correct entity by MIME type: image/*_image, video/*_video, everything else → _file. Returns the created asset record with image dimensions extracted automatically (for images). Deduplicates by SHA-256 hash within the same entity.

See the Assets guide for full response shapes and querying.

Image transforms


          GET /api/assets/:filename?w=400&format=webp
GET /api/assets/:filename?w=800&fit=cover&format=avif&q=75
        

Serves the original file with no query params, or a transformed image with w, fit, format, and q parameters. See Assets guide for full parameter reference.

Health check


          GET /api/health
        

Returns 200 when healthy:


          { "status": "ok", "db": "ok" }
        

Returns 503 when the database is unreachable:


          { "status": "degraded", "db": "unreachable" }
        

TypeScript client

@cms/client provides a typed wrapper around the REST API. Install it in your frontend project:


          npm install @cms/client
        

Setup


          import { createClient } from "@cms/client";
import type { InferEntitiesSchema } from "@cms/types";
import type cmsConfig from "./cms.config";

type Schema = InferEntitiesSchema<typeof cmsConfig>;

export const cms = createClient<Schema>({
  url: "https://your-cms.example.com",
  apiKey: process.env.CMS_API_KEY,
});
        

InferEntitiesSchema covers all entities — both collections and globals (defined with global: true).

Collection methods


          // Find one record matching a filter (returns null if not found)
const { data: post } = await cms.find("posts", {
  filter: { slug: "/blog/my-post" },
});

// List records with filtering, sorting, and pagination
const { data: posts, meta } = await cms.list("posts", {
  filter: { status: "published" },
  sort: "-createdAt",
  limit: 10,
  offset: 0,
});

// Get a record by ID
const { data: post } = await cms.get("posts", "uuid-here");

// Create
const { data: created } = await cms.create("posts", {
  title: "New Post",
  status: "draft",
});

// Update
const { data: updated } = await cms.update("posts", "uuid-here", {
  title: "Updated Title",
});

// Delete
await cms.delete("posts", "uuid-here");
        

Global methods

Globals use the same methods as collections but without an ID:


          // Get a global (returns null if never saved and has required fields)
const { data: settings } = await cms.get("site-settings");

// Update a global (upserts)
const { data: updated } = await cms.update("site-settings", {
  siteName: "My Site",
});
        

The client detects the call pattern at runtime: two arguments (name + params) → global, three arguments (name + id + params) → collection.

Runtime config


          // Update client options at runtime (e.g. switch API key)
cms.config({ apiKey: newKey });
        

Raw requests


          // Escape hatch for endpoints not covered by the client methods
const result = await cms.request<MyType>("/api/custom-path", {
  method: "POST",
  body: { foo: "bar" },
});
        

Admin config


          GET /api/admin/config
        

Returns the full CMS configuration for the admin UI (entity definitions, field types, locales, admin config). Stripped of hooks and internals.

Rate limiting

All endpoints are rate-limited per IP:

| Scope | Limit | |---|---| | General API | 200 requests/minute | | Auth endpoints (/api/auth/*) | 10 requests/minute |

When exceeded, the server returns 429 Too Many Requests with a Retry-After header.

Rate limiting is in-memory and per-process — it is not shared across multiple instances. For production deployments with multiple processes, use a reverse-proxy rate limiter (nginx, Cloudflare) in front of the CMS.

Error responses

All errors follow a consistent format:


          {
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      { "field": "title", "message": "Required" },
      { "field": "slug", "message": "Must be unique" }
    ]
  }
}
        

Error codes

| Code | HTTP Status | When | |---|---|---| | VALIDATION_ERROR | 400 | Invalid input data | | VERSION_INCOMPATIBLE | 400 | Stored version's data no longer parses against the current schema | | UNAUTHORIZED | 401 | Missing or invalid session | | FORBIDDEN | 403 | Role lacks permission | | NOT_FOUND | 404 | Document or entity not found | | CONFLICT | 409 | Unique constraint violation | | TOO_MANY_REQUESTS | 429 | Rate limit exceeded | | INTERNAL_ERROR | 500 | Unexpected server error |

Previous

Deployment

Next

Client