Server APIs
REST API
Endpoints, query parameters, response envelopes and status codes.
The HTTP layer is transport only: it parses the query string, resolves the user, calls the Local API, wraps the result in an envelope and maps typed errors to status codes.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/:collection |
List documents |
POST |
/api/v1/:collection |
Create (JSON, or multipart on upload: true) |
GET |
/api/v1/:collection/:id |
Read one |
PUT |
/api/v1/:collection/:id |
Update (partial) |
DELETE |
/api/v1/:collection/:id |
Delete |
GET |
/api/v1/collections |
Schema metadata for every collection |
POST |
/api/auth/login |
{ email, password } → { token, user } |
GET |
/api/auth/me |
The current user |
GET/POST/PUT/DELETE |
/api/auth/users[/:id] |
User management (admin only) |
GET |
/api/status |
Adapter / API health |
Envelopes
// list
{ "data": [ /* … */ ], "meta": { "count": 10, "totalDocs": 42, "page": 1, "totalPages": 5, "hasNextPage": true, "hasPrevPage": false } }
// single item (create → 201)
{ "data": { "id": "…", "title": "…" } }
// delete → 204 No Content
// error
{ "error": "title is required", "details": [{ "field": "title", "message": "title is required", "code": "required" }] }
Write bodies (POST/PATCH/preview) carry content fields and, on a drafts collection, _status.
id, created_at, updated_at and _storageKey are Forge's: a POST containing any of them is a
400 INVALID_INPUT, and a PATCH may only echo their stored values back (echoes are dropped).
Round-tripping the document you read therefore works. See
Local API → System metadata.
meta.count is the length of this page; meta.totalDocs is the total matching the query. Clients
(@forge-cms/angular, the admin UI) depend on this envelope — do not change it in a fork without
changing them.
Query parameters
Filtering
Any parameter that is not reserved is a filter. Bare field=value is equality; field[op]=value
names an operator. Values are coerced to the field's declared type.
curl "…/api/v1/products?category=facial"
curl "…/api/v1/products?price[gte]=50&price[lte]=200"
curl "…/api/v1/products?id[in]=a,b,c"
curl "…/api/v1/products?name[contains]=laser" # case-insensitive
Operators: eq, ne, gt, gte, lt, lte, in (comma-separated), contains, containsValue
(relation-array membership, relation({ many: true }) fields only). An unknown operator is a 400.
Nested queries (?where=)
For anything beyond flat implicit-AND, pass a URL-encoded JSON object as where — it replaces
the flat filter parameters above for that request:
curl -G "…/api/v1/posts" --data-urlencode 'where={
"and": [
{ "status": "published" },
{ "or": [{ "featured": true }, { "views": { "gte": 1000 } }] }
]
}'
Strictly validated: malformed JSON, a non-object value, an unknown field, an unknown operator, an
empty and: []/or: [], or nesting deeper than 6 levels are all a 400 — nothing reaches SQL
unvalidated, and no adapter/SQL detail leaks into the error message. The parameter is capped at 4096
characters.
Sorting
curl "…/api/v1/posts?sort=publishedAt&order=desc"
A field name must be a declared field, id/created_at/updated_at, or _status (on a
drafts: true collection); anything else is a 400. order must be asc or desc.
For a multi-field sort, pass a URL-encoded JSON array instead of a plain field name — each entry
carries its own order, and the standalone order parameter is ignored:
curl -G "…/api/v1/posts" --data-urlencode 'sort=[{"field":"featured","order":"desc"},{"field":"created_at","order":"desc"}]'
Pagination
curl "…/api/v1/posts?limit=10&offset=20"
Read meta.totalPages / meta.hasNextPage rather than counting pages yourself.
Relation population
curl "…/api/v1/posts?depth=1"
depth=1 replaces relation and upload ids with the referenced documents (one batched query per
relation field). 0 and 1 are the only accepted values — multi-level population is not
implemented.
Draft visibility
On a drafts: true collection:
curl "…/api/v1/posts" # published only (public)
curl "…/api/v1/posts?status=draft" # requires authentication
curl "…/api/v1/posts?status=all" # requires authentication
Reserved parameter names — limit, offset, sort, order, depth, status, where — cannot be
used as filter field names.
Authentication
TOKEN=$(curl -s -X POST …/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"forgecms-demo"}' | jq -r .data.token)
curl …/api/v1/posts -H "Authorization: Bearer $TOKEN"
Send the token on reads too, not just writes: without it you are anonymous, which means no drafts and field-level read rules resolve as "not logged in".
Status codes
| Code | When |
|---|---|
200 |
OK |
201 |
Created |
204 |
Deleted |
400 |
Validation failed (details), malformed query, bad JSON/multipart, a write of id/created_at/updated_at/_storageKey (INVALID_INPUT) |
401 |
Authentication required, or access denied while unauthenticated |
403 |
Authenticated but not permitted (including field-level writes) |
403 |
AUTH_MANAGED_COLLECTION: create/update/delete of a collection an auth adapter manages (e.g. users) — use the auth user-management routes; reads are unaffected |
404 |
Unknown collection or id — also an id an access rule hides |
409 |
UNIQUE_CONSTRAINT: a unique field/index value (or an id) is already taken — details.fields names it |
409 |
CONCURRENT_MODIFICATION: another write to the same versioned document committed first; nothing was saved — reload and retry |
413 |
PAYLOAD_TOO_LARGE: a login/signup body over the auth bound (8 KiB by default) |
429 |
RATE_LIMITED: the host's login/signup throttle refused the attempt; may carry Retry-After |
500 |
Unexpected |
Mounting the handlers
The routes are yours; the handlers are the package's. In Analog/Nitro:
// src/server/routes/api/v1/[collection].get.ts
import { defineEventHandler, getRouterParam, toWebRequest } from 'h3';
import type { ApiContext } from '@forge-cms/api';
import { handleList } from '@forge-cms/runtime';
import { getServerRuntime } from '../../../api/runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
const context: ApiContext = {
request: toWebRequest(event),
params: { collection: getRouterParam(event, 'collection') ?? '' },
env: event.context.cloudflare?.env
};
return handleList(context, { runtime });
});
Handlers: handleList, handleRead, handleCreate, handleUpdate, handleDelete, plus
handleFile for stored bytes. Each takes { runtime, requireAuth?, allowedRoles? }:
return handleCreate(context, { runtime, allowedRoles: ['admin', 'editor'] });
That role gate is coarse and route-level; a collection's own access rule overrides it for that
operation. Keep the routes thin — logic belongs in the Local API.
Schema metadata
GET /api/v1/collections returns what the admin builds its forms from:
{
"data": [
{
"slug": "posts",
"name": "Posts",
"drafts": true,
"upload": false,
"fieldDefinitions": [
{ "name": "title", "kind": "text", "label": "Title", "required": true },
{
"name": "category",
"kind": "relation",
"label": "Category",
"required": false,
"relation": { "collection": "categories", "many": false }
}
]
}
]
}
Composite fields carry their nested structure (fields, blocks, minRows, maxRows), which is
why the admin can render arbitrarily nested groups, arrays and blocks without knowing your schema at
build time.
What the metadata endpoint does not list. Metadata describes shape, never values, and it does not advertise internal storage: a field that no role can read or write
(access: { read: [], write: [] } — for example the users collection's passwordHash and _sessionVersion) is omitted for every caller, anonymous included (since 0.13.3; before it the
two names appeared in anonymous metadata, never their values). Admin forms are unaffected because nobody could edit those fields. Field-level access rules that depend on the caller
still apply to values on every read.