HTTP API
This is the endpoint list. It describes what the server actually mounts, not what a client library exposes on top of it. If you are reading content from a frontend, the query builder is a shorter route to the same data.
Base path
Section titled “Base path”The Express integration mounts the API router at /api and the Studio at /studio:
trokky.mount(app) // /api and /studiotrokky.mount(app, { apiPath: '/cms', studioPath: '/admin' })Every path below is relative to that mount point, so /collections/post is /api/collections/post by default.
There is a second, separate prefix: server.basePath in your config, which is prepended to each route inside the router. It defaults to '', and you generally want to leave it there. Setting it to something starting with /api while also mounting on /api produces /api/api/..., and the server logs a warning when it sees that.
Authentication
Section titled “Authentication”Send a bearer token:
Authorization: Bearer <token>Both kinds of token work on every authenticated endpoint: a JWT from POST /auth/login, or an API token created through /tokens. The server verifies either.
If security.enabled is false in your configuration, no endpoint checks anything. That is a development convenience and it turns the whole table below into “authentication: none”. See Authentication and permissions.
Permissions are checked per collection and action. A session passes if it has the admin role, or one of <collection>:<action>, <collection>:*, content:<action>, or content:*.
Response shape
Section titled “Response shape”Most endpoints answer with the same envelope:
{ "success": true, "data": { }}and on failure:
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [] }}The data column in the tables below names what sits inside data.
Two exceptions are worth knowing before you write a client. GET /media puts an array directly in data rather than an object. And several of the auth handlers — password reset, MFA verification, MFA setup — put their fields at the top level of the body next to success instead of nesting them under data, and some of them return error as a plain string rather than an object. Read the individual entries rather than assuming.
Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
| 200 | Success. |
| 201 | Created — document create, media upload, user create, webhook create, token create. |
| 204 | Token deleted. |
| 400 | The request is malformed, validation failed, or a permission check on a collection, user or webhook denied it. |
| 401 | The token is missing, invalid or expired. Also returned when the admin check on user, webhook and settings endpoints fails. |
| 403 | Returned by three endpoints only: reading another actor’s audit logs, an admin MFA reset by a non-admin, and a passkey session that does not match the authenticated user. |
| 404 | Document, media file, user, webhook, schema or variant not found. |
| 429 | Rate limit exceeded. |
| 500 | Unhandled error. The message is only included when NODE_ENV=development. |
| 501 | GET/PUT on a single token, and static file serving on edge runtimes. Not implemented. |
| 503 | The event system (webhooks) or settings storage is unavailable. |
The 401/403/400 split described in Traps is the intent. In practice the boundary is not uniform: missing or expired tokens are reliably 401, but a permission denial reaches you as 400 on the content endpoints and as 401 on the admin-gated ones. Branch on error.code where you can, and do not treat a 400 as proof that the request body was wrong.
Collections and documents
Section titled “Collections and documents”| Method | Path | Auth | Permission |
|---|---|---|---|
| GET | /collections | Required | — |
| GET | /collections/:collection | Required | read |
| POST | /collections/:collection | Required | write |
| GET | /collections/:collection/:id | Required | read |
| PUT | /collections/:collection/:id | Required | write, plus publish when _status changes |
| DELETE | /collections/:collection/:id | Required | delete, or ownership |
| GET | /stats/:collection | Required | — |
GET /collections returns { collections }, the full schema objects the server was started with.
GET /collections/:collection takes these query parameters:
| Parameter | Type | Notes |
|---|---|---|
limit | integer | Page size. Defaults to 25 in the pagination metadata. |
offset | integer | Documents to skip. Ignored when page is used. |
page | integer | Used with limit instead of offset. |
sort | string | field for ascending, -field for descending. |
filter | JSON string or filter[field]=value | Passed to the storage adapter. |
search | string | Substring match, applied after the query. |
expand | string or repeated | Reference fields to resolve inline. |
Returns { documents, pagination: { page, limit, total, pages }, meta: { total, limit, offset } }.
Two costs. search filters the page the adapter already returned rather than the whole collection, so results and total disagree — total is the unfiltered count. And expand fetches each referenced document one at a time; on a list of fifty documents with two references each, that is a hundred extra reads. See References.
POST /collections/:collection takes { "data": { ... }, "id": "optional-id" } and returns 201 with { document }. Keys beginning with _ are stripped from data before saving, except _status and _type. Slug fields are generated from their source field when absent. Creating a second document in a singleton collection is rejected with 400.
GET /collections/:collection/:id returns { document }, and accepts expand. If the document does not exist and the collection is a singleton the server may create it from schema defaults; otherwise you get 404. See Singletons.
PUT /collections/:collection/:id takes { "data": { ... } } and returns { document }. The body is merged over the stored document: a field you leave out is unchanged, not cleared. Send null to clear one. On a singleton this is an upsert; anywhere else, a missing document is 404. Changing _status to or from published requires the publish permission separately — see Drafts and publishing.
DELETE /collections/:collection/:id returns { message }. Without the delete permission the server falls back to ownership: you may delete a document whose _createdBy matches your id or username, and nothing else.
GET /stats/:collection returns { stats: { collection, totalDocuments, publishedDocuments, draftDocuments } }. It counts rather than loading documents.
Search and slugs
Section titled “Search and slugs”| Method | Path | Auth |
|---|---|---|
| GET | /search | Required |
| GET | /slugs/check-unique | Required |
GET /search takes q (at least two characters, otherwise 400), limit (default 10) and offset (default 0). It returns { results, total, query, limit, offset }, where each result is { id, type, collection?, title, url, excerpt, metadata } and type is document or media.
It works by scanning every collection and the media library in the request, matching on fields whose names contain title, name or slug, plus filenames and media descriptions. It does not use a search index, and it reads limit * 2 documents per collection. On a large content set this is slow and the ranking is shallow — exact title matches first, then newest.
GET /slugs/check-unique takes slug and collection (both required) and optional excludeId, which is what you pass while editing an existing document. Returns { unique, slug, collection }, plus reason when it is not unique.
| Method | Path | Auth |
|---|---|---|
| GET | /media | Required |
| POST | /media/upload | Required |
| POST | /media/bulk-delete | Required |
| GET | /media/:id | Required |
| PUT | /media/:id | Required |
| DELETE | /media/:id | Required |
| POST | /media/:id/regenerate-variants | Required |
| GET | /media/:id/file | None |
| GET | /media/:id/variants/:variant | None |
The two serving endpoints are public. Anyone who knows a media id can fetch the bytes. Treat media ids as public information, and do not put anything confidential in the media library.
GET /media takes limit (default 50) and offset (default 0). It returns the array of files as data directly, with meta: { count, total, limit, offset, hasMore }. Metadata is sanitised on the way out: path, storagePath, absolutePath, relativePath and filePath are removed, so a client never sees server filesystem layout.
POST /media/upload is multipart/form-data and returns 201 with { files, meta: { count } }. The handler enforces its own limits regardless of what you configured: at most 10 files, 100 MB each, an allowed list of image, video, audio, PDF, Word, text, CSV and JSON MIME types. Executable extensions are rejected anywhere in the filename, not only at the end, and a filename containing .., / or \ is rejected.
GET /media/:id returns { file }. PUT /media/:id takes { "metadata": { ... } } and updates metadata only — it cannot replace the file. DELETE /media/:id returns { message }.
POST /media/bulk-delete takes { "ids": [ ... ] }, at most 100, and returns { message, results, successCount, errorCount }. Individual failures do not fail the request; check results.
POST /media/:id/regenerate-variants rebuilds the configured variants for one file and returns { message, file }. Images only — anything else is 400.
GET /media/:id/file returns the original bytes with the stored content type, Content-Disposition: inline, Cache-Control: public, max-age=31536000 and an ETag of the id. GET /media/:id/variants/:variant returns one generated variant on the same caching terms. The variant name must match [a-zA-Z0-9_-]+. A variant that is not in the file’s metadata, or a storage adapter that cannot read variants, is a 404 — see Media.
Session authentication
Section titled “Session authentication”| Method | Path | Auth |
|---|---|---|
| POST | /auth/login | None |
| POST | /auth/logout | None |
| GET | /auth/me | Required |
| POST | /auth/validate | None |
| POST | /auth/refresh | None |
POST /auth/login takes { username, password, rememberMe?, deviceId?, captchaToken? }. It has three outcomes, all 200:
{ success, token, refreshToken, user, expiresAt }— signed in.{ success, requiresMFA, mfaToken, methods, expiresIn }— continue at/auth/mfa/verify.{ success, requiresMFASetup, setupToken, allowedMethods, message, expiresIn }— MFA is required by policy and this user has none yet.
Bad credentials come back as 400, not 401. A client that keys off the status code alone cannot tell a wrong password from a malformed body; read error.message.
POST /auth/logout returns { message } and nothing else. The token is not invalidated server-side — logout is the client discarding it. A stolen token stays valid until it expires.
GET /auth/me returns the full user record without passwordHash.
POST /auth/validate takes { token } and returns { valid, message, session? }. It accepts JWTs only, not API tokens.
POST /auth/refresh takes { refreshToken } and returns a fresh token pair, or 401 if the refresh token is invalid or expired.
Token lifetimes come from security.tokens: access 2h, refresh 7d, remember-me 30d by default. See Configuration.
Passwords
Section titled “Passwords”| Method | Path | Auth |
|---|---|---|
| POST | /auth/request-reset | None |
| POST | /auth/reset-password | None |
| POST | /auth/verify-reset-token | None |
| POST | /auth/change-password | Required |
POST /auth/request-reset takes { email, captchaToken?, ipAddress? }. It always returns 200 with the same message whether or not the address exists, and whether or not the account is active — deliberately, so the endpoint cannot be used to enumerate users. It is rate limited per email address. The reset token is emitted as a user.password_reset_requested event; delivering it is your mail adapter’s job, and with no mail adapter configured nothing reaches the user.
POST /auth/reset-password takes { token, newPassword, captchaToken? }. An invalid or expired token is 400. Tokens are stored hashed and last one hour.
POST /auth/verify-reset-token takes { token } and returns 200 with { valid, expiresIn, message }, where expiresIn is in minutes. An invalid token is still a 200 with valid: false.
POST /auth/change-password takes { currentPassword, newPassword }. A wrong current password is 400.
Google OAuth
Section titled “Google OAuth”| Method | Path | Auth |
|---|---|---|
| POST | /auth/oauth/google/init | Required when mode: "link" |
| POST | /auth/oauth/google/callback | Required when mode: "link" |
| DELETE | /auth/oauth/google/unlink | Required |
| GET | /auth/oauth/status | None |
POST /auth/oauth/google/init takes { mode }, either login (default) or link, and returns { authUrl, state, codeVerifier }. The client stores codeVerifier and sends it back on the callback.
POST /auth/oauth/google/callback takes { code, state, codeVerifier, mode?, deviceId? }. In login mode it returns the same three outcomes as /auth/login. In link mode it returns { message, provider }. Signing in with a Google account that no user has linked is refused with NO_LINKED_ACCOUNT — linking happens from an existing session, never implicitly.
GET /auth/oauth/status returns { providers: { google } }. With OAuth unconfigured, the init and callback endpoints answer 400 OAUTH_NOT_CONFIGURED.
Passkeys
Section titled “Passkeys”| Method | Path | Auth |
|---|---|---|
| GET | /auth/passkey/status | None |
| POST | /auth/passkey/register/options | Required |
| POST | /auth/passkey/register/verify | Required |
| POST | /auth/passkey/login/options | None |
| POST | /auth/passkey/login/verify | None |
| GET | /auth/passkey/credentials | Required |
| PATCH | /auth/passkey/credentials/:credentialId | Required |
| DELETE | /auth/passkey/credentials/:credentialId | Required |
GET /auth/passkey/status returns { enabled }. Every other endpoint here answers 400 PASSKEY_NOT_CONFIGURED when passkeys are off.
The registration and login endpoints come in pairs. The options call returns WebAuthn options plus a sessionId; the verify call takes { sessionId, credential } — plus friendlyName on registration, deviceId on login — and 400s on an expired or unknown session. Sessions are held in memory in the server process, so they do not survive a restart and do not work across multiple instances without sticky routing.
Registration returns { credential: { id, deviceType, backedUp, transports, createdAt } }. A sessionId belonging to a different user than the bearer token is 403. Login verification returns the same three outcomes as /auth/login, so a passkey login can still land on an MFA challenge.
GET /auth/passkey/credentials returns { credentials }. PATCH takes { friendlyName }. Both PATCH and DELETE are 404 for a credential id you do not own.
Multi-factor authentication
Section titled “Multi-factor authentication”| Method | Path | Auth |
|---|---|---|
| POST | /auth/mfa/verify | None (uses mfaToken) |
| POST | /auth/mfa/verify-backup | None (uses mfaToken) |
| POST | /auth/mfa/send-code | None (uses mfaToken) |
| POST | /auth/mfa/setup/totp | Required, or X-MFA-Setup-Token |
| POST | /auth/mfa/setup/totp/verify | Required, or X-MFA-Setup-Token |
| POST | /auth/mfa/setup/email | Required, or X-MFA-Setup-Token |
| POST | /auth/mfa/setup/email/verify | Required, or X-MFA-Setup-Token |
| POST | /auth/mfa/disable | Required |
| POST | /auth/mfa/disable-all | Required |
| POST | /auth/mfa/backup-codes/regenerate | Required |
| GET | /auth/mfa/status | Required |
| GET | /auth/mfa/trusted-devices | Required |
| DELETE | /auth/mfa/trusted-devices/:deviceId | Required |
| DELETE | /auth/mfa/trusted-devices | Required |
| POST | /admin/users/:userId/mfa/reset | Required, admin |
The four setup endpoints accept either a normal bearer token or an X-MFA-Setup-Token header. The second form exists so a user who is being forced to set up MFA can complete it during login, before they hold a session token.
POST /auth/mfa/verify takes { mfaToken, code, method, trustDevice?, deviceId?, deviceName?, rememberMe? } and returns { success, token, refreshToken, user } at the top level of the body, not under data. A bad or expired mfaToken, or a wrong code, is 401.
POST /auth/mfa/verify-backup takes the same fields with code as a backup code, and no method. POST /auth/mfa/send-code takes { mfaToken } and returns { message, expiresIn }.
POST /auth/mfa/setup/totp returns { qrCode, manualEntryKey, uri } under data. Its verify endpoint takes { code } and returns { message, backupCodes } — that is the only time the backup codes are shown. POST /auth/mfa/setup/email returns { message, expiresIn }; its verify endpoint takes { code }.
POST /auth/mfa/disable takes { method, password }. POST /auth/mfa/disable-all takes { password }. POST /auth/mfa/backup-codes/regenerate takes { password } and returns { backupCodes, message }; the previous codes stop working immediately.
GET /auth/mfa/status returns the user’s MFA configuration. GET /auth/mfa/trusted-devices returns { devices }; the two DELETE forms revoke one device or all of them.
POST /admin/users/:userId/mfa/reset takes an optional { reason }. Non-admins get 403. Resetting your own MFA through this endpoint is refused with 400 — use the ordinary MFA endpoints.
OAuth2 authorization server
Section titled “OAuth2 authorization server”These implement the device flow and the authorization code flow, and are what trokky login uses. They are only present when oauth2.enabled is set; otherwise they answer 501 NOT_ENABLED.
| Method | Path | Auth |
|---|---|---|
| POST | /auth/device | None |
| GET | /auth/device/verify | Required |
| POST | /auth/device/verify | Required |
| POST | /auth/token | None (client credentials in body) |
| GET | /auth/authorize | Optional |
| POST | /auth/authorize | Required |
POST /auth/device takes { client_id, scope? } and returns the standard device authorization response — device_code, user_code, verification_uri, expires_in, interval — as the body, with no envelope. Errors here follow OAuth2 convention: { error, error_description }, not { success, error }.
GET /auth/device/verify?code=XXXX-XXXX returns { clientId, clientName, clientDescription, scopes, expiresIn } for the consent screen. An unknown code is 404; a code already approved or denied is 400.
POST /auth/device/verify takes { user_code, action } where action is authorize or deny, and returns { message }.
POST /auth/token takes grant_type and client_id, plus the fields that grant needs — device_code, or code with redirect_uri and code_verifier, or refresh_token. Confidential clients also send client_secret, and a bad secret is 401 invalid_client. A successful exchange returns { access_token, token_type, expires_in, refresh_token?, scope }. refresh_token is only issued when the offline_access scope was granted. Unknown grant types are 400 unsupported_grant_type.
GET /auth/authorize takes response_type, client_id, redirect_uri, state, code_challenge, code_challenge_method and optional scope. It does not redirect on success — it returns { client, scopes, redirectUri, state, codeChallenge, hasExistingConsent } for the Studio to render a consent page. On an invalid request with a registered redirect_uri it answers 302 to that URI carrying error; otherwise 400.
POST /auth/authorize takes { action, client_id, redirect_uri, scopes, state, code_challenge } and returns { redirectUrl } — the URL the browser should go to, carrying either code or error=access_denied. Approving also records consent, so the same client and scopes are auto-approved next time.
| Method | Path | Auth |
|---|---|---|
| GET | /users | Required, users:read or admin |
| POST | /users | Required, admin |
| GET | /users/:id | Required, users:read or admin |
| PUT | /users/:id | Required, admin |
| DELETE | /users/:id | Required, admin |
| GET | /users/by-username/:username | Required, admin |
| GET | /users/by-email/:email | Required, admin |
passwordHash is stripped from every response.
GET /users takes role, isActive, limit and offset, and returns { users, meta: { total, limit, offset } }. meta.total is the size of the returned page, not the size of the table.
POST /users returns 201 with { user }. PUT /users/:id returns { user }. Both accept fullName and split it on the first space into firstName and lastName, and POST accepts active as an alias for isActive.
GET /users/by-email/:email needs the address URL-encoded in the path.
The admin check on these endpoints passes for the admin role or the users:write permission, and admin access is written to the audit log. Note that failing it returns 401, not 403.
API tokens
Section titled “API tokens”| Method | Path | Auth |
|---|---|---|
| GET | /tokens | Required |
| POST | /tokens | Required |
| GET | /tokens/:id | Required |
| PUT | /tokens/:id | Required |
| DELETE | /tokens/:id | Required |
GET /tokens/:id and PUT /tokens/:id are registered but not implemented; both return 501.
GET /tokens takes limit, offset and isActive. POST /tokens takes { name, permissions, ... } — both required — and returns 201 with { token, appToken }, where token is the plaintext value and is never shown again. DELETE /tokens/:id returns 204 with no body.
These endpoints require authentication but no specific permission, so any signed-in user can create a token. Constrain that with your own middleware if it matters.
Webhooks
Section titled “Webhooks”| Method | Path | Auth |
|---|---|---|
| GET | /webhooks | Required, webhooks:read or admin |
| POST | /webhooks | Required, admin |
| GET | /webhooks/:id | Required, webhooks:read or admin |
| PUT | /webhooks/:id | Required, admin |
| DELETE | /webhooks/:id | Required, admin |
| GET | /webhooks/:id/deliveries | Required, webhooks:read or admin |
| POST | /webhooks/:id/test | Required, admin |
All of these answer 503 when the event system is not available.
GET /webhooks takes active, limit (default 50) and offset (default 0), and returns { webhooks, meta }.
POST /webhooks takes { "webhookData": { name, url, events, secret?, active?, headers?, retryPolicy? } }. name, url and a non-empty events array are required, and url must parse as a URL. A missing secret is generated for you; the default retry policy is three attempts with exponential backoff on 408, 429, 500, 502, 503 and 504. Returns 201 with { webhook }, including the secret.
PUT /webhooks/:id takes the same webhookData wrapper with any subset of fields. DELETE returns { message }.
GET /webhooks/:id/deliveries takes limit and offset and returns { deliveries, meta }. POST /webhooks/:id/test takes an optional { eventType }, defaulting to system.test, emits a real event through the bus, and returns { message, eventId, eventType, webhookUrl }. Anything subscribed to that event type also receives it.
Webhooks registered here live in the event bus in memory. They are not the same thing as the hooks.webhooks entries in your config file.
Audit logs
Section titled “Audit logs”| Method | Path | Auth |
|---|---|---|
| GET | /audit-logs/documents/:documentId | Required |
| GET | /audit-logs/collections/:collection | Required, read on the collection |
| GET | /audit-logs/actors/:actorId | Required, own id or admin |
All three take limit (default 50) and offset (default 0) and return { auditLogs, pagination: { limit, offset, count } }.
Reading another actor’s logs without the admin role is 403 — one of the three places that code is used.
Whether there is anything to read depends on your data adapter: audit logging is a storage-adapter capability, not something the route layer synthesises.
Schemas and configuration
Section titled “Schemas and configuration”| Method | Path | Auth |
|---|---|---|
| GET | /schemas/:schemaName | Required |
| GET | /config/structure | Required |
| GET | /config/studio | None |
| GET | /config/settings | Required |
| PUT | /config/settings | Required, admin |
GET /schemas/:schemaName returns { schema }, or 404. This is what trokky generate-types reads.
GET /config/structure returns { structure }. If you supplied a structure function it is called with the current user, so the response varies per role; otherwise a default structure is generated from your schemas, and a user with no session gets an empty one.
GET /config/studio is public, because the login page needs branding before anyone has signed in. It returns { studioConfig } — branding, colours, logo, media variants and session timings. Do not put anything private in Studio branding.
GET /config/settings returns { settings }, creating a default record on first read. PUT /config/settings takes { "settings": { ... } } and merges field by field, so a field you omit keeps its stored value. It answers 503 if your data adapter does not support settings, and emits settings.updated plus a specific event when the public URL, title or theme changes.
System
Section titled “System”| Method | Path | Auth |
|---|---|---|
| GET | /health | None |
| GET | /openapi.json | None |
| OPTIONS | /* | None |
GET /health returns { status, timestamp }, where status is healthy or unhealthy. It checks storage, so it is a real readiness probe rather than a constant.
GET /openapi.json returns an OpenAPI 3.0.3 document generated from the mounted route table. It is accurate about paths and methods, and thin about bodies — most request bodies are described as a bare object. Treat it as a route index, not as a contract. The servers entry is derived from the path the request arrived on, so it stays correct whatever you mounted the router at.
OPTIONS on any path returns the CORS preflight headers built from server.cors.
What this page does not cover
Section titled “What this page does not cover”Custom routes you declare in routes in your config are mounted alongside these and are yours to document. The Studio’s own asset routes under /studio are not an API.