Aphex

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:

PathWhat it is
/api/docsA rendered, browsable reference. Open it while signed in to the admin.
/api/openapi.jsonThe 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 describe draftData as an untyped object; the generated spec emits a real PostData / PageData component per collection, with the same required fields 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.ts

Both 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:

aphex.config.ts
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 routes

SvelteKit'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

MethodPathNotes
GET/api/documentsList with filters and pagination.
POST/api/documentsCreate a draft.
POST/api/documents/queryAdvanced query — read-only.
GET/api/documents/:idRead a single document.
PUT/api/documents/:idUpdate draft data.
DELETE/api/documents/:idDelete (draft or both).
POST/api/documents/:id/publishPublish draft to published.
DELETE/api/documents/:id/publishUnpublish.
GET/api/documents/:id/versionsList versions.
GET/api/documents/:id/versions/:versionRead a specific version.
POST/api/documents/:id/versions/:version/restoreRestore a version into the draft.
GET/api/documents/by-idsBatch lookup, up to 100 ids.
GET/api/documents/:id/back-referencesDocuments referencing this one.
GET/api/documents/:id/scheduleRead the pending schedule.
POST/api/documents/:id/scheduleSchedule a publish / unpublish.
DELETE/api/documents/:id/scheduleCancel a pending schedule.

Assets

MethodPathNotes
GET/api/assetsList with filters.
POST/api/assetsUpload (multipart/form-data).
DELETE/api/assets/bulkBulk delete by id list.
POST/api/assets/references/countsReference counts for a list of asset ids.
GET/api/assets/:idRead asset metadata.
PATCH/api/assets/:idUpdate metadata (title, alt, credit, etc).
DELETE/api/assets/:idDelete an asset.
GET/api/assets/:id/referencesList documents referencing this asset.
POST/api/assets/upload-urlPresigned direct upload (upload.direct).
POST/api/assets/confirmRegister an asset after a direct upload.
POST/api/assets/:id/posterAttach a poster image to a video asset.

Organizations

MethodPathNotes
GET/api/organizationsList orgs the caller belongs to.
POST/api/organizationsCreate an organization.
POST/api/organizations/switchSwitch active org context.
GET/api/organizations/membersList members of the active org.
PATCH/api/organizations/membersUpdate a member's role.
DELETE/api/organizations/membersRemove a member.
POST/api/organizations/invitationsSend an invitation.
DELETE/api/organizations/invitationsCancel an invitation.
GET/api/organizations/:idRead a specific org.
PATCH/api/organizations/:idUpdate org name / slug / metadata.
DELETE/api/organizations/:idDelete an org (super admin / owner).

Roles

MethodPathNotes
GET/api/rolesList built-in + custom org roles.
POST/api/rolesCreate a custom role.
PATCH/api/roles/:nameUpdate an existing role's capabilities.
DELETE/api/roles/:nameDelete a custom role.

Schemas

MethodPathNotes
GET/api/schemasAll registered schemas (used by the studio shell).
GET/api/schemas/:typeA single schema.

User account

MethodPathNotes
PATCH/api/userUpdate profile (name, email).
GET/api/user/cms-preferenceRead editor preferences (sidebar state, etc).
PATCH/api/user/cms-preferenceUpdate editor preferences.
POST/api/user/request-password-resetTrigger a password-reset email.
POST/api/user/reset-passwordComplete 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.

MethodPathNotes
GET/api/jobsList queue jobs, filterable by status and type.
GET/api/jobs/healthQueue health counters.
POST/api/jobs/:id/retryRequeue a failed or dead-lettered job.
POST/api/jobs/:id/cancelCancel a pending job.
GET/api/eventsThe append-only fact log. Immutable — there is no write path.
POST/api/internal/workers/runRun 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.

MethodPathNotes
GET/api/plugin-settingsRead settings for the active org.
PUT/api/plugin-settings/:pluginSave one plugin's settings.

Meta

MethodPathNotes
GET/api/docsRendered API reference. Unmount with openapi.docsUi: false.
GET/api/openapi.jsonThe OpenAPI 3.1 document. Authenticated.
GET/api/aphex-healthUnauthenticated 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

PathOwned by
/api/auth/*Better Auth — handled by svelteKitHandler in hooks.server.ts, intercepts before the filesystem router runs.
/api/graphqlThe GraphQL endpoint. Configurable via graphql.path in aphex.config.ts.
/api/instance-settingsStudio +server.ts (super-admin gated). See God Mode.
/media/:id/:filenameStudio +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=post

If 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}
ParameterTypeDefaultDescription
typestringrequiredDocument type (e.g. post, page).
statusstring-Filter by draft or published.
perspectivestring'draft'Which version to return: 'draft' or 'published'.
pagenumber1Page number.
pageSizenumber20Results per page. Alias: limit.
sortstring-Sort field. Prefix with - for descending (e.g. -publishedAt).
depthnumber0Reference resolution depth (0–5).
includeChildOrganizationsbooleanfalseInclude 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}
ParameterTypeDefaultDescription
perspectivestring'draft''draft' or 'published'.
depthnumber0Reference 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}/publish

Validates the draft and copies it to published data. Returns 400 if validation fails.

Unpublish document

DELETE /api/documents/{id}/publish

Reverts 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=0

Returns 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}/restore

Replaces 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, and status parameters because there is at most one row.
  • DELETE /api/documents/{id} returns 400 when 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/query

This 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.

OperatorApplies toExample
equals, not_equalsany{ "slug": { "equals": "contact" } }
in, not_inany{ "slug": { "in": ["home", "about"] } }
existsany{ "heroImage": { "exists": true } }
greater_than, greater_than_equal, less_than, less_than_equalnumber, date{ "order": { "less_than": 10 } }
like, contains, starts_with, ends_withstring{ "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 a 200. A typo widens the result set instead of narrowing it.
  • A malformed clause matches nothing. A where of the wrong shape, or one naming a field your schema doesn't declare, returns total: 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
ParameterTypeDefaultDescription
assetTypestring-'image' or 'file'.
mimeTypestring-Filter by MIME type (e.g. image/png).
searchstring-Search by title or description.
limitnumber20Results per page.
offsetnumber0Number 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
FieldTypeDescription
fileFileThe file to upload. Required.
titlestringDisplay title.
descriptionstringDescription.
altstringAlt text (for images).
creditLinestringCredit/attribution.
organizationIdstringTarget 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/assets

Get 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}/references

Returns which documents reference a given asset:

{
  "success": true,
  "data": {
    "references": [ ... ],
    "total": 3
  }
}

Batch reference counts

POST /api/assets/references/counts

Get 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/schemas

Get 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/roles

Returns 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/roles

Requires 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

CodeMeaning
200Success (GET, PUT, PATCH).
201Created (POST).
400Bad request — missing parameters, invalid data, or validation failure.
401Unauthorized — no valid session or API key.
403Forbidden — insufficient permissions (e.g. viewer trying to write, or read-only API key on a mutation).
404Not found.
409Conflict — a stale expectedRevision on a document write, or an asset that still has references.
500Server error.
Edit on GitHub

Last updated on