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, andselectfields withsearchable: trueand column-backed storage. textfields nested inside a JSONBobject()field when the nested field itself is markedsearchable: 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