HTTP API
RESTful endpoints for querying, creating, updating, and managing your content over HTTP.
The HTTP API gives you full CRUD access to documents, assets, and schemas. All document and asset endpoints require authentication via session or API key.
Live API reference
Your running studio describes its own API. Two endpoints, both generated from the instance itself:
| Path | What it is |
|---|---|
/api/docs | A rendered, browsable reference. Open it while signed in to the admin. |
/api/openapi.json | The OpenAPI 3.1 document behind it — feed this to a client generator or an HTTP tool. |
This page stays the prose explanation of how the API behaves. The generated reference is the authoritative list of what exists and what it takes, because it is built at request time from the same sources the server runs on:
- Endpoint shapes come from the zod contracts in
packages/cms-core/src/lib/api/schemas/— the very objects each handler validates with, so a parameter cannot drift from its documentation. - Document shapes come from your own
schemaTypes. A static file could only ever describedraftDataas an untyped object; the generated spec emits a realPostData/PageDatacomponent per collection, with the samerequiredfields the validator enforces.
That second half is the reason it's generated per instance rather than checked in — your content model is yours, so no shipped file could describe it.
Generate a typed client
Because the document is standard OpenAPI 3.1, the usual tooling works:
curl -H "x-api-key: $APHEX_API_KEY" \
https://your-app.com/api/openapi.json -o openapi.json
npx openapi-typescript openapi.json -o src/lib/aphex-api.d.tsBoth endpoints require authentication. /api/openapi.json enumerates your entire content model, which is not something to hand to anonymous callers even though it carries no content; /api/docs is a static shell that fetches that spec from the browser with your session, so signed out it would only render a failure anyway. Signed out, it redirects you to the login page rather than answering 401 — the response is HTML for a person.
The reference UI loads Scalar from a public CDN — the one thing it does that the JSON endpoint doesn't. On an instance that shouldn't pull third-party scripts, unmount it, after which it answers 404:
export default defineConfig({
openapi: { docsUi: false }
});That script is pinned to an exact version with a Subresource Integrity hash, because the page is same-origin with your admin — whatever runs there runs with the signed-in user's cookies. If the CDN ever serves different bytes, the browser refuses to execute them and the page renders empty rather than running something unreviewed.
/api/openapi.json is unaffected either way.
Base URL
All endpoints are relative to your app's origin:
https://your-app.com/api/...Reserved URLs
Three different layers handle requests under /api/*, and they're picked in this order:
1. SvelteKit handle hook → Better Auth (/api/auth/*)
2. SvelteKit filesystem router → specific +server.ts files
3. Catch-all (/api/[...slug]) → forwards into Hono → CMS routesSvelteKit's filesystem router prefers more-specific paths over rest parameters. Hono only sees what falls through the catch-all — it does not take precedence over a sibling +server.ts. That means if you create apps/studio/src/routes/api/documents/+server.ts, your file silently shadows the CMS's /api/documents handler and the CMS code never runs.
To wrap or replace a CMS route safely, use the api(app) config hook — it registers your handler inside Hono itself, where you can call await next() to chain to the built-in handler.
The full list of URLs the CMS owns. (/api/docs renders this same list from the live route table, so if the two ever disagree, believe the generated one.)
Documents
| Method | Path | Notes |
|---|---|---|
GET | /api/documents | List with filters and pagination. |
POST | /api/documents | Create a draft. |
POST | /api/documents/query | Advanced query — read-only. |
GET | /api/documents/:id | Read a single document. |
PUT | /api/documents/:id | Update draft data. |
DELETE | /api/documents/:id | Delete (draft or both). |
POST | /api/documents/:id/publish | Publish draft to published. |
DELETE | /api/documents/:id/publish | Unpublish. |
GET | /api/documents/:id/versions | List versions. |
GET | /api/documents/:id/versions/:version | Read a specific version. |
POST | /api/documents/:id/versions/:version/restore | Restore a version into the draft. |
GET | /api/documents/by-ids | Batch lookup, up to 100 ids. |
GET | /api/documents/:id/back-references | Documents referencing this one. |
GET | /api/documents/:id/schedule | Read the pending schedule. |
POST | /api/documents/:id/schedule | Schedule a publish / unpublish. |
DELETE | /api/documents/:id/schedule | Cancel a pending schedule. |
Assets
| Method | Path | Notes |
|---|---|---|
GET | /api/assets | List with filters. |
POST | /api/assets | Upload (multipart/form-data). |
DELETE | /api/assets/bulk | Bulk delete by id list. |
POST | /api/assets/references/counts | Reference counts for a list of asset ids. |
GET | /api/assets/:id | Read asset metadata. |
PATCH | /api/assets/:id | Update metadata (title, alt, credit, etc). |
DELETE | /api/assets/:id | Delete an asset. |
GET | /api/assets/:id/references | List documents referencing this asset. |
POST | /api/assets/upload-url | Presigned direct upload (upload.direct). |
POST | /api/assets/confirm | Register an asset after a direct upload. |
POST | /api/assets/:id/poster | Attach a poster image to a video asset. |
Organizations
| Method | Path | Notes |
|---|---|---|
GET | /api/organizations | List orgs the caller belongs to. |
POST | /api/organizations | Create an organization. |
POST | /api/organizations/switch | Switch active org context. |
GET | /api/organizations/members | List members of the active org. |
PATCH | /api/organizations/members | Update a member's role. |
DELETE | /api/organizations/members | Remove a member. |
POST | /api/organizations/invitations | Send an invitation. |
DELETE | /api/organizations/invitations | Cancel an invitation. |
GET | /api/organizations/:id | Read a specific org. |
PATCH | /api/organizations/:id | Update org name / slug / metadata. |
DELETE | /api/organizations/:id | Delete an org (super admin / owner). |
Roles
| Method | Path | Notes |
|---|---|---|
GET | /api/roles | List built-in + custom org roles. |
POST | /api/roles | Create a custom role. |
PATCH | /api/roles/:name | Update an existing role's capabilities. |
DELETE | /api/roles/:name | Delete a custom role. |
Schemas
| Method | Path | Notes |
|---|---|---|
GET | /api/schemas | All registered schemas (used by the studio shell). |
GET | /api/schemas/:type | A single schema. |
User account
| Method | Path | Notes |
|---|---|---|
PATCH | /api/user | Update profile (name, email). |
GET | /api/user/cms-preference | Read editor preferences (sidebar state, etc). |
PATCH | /api/user/cms-preference | Update editor preferences. |
POST | /api/user/request-password-reset | Trigger a password-reset email. |
POST | /api/user/reset-password | Complete a password reset with the emailed token. |
Jobs and events
Read-only observability over the event, queue and job spine, plus the two operator actions that make a dead letter recoverable. ?scope=all widens a read from the active organization to the whole instance and is super-admin only.
| Method | Path | Notes |
|---|---|---|
GET | /api/jobs | List queue jobs, filterable by status and type. |
GET | /api/jobs/health | Queue health counters. |
POST | /api/jobs/:id/retry | Requeue a failed or dead-lettered job. |
POST | /api/jobs/:id/cancel | Cancel a pending job. |
GET | /api/events | The append-only fact log. Immutable — there is no write path. |
POST | /api/internal/workers/run | Run one tick. Bearer jobs.workerSecret; 404 when unset. |
Plugin settings
Session only — these reject API keys. Secret values are returned redacted and encrypted at rest.
| Method | Path | Notes |
|---|---|---|
GET | /api/plugin-settings | Read settings for the active org. |
PUT | /api/plugin-settings/:plugin | Save one plugin's settings. |
Meta
| Method | Path | Notes |
|---|---|---|
GET | /api/docs | Rendered API reference. Unmount with openapi.docsUi: false. |
GET | /api/openapi.json | The OpenAPI 3.1 document. Authenticated. |
GET | /api/aphex-health | Unauthenticated health check. See the note below. |
Two health endpoints, different answers
/api/aphex-health is the CMS built-in: the database decides the status code, storage is
reported but never fails the check (it degrades to status: "degraded" with HTTP 200), and
storage is only probed when storageHealthCheck is enabled.
The templates ship and deploy a different probe at /healthz, which returns
{ok, db, storage} and 503s when either is unhealthy. That is the one every
deployment guide configures. Point your platform's health check at /healthz
unless you have a reason to prefer the other semantics.
Outside Hono
| Path | Owned by |
|---|---|
/api/auth/* | Better Auth — handled by svelteKitHandler in hooks.server.ts, intercepts before the filesystem router runs. |
/api/graphql | The GraphQL endpoint. Configurable via graphql.path in aphex.config.ts. |
/api/instance-settings | Studio +server.ts (super-admin gated). See God Mode. |
/media/:id/:filename | Studio +server.ts — asset CDN handler. Lives at /media/*, not /api/media/*. |
Picking a safe path for custom endpoints
When you mount your own +server.ts under /api/*, avoid these prefixes: /api/auth, /api/documents, /api/assets, /api/organizations, /api/roles, /api/schemas, /api/user, /api/graphql, /api/instance-settings. The convention some teams adopt to be future-proof:
- Namespace custom routes under a stable prefix, e.g.
/api/app/*or/api/v1/*. - For one-off webhooks:
/api/webhooks/<provider>(Stripe, Slack, GitHub). - For internal tooling:
/api/admin/*or/api/internal/*.
If you ever need to claim a URL the CMS already owns — to wrap, replace, or extend a built-in handler — use the api(app) config hook. That's the only safe path to do so without forking cms-core.
Authentication
Include an API key in the x-api-key header:
curl -H "x-api-key: your_key_here" \
https://your-app.com/api/documents?type=postIf no x-api-key header is present, the API falls back to session authentication (cookies). When both are available, the API key takes precedence.
Each API key is scoped to the organization that was active when the key was created. All requests made with that key only see and modify data within that organization. See API Keys for details on organization scoping and parent–child hierarchy access.
Write protection
Mutating requests (POST, PUT, PATCH, DELETE) require write permission. API keys with only read permission receive a 403 response on mutations.
The one exception is POST /api/documents/query, which is treated as a read operation (it uses POST only because the filter payload can be complex).
Response format
Successful responses share a consistent envelope:
{
"success": true,
"data": { ... },
"pagination": { ... }
}Errors come in two shapes depending on which layer rejected the request:
// Validation / business errors (most 400s, 404s)
{
"success": false,
"error": "Bad Request",
"message": "Detailed error message",
"issues": [ ... ] // present when zod validation failed
}// Auth / permission middleware (401, 403)
{
"error": "Unauthorized"
}The middleware path is intentionally minimal — it short-circuits before the route handler, so it doesn't carry success or message. Treat both shapes as errors when the HTTP status is ≥ 400.
Documents
List documents
GET /api/documents?type={type}| Parameter | Type | Default | Description |
|---|---|---|---|
type | string | required | Document type (e.g. post, page). |
status | string | - | Filter by draft or published. |
perspective | string | 'draft' | Which version to return: 'draft' or 'published'. |
page | number | 1 | Page number. |
pageSize | number | 20 | Results per page. Alias: limit. |
sort | string | - | Sort field. Prefix with - for descending (e.g. -publishedAt). |
depth | number | 0 | Reference resolution depth (0–5). |
includeChildOrganizations | boolean | false | Include documents from child organizations. |
There is no filterOrganizationIds query parameter. It was documented as one, and it is not: the
request schema doesn't accept it, so passing it is silently ignored. The option exists on the
Local API, where the engine fills it in from the caller's own organization hierarchy — a
tenant naming arbitrary organization IDs over HTTP is exactly what it must not allow. Use
includeChildOrganizations to widen a read to the organizations below the one the request is
authenticated for.
curl -H "x-api-key: your_key" \
"https://your-app.com/api/documents?type=post&perspective=published&pageSize=10&sort=-publishedAt"Response:
{
"success": true,
"data": [ ... ],
"pagination": {
"total": 42,
"page": 1,
"pageSize": 10,
"totalPages": 5,
"hasNextPage": true,
"hasPrevPage": false
}
}Get document by ID
GET /api/documents/{id}| Parameter | Type | Default | Description |
|---|---|---|---|
perspective | string | 'draft' | 'draft' or 'published'. |
depth | number | 0 | Reference resolution depth (0–5). |
curl -H "x-api-key: your_key" \
"https://your-app.com/api/documents/doc_123?perspective=published&depth=1"Create document
POST /api/documents{
"type": "post",
"data": {
"title": "My New Post",
"slug": "my-new-post",
"body": "Hello world."
},
"publish": false
}Returns 201 with the created document and validation results:
{
"success": true,
"data": { ... },
"validation": { "isValid": true, "errors": [] }
}Set "publish": true to publish immediately. This validates before publishing and returns 400 if validation fails.
Update document
PUT /api/documents/{id}{
"data": {
"title": "Updated Title"
},
"publish": false,
"expectedRevision": 7
}expectedRevision is optional — see Concurrency below.
Delete document
DELETE /api/documents/{id}Publish document
POST /api/documents/{id}/publishValidates the draft and copies it to published data. Returns 400 if validation fails.
Unpublish document
DELETE /api/documents/{id}/publishReverts the document to draft-only state.
Concurrency
Every document carries a monotonic revision, returned as _meta.revision and incremented on every draft write. Echo it back as expectedRevision on your next write and the request only lands if nobody changed the document in between:
{
"data": { "title": "Updated Title" },
"expectedRevision": 7
}If the stored revision has moved on, the request is rejected with 409 rather than overwriting:
{
"success": false,
"error": "Conflict",
"message": "Document was modified by another write (expected revision 7, current 9)",
"currentRevision": 9
}Re-read the document and decide what to do — merge, discard, or ask the user. Accepted by update, publish, unpublish, and version restore.
expectedRevision is optional on every endpoint. Omit it and the write is unconditional
(last-write-wins), exactly as before — existing integrations keep working unchanged.
Versions
Every draft save and publish writes an entry to the document's version history. See Version History for the full guide.
List versions
GET /api/documents/{id}/versions?limit=25&offset=0Returns versions newest-first with the author resolved to a friendly createdByName.
Get a specific version
GET /api/documents/{id}/versions/{versionNumber}Restore a version
POST /api/documents/{id}/versions/{versionNumber}/restoreReplaces the document's draft with the snapshot data and writes a new draft-event version recording the restore. Published data is untouched until the editor publishes again.
Accepts an optional expectedRevision in the body — see Concurrency.
Singletons
Singleton schemas are reachable through the same endpoints, with two differences:
- The list endpoint (
GET /api/documents?type=siteNavigation) always returns a one-element array — singletons ignore filters, pagination, andstatusparameters because there is at most one row. DELETE /api/documents/{id}returns400when called against the canonical singleton row.
Singletons are lazy-created on first read, so consumers never have to handle a 404.
Advanced querying
For complex filters that don't fit in query parameters, use the query endpoint:
POST /api/documents/queryThis is a read operation — API keys with read permission can use it.
{
"type": "post",
"where": {
"status": { "equals": "published" },
"title": { "contains": "tutorial" }
},
"limit": 20,
"page": 1,
"sort": ["-publishedAt", "title"],
"depth": 1,
"perspective": "published",
"includeChildOrganizations": true
}The where clause
where is a map of field name to { operator: value }. Multiple fields are ANDed together; and and or take arrays of nested where objects; dot-notation reaches into nested fields.
| Operator | Applies to | Example |
|---|---|---|
equals, not_equals | any | { "slug": { "equals": "contact" } } |
in, not_in | any | { "slug": { "in": ["home", "about"] } } |
exists | any | { "heroImage": { "exists": true } } |
greater_than, greater_than_equal, less_than, less_than_equal | number, date | { "order": { "less_than": 10 } } |
like, contains, starts_with, ends_with | string | { "title": { "contains": "incident" } } |
{
"type": "post",
"where": {
"title": { "contains": "incident" },
"or": [{ "slug": { "starts_with": "2026-" } }, { "featured": { "equals": true } }]
},
"sort": "-publishedAt",
"limit": 20
}The same operators back the Local API — see filtering. The canonical list lives in packages/cms-core/src/lib/types/filters.ts.
Filters fail silently, in both directions
where is not validated — it is passed through to LocalAPI.find(), which ignores what it
doesn't recognise. Two consequences worth knowing before you trust a result:
- A misspelt operator matches everything.
{ "slug": { "eq": "contact" } }drops the clause entirely and returns every document of that type, with a200. A typo widens the result set instead of narrowing it. - A malformed clause matches nothing. A
whereof the wrong shape, or one naming a field your schema doesn't declare, returnstotal: 0— indistinguishable from an empty collection.
Check your operator names against the table above. select is likewise accepted and currently
ignored: the full document comes back regardless.
Assets
List assets
GET /api/assets| Parameter | Type | Default | Description |
|---|---|---|---|
assetType | string | - | 'image' or 'file'. |
mimeType | string | - | Filter by MIME type (e.g. image/png). |
search | string | - | Search by title or description. |
limit | number | 20 | Results per page. |
offset | number | 0 | Number of results to skip. |
Response:
{
"success": true,
"data": [ ... ],
"pagination": {
"total": 85,
"page": 1,
"pageSize": 20,
"totalPages": 5,
"hasNextPage": true,
"hasPrevPage": false
}
}Upload asset
POST /api/assets
Content-Type: multipart/form-data| Field | Type | Description |
|---|---|---|
file | File | The file to upload. Required. |
title | string | Display title. |
description | string | Description. |
alt | string | Alt text (for images). |
creditLine | string | Credit/attribution. |
organizationId | string | Target organization. Defaults to the authenticated user's active org. |
curl -H "x-api-key: your_key" \
-F "[email protected]" \
-F "title=Hero Image" \
-F "alt=A sunset over the ocean" \
https://your-app.com/api/assetsGet asset
GET /api/assets/{id}Update asset metadata
PATCH /api/assets/{id}{
"title": "Updated Title",
"alt": "New alt text"
}Delete asset
DELETE /api/assets/{id}Returns 409 if the asset is still referenced by documents.
Bulk delete assets
DELETE /api/assets/bulk{
"ids": ["asset_id_1", "asset_id_2", "asset_id_3"]
}Response:
{
"success": true,
"data": {
"deleted": 2,
"failed": 0
}
}Returns 409 if any assets are still referenced, with the list of blocked IDs:
{
"success": false,
"error": "Cannot delete 1 asset because it is still referenced by documents",
"referencedIds": ["asset_id_2"]
}Find asset references
GET /api/assets/{id}/referencesReturns which documents reference a given asset:
{
"success": true,
"data": {
"references": [ ... ],
"total": 3
}
}Batch reference counts
POST /api/assets/references/countsGet reference counts for multiple assets in one request. Useful for checking which assets are safe to delete.
{
"ids": ["asset_id_1", "asset_id_2", "asset_id_3"]
}Response:
{
"success": true,
"data": {
"asset_id_1": 2,
"asset_id_2": 0,
"asset_id_3": 1
}
}Schemas
List all schemas
GET /api/schemasGet schema by type
GET /api/schemas/{type}Roles
Roles let an organization map names (owner, admin, custom roles like Publisher) to sets of capabilities. All role endpoints require a session — API keys cannot manage roles.
List roles
GET /api/rolesReturns every role defined for the active organization, including the four built-ins.
{
"success": true,
"data": [
{
"id": "role_abc",
"organizationId": "org_123",
"name": "owner",
"description": "Full access including organization deletion.",
"capabilities": ["document.read", "document.create", "…"],
"isBuiltIn": true,
"createdAt": "2025-01-10T00:00:00Z",
"updatedAt": "2025-01-10T00:00:00Z"
}
]
}Create a custom role
POST /api/rolesRequires the role.manage capability. Built-in names (owner, admin, editor, viewer) are reserved.
{
"name": "Publisher",
"description": "Can edit and publish but not delete.",
"capabilities": [
"document.read",
"document.create",
"document.update",
"document.publish",
"document.unpublish",
"asset.read",
"asset.upload"
]
}Write capabilities auto-include their matching read cap, so you don't need to list document.read alongside document.create manually (it's inserted on intake).
Update a role
PATCH /api/roles/{name}Requires role.manage. Both built-in and custom roles can be edited — at least one of description or capabilities must be provided.
{
"capabilities": ["document.read", "asset.read", "member.invite"]
}Delete a custom role
DELETE /api/roles/{name}Requires role.manage. Returns 403 for built-in names and 409 if the role is still assigned to any member or pending invitation.
Status codes
| Code | Meaning |
|---|---|
200 | Success (GET, PUT, PATCH). |
201 | Created (POST). |
400 | Bad request — missing parameters, invalid data, or validation failure. |
401 | Unauthorized — no valid session or API key. |
403 | Forbidden — insufficient permissions (e.g. viewer trying to write, or read-only API key on a mutation). |
404 | Not found. |
409 | Conflict — a stale expectedRevision on a document write, or an asset that still has references. |
500 | Server error. |
Last updated on