Browse documentation

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.