LumiBaseDocs

Hono API Specification — LumiBase

For AI agents: This page is also available as clean Markdown. Append /index.md to any LumiBase docs URL.

Base URL: https://api.<your-site>.lumibase.dev (or http://localhost:1989 in local dev)

All endpoints are versioned under /api/v1. Every request must include:

  • Authorization: Bearer <token> — login token, or an API key (lbk_…)
  • X-Lumi-Site: <siteId> — site identifier (or resolved via subdomain routing)

Response envelope

All responses follow this structure:

json
{
  "data": <T>,
  "meta": {
    "total": 123,
    "page": 1,
    "pageSize": 50,
    "filter_count": 50
  }
}

Error response:

json
{
  "errors": [
    {
      "code": "PERMISSION_DENIED",
      "message": "You don't have permission to read field 'secret'.",
      "path": ["fields", "secret"],
      "extensions": { "reason": "field_policy" }
    }
  ]
}

Error codes

CodeHTTPDescription
PERMISSION_DENIED403Policy check failed
RECORD_NOT_FOUND404Item does not exist or not visible to this role
VALIDATION_FAILED400Input schema validation error
CONFLICT409Unique constraint violated
RATE_LIMITED429Rate limit exceeded
SITE_NOT_FOUND404X-Lumi-Site header resolves to unknown tenant
TOKEN_EXPIRED401JWT has expired — refresh and retry
SKILL_DENIED403AI skill requires a capability the session lacks
HITL_REQUIRED202Dangerous operation gated for human approval

Standard query parameters (list endpoints)

ParameterExampleDescription
fieldsfields=id,title,author.nameSelect specific fields + nested relations
filterfilter[status][_eq]=published or filter={"status":{"_eq":"published"}}Filter using rule operators — see Filter forms
sortsort=-updated_at,titleComma-separated, - prefix for DESC
pagepage=2Page number (1-indexed)
limitlimit=25Items per page (max 200)
aggregate[count]aggregate[count]=*Aggregate functions
groupBygroupBy=statusGroup aggregation results
deepdeep[author][fields]=name,avatarNested relation query params

Full-text search is a separate endpoint, GET /api/v1/search (MeiliSearch), not a list-endpoint parameter. It supports single-collection and cross-collection (omit collection) queries and is diacritics-insensitive for Vietnamese. See features/search.md.

Filter operators

OperatorDescription
_eqEquals
_neqNot equals
_lt, _lte, _gt, _gteComparison
_in, _ninIn / not in array
_null, _nnullIs null / not null
_contains, _icontainsContains (case-sensitive / insensitive)
_starts_with, _ends_withString prefix/suffix
_betweenRange (two-element array)
_and, _orLogical grouping
_json_containsJSONB @> — value/sub-object/array-membership containment
_has_keyThe JSON object has this key
_has_any_keys, _has_all_keysThe JSON object has any / all of these keys (string array)

Searching inside JSON. A filter field key may be a dotted path into a nested JSON/JSONB field — e.g. filter={"metadata.author.country":{"_eq":"VN"}} compiles to a data #>> '{metadata,author,country}' lookup. Path segments are restricted to [A-Za-z0-9_] and bound as parameters (injection-safe); depth is capped at 8. The _json_contains / _has_* operators run against the JSONB and use the existing GIN index. Top-level keys and structural fields behave exactly as before.

Filter forms

List endpoints accept the filter query parameter in two equivalent forms — use whichever is more convenient. Both produce the same filter object server-side.

1. Bracket form — ergonomic for hand-written URLs and HTML forms:

code
GET /api/v1/items/articles?filter[status][_eq]=published&filter[views][_gte]=100
  • Nesting maps directly: filter[field][_op]=value.
  • Values are coerced: true/false → boolean, null → null, clean integers/decimals → number (leading-zero strings like 007 stay strings), everything else → string.
  • Array operators (_in, _nin, _between) accept comma-separated values: filter[status][_in]=published,scheduled.
  • Logical groups use an index segment: filter[_and][0][status][_eq]=published.

2. JSON form — better for complex/programmatic filters and the SDK:

code
GET /api/v1/items/articles?filter={"status":{"_eq":"published"}}

If both are supplied on the same request, the JSON form wins. A malformed JSON filter returns 400 VALIDATION; a malformed bracket key is ignored rather than failing the whole request.


1. Auth

MethodPathAuthDescription
POST/api/v1/auth/loginpublicExchange Logto auth code or username/password for an access JWT + rotating refresh token
POST/api/v1/auth/registerpublicSelf-service subscriber sign-up (generic 202, anti-enumeration, rate-limited)
POST/api/v1/auth/verify-emailpublicActivate a self-service account from the emailed token
POST/api/v1/auth/resend-verificationpublicRe-send the activation email (generic 202, rate-limited)
POST/api/v1/auth/forgot-passwordpublicEmail a password-reset link (generic 202, rate-limited)
POST/api/v1/auth/reset-passwordpublicConsume a reset token, set new password, revoke refresh tokens
POST/api/v1/auth/refreshpublicRotate the refresh token (cookie or body) → fresh access JWT + new refresh token
POST/api/v1/auth/logoutpublicRevoke the presented refresh token's family + clear the cookie
GET/api/v1/auth/mebearerGet current user profile
POST/api/v1/me/change-passwordbearerVerify current password, set new hash, revoke refresh tokens + bump tokenVersion
GET/api/v1/me/sessionsbearerList the caller's active sessions (live refresh tokens, redacted)
DELETE/api/v1/me/sessions/:idbearerRevoke one of the caller's sessions
DELETE/api/v1/me/sessionsbearerRevoke all of the caller's sessions
POST/api/v1/users/subscriber-accesssite-adminGrant subscribers read on a collection (Policy DSL)
GET/DELETE/api/v1/users/subscriber-access[/:collection]site-adminList / revoke subscriber read grants
GET/api/v1/me/preferencesbearerCurrent user's preferences blob (identity-global)
PATCH/api/v1/me/preferencesbearerShallow-merge a preferences patch
GET/api/v1/me/consentsbearerList the current user's consent decisions
PUT/api/v1/me/consents/:typebearerGrant or withdraw a consent (GDPR Art. 7, PDPD)
GET/api/v1/me/data-exportbearerDownload the current user's personal data (GDPR Art. 15/20)
GET/api/v1/me/restrictionbearerCurrent restriction-of-processing state (GDPR Art. 18)
PUT/api/v1/me/restrictionbearerSet restriction of processing ({ restricted, reason? })
GET/api/v1/me/automated-decisionsbearerAgent-authored revisions on the user's content (GDPR Art. 22)
GET/api/v1/retentionadminReport configured retention horizons
POST/api/v1/retention/runadminPrune activity + handled notifications past horizons

Cookie-sourced /auth/refresh + /auth/logout require the X-LumiBase-Refresh header (CSRF brake). Refresh tokens are delivered both as an httpOnly cookie and in the response body; see docs/en/security/user-management.md §4d for per-realm TTLs and the cross-domain cookie env (REFRESH_COOKIE_SAMESITE/_DOMAIN/_SECURE).

Account erasure (GDPR Art. 17) and Subject Access Requests are served by the regulated-content-readiness feature at /api/v1/admin/erasure and /api/v1/admin/sar.

Consent management (:typemarketing · analytics · personalization · functional · sale_share):

jsonc
// PUT /api/v1/me/consents/marketing
{ "granted": true, "source": "preference_center", "version": "v1" }

// Response
{ "data": { "consentType": "marketing", "granted": true,
            "grantedAt": "2026-06-24T10:00:00.000Z", "withdrawnAt": null,
            "source": "preference_center", "version": "v1",
            "updatedAt": "2026-06-24T10:00:00.000Z" } }

Each change writes a consent_granted / consent_withdrawn audit event. Only user principals (not API keys) can manage consent. Current state is stored per (site_id, user_id, consent_type) in user_consents; full history lives in the audit log.

Email unsubscribe & suppression (CAN-SPAM):

MethodPathAuthDescription
GET/api/v1/email/unsubscribe?token=…publicOne-click unsubscribe (renders HTML confirmation)
POST/api/v1/email/unsubscribepublicRFC 8058 one-click (token in query or form body)
GET/api/v1/email/suppressionsadminList suppressed addresses
POST/api/v1/email/suppressionsadminAdd an address ({ email, reason? })
DELETE/api/v1/email/suppressions/:emailadminRemove an address (re-subscribe)

The unsubscribe token is a stateless HS256 JWT ({ siteId, email }, no expiry) signed with JWT_SECRET. Marketing sends (EmailModuleService.send({ category: 'marketing' })) filter recipients against email_suppressions before dispatch. Unsubscribe/suppression changes audit email_unsubscribed / email_suppressed / email_unsuppressed.

Preferences & save action. PATCH /api/v1/me/preferences shallow-merges a validated patch into users.preferences (other sections like language / keybindings are preserved). Send { "saveAction": "stay" | "return" | "create_new" } to set the Studio editor's post-save navigation; send { "saveAction": null } to fall back to the site default (sites.default_save_action, set via PATCH /api/v1/site). Invalid enum → 400.

External JWT authentication

A site can trust JWTs issued by an external IdP (Okta, Entra, Auth0, Logto, Keycloak, Cloudflare Access…). Present the token as Authorization: Bearer <jwt> with X-Lumi-Site. The auth chain verifies it against the issuer's public JWKS (between the API-key and internal-JWT branches): it matches the token's iss to a trusted issuer registered for that site, verifies the signature + aud/exp/nbf with the issuer's allowed (asymmetric-only) algorithms, maps role claims to LumiBase roles (default-deny — no mapping means 403, never implicit admin), enforces that any siteId claim equals the request site, and optionally JIT-provisions the user. A token whose iss isn't trusted is ignored (falls through to internal auth); once an issuer matches, verification is fail-closed.

Admin CRUD for trusted issuers (admin-only):

MethodPathDescription
GET/api/v1/admin/auth/issuersList trusted external issuers
POST/api/v1/admin/auth/issuersRegister an issuer
GET/api/v1/admin/auth/issuers/:idGet one
PATCH/api/v1/admin/auth/issuers/:idUpdate
DELETE/api/v1/admin/auth/issuers/:idRemove

Issuer config: { issuer, jwksUri | discoveryUrl, audience, algorithms (RS*/ES* only), claimMapping: { email, roles, siteId?, externalId? }, roleMapping: { "<claim role>": { roleId | systemKey } }, defaultRoleId?, jitProvisioning, clockSkewSeconds (≤300), enabled }. Errors: VALIDATION_FAILED (422, incl. an HS*/none algorithm), ISSUER_ALREADY_EXISTS (409), NOT_FOUND (404).

Login request:

json
{
  "email": "admin@example.com",
  "password": "your-password"
}

Login response: a single bearer token plus the user profile. Send the token as Authorization: Bearer <token> on subsequent requests.

json
{
  "data": {
    "token": "eyJ...",
    "user": {
      "id": "usr_abc123",
      "email": "admin@example.com",
      "firstName": "Admin",
      "lastName": "User",
      "avatar": null
    }
  }
}

For long-lived server-to-server access, create an API key (POST /api/v1/api-keys, token prefix lbk_) instead of using a login token.


2. Schema Admin

Collections

MethodPathDescription
GET/api/v1/collectionsList all collections
POST/api/v1/collectionsCreate a new collection
GET/api/v1/collections/:nameGet collection detail
PATCH/api/v1/collections/:nameUpdate collection meta (display name, icon, note)
DELETE/api/v1/collections/:nameSoft-delete collection
GET/api/v1/collections/:name/schemaExport collection schema as JSON
PUT/api/v1/collections/:name/schemaApply schema (idempotent, diff-aware)
POST/api/v1/collections/diffCompare bundle schema vs current

Create collection request:

json
{
  "name": "articles",
  "displayName": "Articles",
  "icon": "article",
  "note": "Blog articles",
  "singleton": false,
  "status_field": "status",
  "sort_field": "sort"
}

Fields

MethodPathDescription
GET/api/v1/fields/:collectionList fields in a collection
POST/api/v1/fields/:collectionAdd a field
GET/api/v1/fields/:collection/:fieldGet field detail
PATCH/api/v1/fields/:collection/:fieldUpdate field config
DELETE/api/v1/fields/:collection/:fieldRemove field

Create field request:

json
{
  "field": "title",
  "type": "string",
  "interface": "input",
  "display": "raw",
  "options": { "placeholder": "Article title" },
  "required": true,
  "sort": 1
}

Relations

MethodPathDescription
GET/api/v1/relationsList all relations
POST/api/v1/relationsCreate relation
PATCH/api/v1/relations/:idUpdate relation
DELETE/api/v1/relations/:idRemove relation

Code-First Configuration (Config Manifest)

Export / diff / apply a site's schema configuration — collections, fields, relations, settings and webhooks — as a single declarative, version-controllable JSON manifest (lumibase.config@v1). Built for CI/CD and environment sync (à la Directus schema snapshot/apply). Admin-only. Does not include content items, secrets, or access control (use /api/v1/access/* for the latter).

MethodPathDescription
GET/api/v1/config/export?scope=all|schema|settings|webhooksExport the config manifest
POST/api/v1/config/import?dryRun=true&mode=<mode>Validate + diff a manifest (no write)
POST/api/v1/config/import?mode=<mode>&allowDestructive=trueApply a manifest in one transaction

modemerge (create/update only — never deletes), replace-managed (also deletes resources within the manifest's managedScopes), replace-all (full sync — deletes anything absent from the manifest). High-risk destructive changes (dropping a collection/field with data, changing a field type, widening a relation's onDelete to cascade) are rejected unless allowDestructive=true.

Export response ({ data: ConfigManifest }):

json
{
  "data": {
    "version": "lumibase.config@v1",
    "exportedAt": "2026-06-22T00:00:00.000Z",
    "collections": [{ "name": "articles", "label": "Articles", "versioning": true }],
    "fields": [{ "collection": "articles", "field": "title", "type": "string", "interface": "input" }],
    "relations": [],
    "webhooks": [],
    "settings": [{ "key": "login_security_policy", "value": { } }],
    "managedScopes": ["articles"]
  }
}

Dry-run / apply response ({ data: { valid, errors, diff, applied? } }): the diff lists per-resource create | update | unchanged | delete counts and a top-level risk (low | medium | high). On apply, applied reports { created, updated, deleted }. Validation errors use codes UNSUPPORTED_MANIFEST_VERSION, DANGLING_REFERENCE, DUPLICATE_KEY; a blocked destructive apply returns HTTP 409 DESTRUCTIVE_BLOCKED.

CLI: pnpm --filter @lumibase/cms config export|diff|apply — see docs/en/contributing/code-first-config.md.


3. Items (Generic CRUD)

MethodPathDescription
GET/api/v1/items/:collectionList items (paginated, filterable)
POST/api/v1/items/:collectionCreate item (or array for bulk)
GET/api/v1/items/:collection/:idGet single item
PATCH/api/v1/items/:collection/:idPartial update
PUT/api/v1/items/:collection/:idFull replace
DELETE/api/v1/items/:collection/:idDelete item — 409 if blocked by dependents
GET/api/v1/items/:collection/:id/dependentsList records that reference this item
POST/api/v1/items/:collection/:id/resolve-dependentsBatch-resolve a relation's dependents
GET/api/v1/items/:collection/:id/revisionsList revisions
POST/api/v1/items/:collection/:id/revertRevert to revision
GET/api/v1/items/:collection/:id/versionsList named draft versions (each has a mainChanged flag)
POST/api/v1/items/:collection/:id/versionsCreate a version { key, name } (snapshots current main)
GET/api/v1/items/:collection/:id/versions/:keyGet a version
PATCH/api/v1/items/:collection/:id/versions/:keyUpdate a version { data?, name? } (does not touch main)
DELETE/api/v1/items/:collection/:id/versions/:keyDelete a version
GET/api/v1/items/:collection/:id/versions/:key/compareField diff vs main → { main, version, changes }
POST/api/v1/items/:collection/:id/versions/:key/promoteApply version to main (writes a revision); meta.mainDiverged

Dependent records. Because item references live in JSONB (not physical FK columns), a relation's onDelete is enforced in the application layer. On DELETE, if a relation declared restrict still has records pointing at the item, the delete is blocked with 409 DEPENDENT_RECORDS_EXIST and a dependents array. (set null/cascade are not auto-applied on soft-delete — that would break soft-delete's recoverability; the editor clears them explicitly.) GET …/dependents returns { data: { blocking, dependents: [{ relation, collection, field, onDelete, count, sample }] } }. POST …/resolve-dependents takes { action: "set_null"|"delete"|"reassign", relation, newTargetId?, hard? } and runs transactionally (delete delegates to the normal item-delete path). Errors: DEPENDENT_RECORDS_EXIST (409), FIELD_REQUIRED (409, set_null on a required field), INVALID_TARGET (422, reassign), NOT_FOUND (404).

List pagination & totals. GET /items/:collection accepts limit (1–200, default 25), offset, and meta:

  • meta=total_count (default) — response is { data, meta: { total, limit, offset } }.
  • meta=none — skips the count(*) aggregate for a cheaper query; response is { data, meta: { limit, offset } } (no total). Use for infinite-scroll / feed views that never render a total page count.

The default is unchanged, so existing clients keep receiving meta.total. The @lumibase/sdk readItems accepts the same meta option.

Optional headers:

  • X-Lumi-Draft: true — fetch draft version
  • X-Lumi-Locale: vi — apply translation server-side

Create item:

json
{ "title": "Hello World", "status": "draft", "author": "usr_abc123" }

Bulk create (array body):

json
[
  { "title": "Article 1", "status": "published" },
  { "title": "Article 2", "status": "draft" }
]

3b. Content Releases

A Release collates specific item revisions across collections into a named bundle that publishes all at once — manually or scheduled for a date/time (à la Directus Releases). Publish delegates to the item update path, so the editorial gate, validation, permissions and hooks all apply.

MethodPathDescription
POST/api/v1/releasesCreate a release (draft, or scheduled if publishAt set)
GET/api/v1/releasesList releases (?status=&page=&limit=)
GET/api/v1/releases/:idRelease detail + its items
PATCH/api/v1/releases/:idUpdate meta, addItems/removeItems, set publishAt
POST/api/v1/releases/:id/publishPublish now (manual)
DELETE/api/v1/releases/:idDelete release (items cascade)

Create / patch body:

json
{
  "name": "Spring launch",
  "atomicityMode": "all_or_nothing",
  "publishAt": "2026-07-01T09:00:00Z",
  "maintenanceWindow": { "windows": [{ "dow": 1, "start": "09:00", "end": "17:00" }] },
  "addItems": [
    { "collection": "articles", "itemId": "itm_1", "targetStatus": "published", "revisionId": "rev_3" }
  ]
}

atomicityMode: all_or_nothing (pre-flight checks every item is publishable — exists, not deleted, editorial gate satisfiable — and publishes nothing if any is blocked) or best_effort (publishes each independently, recording a per-item outcome). revisionId pins a specific revision's snapshot; omit it to publish the item's live state at publish time.

Publish response ({ data: { release, status, outcomes } }): status is published | partially_failed | failed; each outcome is { collection, itemId, outcome: 'published'|'skipped'|'failed', reason? }. A partial/failed publish still returns HTTP 200 with the outcomes.

Error codes: EMPTY_RELEASE (422), ALREADY_PUBLISHED (409), RELEASE_IMMUTABLE (409, editing a published release's items), ITEM_NOT_FOUND (404), REVISION_STAGED (409, pinning an uncommitted revision), VALIDATION_FAILED (422, incl. publishAt in the past). Per-item publish blockers surface as outcome reasons EDITORIAL_GATE_REQUIRED / ITEM_DELETED.

Scheduled releases publish via the shared content-scheduler tick (sweepDueReleases) — idempotent and maintenanceWindow-aware.


4. Permissions, Roles & Policies

MethodPathDescription
GET/api/v1/permissions/meEffective permission matrix for current user
POST/api/v1/permissions/checkDebug: evaluate a policy rule
GET/POST/PATCH/DELETE/api/v1/rolesRole CRUD
GET/POST/PATCH/DELETE/api/v1/policiesPolicy CRUD
GET/POST/DELETE/api/v1/policies/:id/permissionsPermission rules in a policy
POST/api/v1/policies/:id/attachAttach policy to a role, user, or team

Permission rule shape:

json
{
  "collection": "articles",
  "action": "read",
  "fields": ["id", "title", "status"],
  "conditions": { "status": { "_eq": "published" } }
}

5. Users & Teams

MethodPathDescription
GET/POST/api/v1/usersList / create users
GET/PATCH/DELETE/api/v1/users/:idGet / update / delete user
POST/api/v1/users/inviteSend invitation email
POST/api/v1/users/:id/impersonateImpersonate (admin only)
GET/api/v1/users/:id/sessionsList active sessions
DELETE/api/v1/sessions/:idRevoke a session
GET/POST/PATCH/DELETE/api/v1/teamsTeam CRUD

6. Files & Assets

MethodPathDescription
POST/api/v1/files/upload-urlGet presigned R2/S3 PUT URL
POST/api/v1/filesRegister file metadata after upload
GET/api/v1/filesList files (filterable)
GET/api/v1/files/:idFile metadata
PATCH/api/v1/files/:idUpdate metadata (title, tags, folder)
DELETE/api/v1/files/:idDelete file
GET/api/v1/assets/:idServe/transform image (query params below)
POST/api/v1/media/:keyUpload raw media bytes (RBAC media:create; upload guard applies)
GET/api/v1/media/:keyDownload media (served as attachment + nosniff); with transform params → 302 to derivative
DELETE/api/v1/media/:keyDelete media object
GET/api/v1/transform-presetsList named image-transform presets (RBAC media:read)
POST/api/v1/transform-presetsCreate a preset { key, name, dsl } (RBAC media:create)
PATCH/api/v1/transform-presets/:idUpdate a preset (RBAC media:update)
DELETE/api/v1/transform-presets/:idDelete a preset (RBAC media:delete)
GET/api/v1/uploads/configEffective upload policy + type catalogue (any member)
PUT/api/v1/uploads/configUpdate allowlist / size cap (site admin)

Image transform DSL (/api/v1/media/:key and /api/v1/assets/:id):

code
?width=800&height=600&format=webp&quality=80&fit=cover&focal=0.5,0.5
?preset=thumbnail

On /media/:key, transform params are validated against transformDslSchema (@lumibase/shared; MAX_DIM=5000) and the request 302-redirects to the runtime image URL (CF Image Resizing / Imgproxy). No params → the original bytes. ?preset=<key> resolves a saved transform_presets row for the site. See .kiro/specs/image-transform-dsl.

Upload policy (/api/v1/uploads/config). Enforced by the file-upload-policy guard on every upload surface (POST /api/v1/files, PUT /api/v1/files/upload/:key, POST /api/v1/media/:key): a public role cannot upload; the body is size-capped on its true byte length; the declared MIME must be in the allowlist and match the filename extension; raw bytes are content-sniffed (magic bytes) and executables / active-content SVGs are rejected; raster images are scanned for an embedded script/executable payload (polyglot) and rejected. The allowlist + cap resolve per-site DB (settings key upload_policy) → env (FILE_UPLOAD_*) → default. See the feature spec .kiro/specs/upload-file-controls/ and docs/en/security/runtime-security-guards-plan.md §3 for the full guarantees.

code
GET  /api/v1/uploads/config
→ { data: { maxBytes, allowedMimeTypes[], allowedExtensions[], catalogue[] } }

PUT  /api/v1/uploads/config           # site admin only
{ "maxBytes": 5242880, "allowedMimeTypes": ["image/png","image/jpeg"] }

6b. View presets (collection views + bookmarks)

MethodPathDescription
GET/api/v1/presets/effective?collection=Effective default view (precedence user > role-chain > global), with sourceScope
GET/api/v1/presets/bookmarks?collection=Named bookmarks visible to the principal, each with sourceScope
GET/api/v1/presetsList presets (optional ?collection=)
POST/api/v1/presetsCreate a preset/bookmark; user-scope self-managed, role/global require admin
PATCH/api/v1/presets/:idUpdate (authorized against the row's current scope)
DELETE/api/v1/presets/:idDelete (user owns own; role/global require admin)

Scope is derived from ownership columns: userId set → user, roleId set → role, neither → global. Role presets inherit down the roles.parentId chain. See .kiro/specs/presets-inheritance.

6c. Translation memory

MethodPathDescription
GET/api/v1/tm?source=&target=&entrySource=&limit=&offset=List TM entries (paginated; meta { total, limit, offset })
POST/api/v1/tmUpsert an entry
PATCH/api/v1/tm/:idEdit target/quality/source (siteId-scoped, 404 cross-tenant)
DELETE/api/v1/tm/:idDelete an entry (siteId-scoped)
POST/api/v1/tm/lookupBest fuzzy match { query, sourceLang, targetLang, threshold? }{ match }
POST/api/v1/tm/translateMT pipeline { text, from, to } (TM → glossary → provider)

TM_DEFAULT_THRESHOLD = 75 (@lumibase/shared). Learn-TM (Studio) upserts human translations on save when translations.learnTm is enabled. See .kiro/specs/translation-memory-ui.


7. Flows / Automation

MethodPathDescription
GET/api/v1/flows/operationsRegistered operation keys + option hints (editor palette / validateGraph knownKeys)
GET/api/v1/flowsList flows (filter by status, trigger)
POST/api/v1/flowsCreate a new flow (validates graph when active; schedule flows require a valid cron)
GET/api/v1/flows/:idGet flow detail + graph
PATCH/api/v1/flows/:idUpdate flow (graph, status, options)
DELETE/api/v1/flows/:idDelete flow
POST/api/v1/flows/:id/runManual trigger with body as input
POST/api/v1/flows/:id/triggerWebhook trigger — no CMS session; authenticate with the per-flow token (x-flow-token header or Bearer), compared constant-time. Input = { body, headers, query } (credential headers stripped). 404 for non-webhook/inactive flows; 401 WEBHOOK_NOT_CONFIGURED/UNAUTHENTICATED
GET/api/v1/flows/:id/runsExecution history
GET/api/v1/flows/:id/runs/:runIdSingle run detail (steps output)

Save-time gates: an active flow must pass the shared graph validation — 400 with GRAPH_DANGLING_EDGE / GRAPH_CYCLE / GRAPH_NO_ENTRY / GRAPH_UNKNOWN_OPERATION (+ nodeId) otherwise; drafts may hold invalid work-in-progress. Schedule flows validate triggerOptions.cron (5-field, UTC): 400 CRON_INVALID on a malformed expression, 400 CRON_REQUIRED when activating without one; next_run_at is computed on save and advanced by the scheduler sweep before each enqueued run (idempotent).

Triggers: event flows fire on item create/update/delete via the flow-events queue (triggerOptions.collection / .action filter, string or array, missing = all); schedule flows are swept every scheduler tick; webhook flows use the endpoint above; manual runs inline via /run.

Trigger a flow:

bash
POST /api/v1/flows/flw_abc123/run
Content-Type: application/json
Authorization: Bearer <token>

{ "userId": "usr_xyz", "action": "welcome" }

Response:

json
{
  "data": {
    "runId": "run_def456",
    "status": "running",
    "startedAt": "2026-06-07T00:00:00Z"
  }
}

8. AI Copilot

MethodPathDescription
POST/api/v1/ai/chatSend natural-language instruction to AI Copilot
GET/api/v1/ai/approvalsList pending HITL approvals
POST/api/v1/ai/approvals/:id/decideApprove or reject a pending action
GET/api/v1/ai/conversationsList conversation history
GET/api/v1/ai/conversations/:id/messagesGet messages in a conversation
DELETE/api/v1/ai/conversations/:idDelete a conversation

Chat request:

json
{ "message": "Create a collection called 'products' with title, price, and status fields" }

Safe skill response:

json
{
  "data": {
    "status": "executed",
    "data": { "collectionName": "products", "fieldsCreated": 3 }
  }
}

HITL required (dangerous skill) response:

json
{
  "data": {
    "status": "pending_approval",
    "approvalId": "apr_ghi789",
    "message": "Creating a collection requires admin approval."
  }
}

Decide on an approval:

json
{ "decision": "approved" }

Agent API (Content OS)

All routes mount under the authenticated chain; the token's roles are the capability set.

MethodPathDescription
GET/POST/api/v1/agent/goalsList / create goals (execution: 'async' enqueues a queued run)
POST/api/v1/agent/goals/:id/decomposePlanner: create role-scoped sub-goals inheriting remaining budget
POST/api/v1/agent/goals/:id/settleSettle a parent goal from its children's terminal states
GET/POST/PATCH/DELETE/api/v1/agent/roles[/:name]Agent role library CRUD (admin) — seeded with Planner, Writer, …
GET/api/v1/agent/autonomyTrust ledger: grants + open incidents
GET/POST/api/v1/agent/autonomy/promotions[...]Promotion proposals; POST :id/decide is the only path to a higher level (admin)
GET/POST/api/v1/agent/staged[...]Veto window: pending stagings enriched with approvalId/collection/itemId/patch/agentRole from the staging revision (null fields when the staging is gone); POST :id/veto discards a staging
POST/api/v1/agent/approvals/:id/agent-decideAgent-as-reviewer decision (needs review:<domain>; self-review forbidden)
GET/POST/api/v1/agent/constitution[...]Versions, draft, /compile (NL→evaluators), :id/dry-run, :id/activate
GET/POST/api/v1/agent/kill-switch[/lift]Four-scope stop (run/intent/role/site); freezes need agents:freeze
*/api/v1/agent/intents[...]Content intents CRUD, `:id/pause
POST/api/v1/mcpMCP server (Streamable HTTP, JSON-RPC 2.0) — gated by contentOs.mcp flag
GET/DELETE/api/v1/items/:collection/:id/pins[/:field]Law Zero pins: list / release
GET/api/v1/deliver/llms.txt/:site_idPublic llms.txt index per site

9. Realtime (WebSocket)

Endpoint: wss://api.<your-site>.lumibase.dev/api/v1/realtime

Auth (ticket exchange): browsers cannot send Authorization on the WS handshake, so exchange the session for a short-lived (1 min) ticket first: POST /api/v1/realtime/ticket (studio) or POST /api/v1/realtime/audience-ticket (end-user), then connect:

code
wss://...realtime?ticket=<ticket>

The studio ticket embeds the collections the principal can read; the hub rejects any other subscribe with { "type": "error", "code": "SUBSCRIBE_FORBIDDEN" }.

Subscribe to collection (optional Directus-style filter, evaluated server-side over the event envelope collection/action/itemId):

json
{ "type": "subscribe", "collection": "articles", "filter": { "action": { "_eq": "delete" } } }

Server event — signal-only: no row data on the wire (clients re-fetch via /items, which enforces RBAC + field masking):

json
{ "type": "event", "collection": "articles", "action": "update", "itemId": "art_001", "payload": null }

Server notification frame (push-noti feature) — broadcast to every session for the site (not collection-scoped):

json
{ "type": "notification", "notification": { "id": "…", "kind": "approval", "severity": "info", "title": "…", "body": "…", "deepLink": "/mission-control/inbox?entry=approval:…", "entityId": "…", "ts": "2026-06-23T01:00:00Z" } }

See features/websockets-realtime.md for full protocol reference.


9b. Push notifications

Web Push (VAPID) + in-app realtime for agent events. Tenant-scoped; the VAPID key is a shared deployment resource. See features/push-notifications.md.

MethodPathDescription
GET/api/v1/push/vapid-public-keyApplication-server public key (404 PUSH_NOT_CONFIGURED when unset). Same key for every tenant.
GET/api/v1/push/status{ vapidConfigured, realtimeAvailable, subscriptions } for the active site.
POST/api/v1/push/testDispatch a one-off test notification to the active site (both transports).
POST/api/v1/push/subscriptionsUpsert a browser subscription: { endpoint, keys: { p256dh, auth } }.
DELETE/api/v1/push/subscriptionsRemove a subscription by { endpoint }.

10. Settings

MethodPathDescription
GET/api/v1/settingsGet all site settings
PATCH/api/v1/settingsUpdate multiple settings
GET/api/v1/settings/:keyGet single setting by key
PUT/api/v1/settings/:keySet single setting
POST/api/v1/settings/exportExport settings as JSON bundle
POST/api/v1/settings/applyApply settings bundle

Site configuration

The active site's identity, branding and theme defaults live on the sites row (not the key/value settings table). Scoped to the active tenant via the X-Lumi-Site header.

MethodPathDescription
GET/api/v1/siteGet the active site's configuration
PATCH/api/v1/siteUpdate site identity / branding / theme (partial)

PATCH /api/v1/site accepts any subset of: name, displayTitle, siteUrl, descriptor, domain, defaultLanguage, defaultAppearance (auto|light|dark), defaultSaveAction (stay|return|create_new — the site-wide default save action a user's personal preference overrides), branding ({ logoUrl, faviconUrl, brandColor }), themeOverrides ({ light, dark } maps of whitelisted CSS tokens → H S% L% values), and customCss. An empty string clears a nullable field. A duplicate domain returns 409 { errors: [{ code: 'DOMAIN_TAKEN' }] }.

Theme model: the site holds the global defaults; per-user appearance/theme/ language overrides (resolved client-side) take precedence.


10b. Regulated / sensitive content (admin)

Opt-in capability set (spec: regulated-content-readiness). All admin routes require the admin role; field-level decryption additionally requires the read_decrypted permission. Sensitive pii/phi field reads are recorded in field_access_log; decrypt failures fail closed (500 DECRYPTION_FAILED) and are audited — never a placeholder.

Encryption — keys & envelope mode

Key material lives only in the runtime KeyProvider (Workers Secrets / env: ENCRYPTION_KEY_<id> + ENCRYPTION_ACTIVE_KEY_ID); these surfaces record metadata + audit and drive migrations.

MethodPathDescription
GET/api/v1/admin/encryption/keysList configured key metadata (id/status/algo).
POST/api/v1/admin/encryption/keys/rotatePromote a provisioned key to active; retires the previous. Body { keyId }. Audits encryption_key_rotated. 422 KEY_NOT_PROVISIONED if the bytes are absent.
POST/api/v1/admin/encryption/keys/rewrapRe-encrypt retired-key ciphertext (and re-wrap per-record DEKs) onto the active key. Idempotent, resumable, bounded per call.
GET/api/v1/admin/encryption/envelopeCurrent envelope-mode setting + migration progress.
POST/api/v1/admin/encryption/envelopeToggle envelope (per-record DEK) mode. Body { enabled, password }step-up auth re-verifies the admin password (401 INVALID_CREDENTIALS on mismatch). Records encryption.envelope, audits envelope_mode_changed, enqueues the background migration and drains a bounded inline batch.
POST/api/v1/admin/encryption/envelope/migrateDrain more migration batches (resumable). Poll until { done: true }.

Editorial review → publish

Mounted at /api/v1/editorial. Per-collection toggle via collection meta.editorialWorkflow; meta.requireSeparateReviewer enforces a different reviewer than the author. Transitions audit editorial_transition.

MethodPathDescription
GET/api/v1/editorial/reviewsList review requests (filter by status/assignee).
POST/api/v1/editorial/:collection/:id/submit-reviewMove draft → in_review; assign a reviewer.
POST/api/v1/editorial/:collection/:id/approvein_review → approved (→ publish per workflow).
POST/api/v1/editorial/:collection/:id/rejectin_review → rejected. Body { reason }.

GDPR erasure (dual-control)

Mounted at /api/v1/admin/erasure. Crypto-shreds (drops per-record DEK) or hard-deletes items + revisions while preserving the tamper-evident data_erased audit (no cascade). Dual-control via erasureDualControl setting.

MethodPathDescription
POST/api/v1/admin/erasureCreate an erasure request. Body { collection, filter } + reason. Stores a subject hash, never plaintext.
POST/api/v1/admin/erasure/:id/confirmSecond-admin confirmation (dual-control).
POST/api/v1/admin/erasure/:id/executeExecute the confirmed erasure; audits data_erased with recordCount.

Field access log & Subject Access Request

MethodPathDescription
GET/api/v1/admin/field-access-logQuery decrypted-read audit of pii/phi fields (never values).
POST/api/v1/admin/sar/exportSubject Access Request: export one subject's decrypted records + provenance. Forces a field_access_log entry (Req 13.2).

11. Extensions

MethodPathDescription
GET/api/v1/extensionsList installed extensions
POST/api/v1/extensions/uploadUpload extension bundle (multipart)
POST/api/v1/extensions/:id/enableEnable extension
POST/api/v1/extensions/:id/disableDisable extension
POST/api/v1/extensions/:id/capabilitiesGrant capabilities
GET/api/v1/extensions/ui/manifestUI manifest for dynamic Studio import

Signing & official extensions. Every install/load path (marketplace install, generic CRUD POST /extensions, the dynamic endpoint mount, and hook dispatch) routes through a shared verifier. Detached Ed25519 signatures are checked against publisher keys resolved from MARKETPLACE_PUBLIC_KEYS (env) merged with the lumibase_publisher_keys table (DB overrides env for official/revoked). isOfficial is derived server-side (name in the lumibase- namespace and a signature by an official key) — never from a manifest claim. Official extensions are fail-closed: an unverifiable bundle never loads. Third-party enforcement follows LUMIBASE_EXT_SIGNATURE_POLICY (require default, or warn). The lumibase- namespace is reserved: community /marketplace/submit rejects it, and official extensions with autoInstall/enabledByDefault are installed during setup / on site-create by the reconciler. Sign bundles with the @lumibase/extension-cli (lumibase-ext keygen|sign|verify).


11a. Pageviews (visitor counting)

Public beacon + authenticated read for the built-in pageview module. Per-site config lives in the pageviews settings key (scope: 'module'): strategy (db-rollup default | hot-counter | cdc | hll), userTable, respectConsent, botFilter, hashSalt, flushIntervalS. Authenticated hits are attributed to a user only with analytics consent; anonymous hits use a salted hash (never a raw IP). Counters flush to lumibase_pageview_daily every 5 minutes.

MethodPathDescription
POST/api/v1/pageviews/:site_id/hitPublic beacon; bot/DNT/GPC-filtered, rate-limited; returns 204
GET/api/v1/pageviews/stats?from=&to=&path=Authenticated daily views/uniques for a range

11b. Email (templates, layouts, send)

Site-admin scope (requireSiteAdmin). Backed by the shared EmailService (SMTP / MailChannels) + a site-scoped template/layout store. Full guide: docs/en/features/email-service.md.

MethodPathDescription
GET/api/v1/email/capabilitiesTransport availability + default from
GET/api/v1/email/layoutsList layouts
POST/api/v1/email/layoutsCreate layout (HTML shell with {{content}})
PATCH/api/v1/email/layouts/:idUpdate layout
DELETE/api/v1/email/layouts/:idDelete layout
GET/api/v1/email/templatesList templates
POST/api/v1/email/templatesCreate template
PATCH/api/v1/email/templates/:idUpdate template
DELETE/api/v1/email/templates/:idDelete template
POST/api/v1/email/templates/:key/previewRender without sending
POST/api/v1/email/sendRender (if templateKey) + send — extension entry point
POST/api/v1/email/testSend a one-off test mail

POST /api/v1/email/send body: { to[], cc?, replyTo?, variables?, and exactly one of templateKey or inline: { subject, html?, text? } }. Returns 502 DELIVERY_FAILED, 404 NOT_FOUND (template), or 503 EMAIL_NOT_CONFIGURED (degraded mode).


12. Firebase Sync

Sync content (items) to a Firebase target — Cloud Firestore or Realtime Database — in real time. All endpoints require site-scoped admin. Firebase credentials are write-only (supplied on create/update, encrypted at rest with ENCRYPTION_KEY, never returned). See features/firebase-sync.md.

MethodPathDescription
GET/api/v1/firebase-sync/pipelinesList sync pipelines for the active site
POST/api/v1/firebase-sync/pipelinesCreate a pipeline
GET/api/v1/firebase-sync/pipelines/:idPipeline detail (credentials omitted)
PATCH/api/v1/firebase-sync/pipelines/:idUpdate config / rotate credentials
DELETE/api/v1/firebase-sync/pipelines/:idDelete pipeline (cascades its log)
GET/api/v1/firebase-sync/pipelines/:id/logRecent sync attempts
POST/api/v1/firebase-sync/pipelines/:id/backfillPush all matching items to Firebase now

Create a pipeline (Firestore):

bash
POST /api/v1/firebase-sync/pipelines
Content-Type: application/json
Authorization: Bearer <token>
X-Lumi-Site: <siteId>

{
  "name": "blog-to-firestore",
  "target": "firestore",
  "projectId": "my-firebase-project",
  "credentials": {
    "project_id": "my-firebase-project",
    "client_email": "svc@my-firebase-project.iam.gserviceaccount.com",
    "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
  },
  "collections": ["articles", "authors"],
  "targetPath": "content/{collection}",
  "syncOnCreate": true,
  "syncOnUpdate": true,
  "syncOnDelete": true
}

For target: "rtdb", credentials is { "databaseUrl": "https://<project>.firebaseio.com", "secret": "<rtdb-secret>" }.

Response (201):

json
{
  "data": {
    "id": "V1StGXR8_Z5jdHi6B-myT",
    "name": "blog-to-firestore",
    "target": "firestore",
    "status": "active",
    "projectId": "my-firebase-project",
    "collections": ["articles", "authors"],
    "targetPath": "content/{collection}",
    "syncOnCreate": true,
    "syncOnUpdate": true,
    "syncOnDelete": true,
    "lastSyncAt": null,
    "lastSyncItemCount": null,
    "createdAt": "2026-06-17T00:00:00Z",
    "updatedAt": "2026-06-17T00:00:00Z"
  }
}

Backfill response:

json
{ "data": { "scanned": 120, "pushed": 118, "failed": 2, "truncated": false } }

Error codes specific to this section: ENCRYPTION_KEY_REQUIRED (400 — ENCRYPTION_KEY not configured, cannot encrypt credentials), VALIDATION_ERROR (400 — body / credential shape does not match the selected target).


12a. Translation Memory

Reusable translations feeding the MT pipeline (TM → glossary → provider).

MethodPathDescription
GET/api/v1/tmList entries; filters ?source=&target=&entrySource=; paginated ?limit=&offset={ data, meta: { total, limit, offset } }
POST/api/v1/tmUpsert a TM entry
PATCH/api/v1/tm/:idUpdate targetText/quality/context/source
DELETE/api/v1/tm/:idDelete an entry
POST/api/v1/tm/lookupFuzzy match (threshold default 75, shared TM_DEFAULT_THRESHOLD)
POST/api/v1/tm/translateFull pipeline (TM → glossary → MT provider)

12b. Insights (Dashboards)

User-built dashboards of aggregate panels over collections. Panel queries are executed safely: every referenced field must be in the collection's field whitelist and the query is scoped to the active site — no user input reaches SQL identifiers.

MethodPathDescription
GET/api/v1/dashboardsList dashboards
POST/api/v1/dashboardsCreate dashboard { name, icon?, color?, note? }
GET/api/v1/dashboards/:idGet dashboard
PATCH/api/v1/dashboards/:idUpdate dashboard
DELETE/api/v1/dashboards/:idDelete dashboard
GET/api/v1/dashboards/:id/panelsList panels
POST/api/v1/dashboards/:id/panelsCreate panel { name, type, position, query }
PATCH/api/v1/dashboards/:id/panels/:panelIdUpdate panel (incl. position for layout)
DELETE/api/v1/dashboards/:id/panels/:panelIdDelete panel
POST/api/v1/dashboards/:id/panels/:panelId/dataRun a panel → { data, meta: { executedAt, rowCount, durationMs } }
POST/api/v1/dashboards/:id/panels/previewDry-run a PanelQuery (editor preview)

PanelQuery (shared contract @lumibase/shared): { collection, aggregate (count|sum|avg|min|max), field?, groupBy?, filter? (condition rule), dateRange?, limit? }. field is required for non-count aggregates. A field outside the collection whitelist returns 400 { errors: [{ code: 'INVALID_FIELD' }] }.


12c. Git Integration (GitHub / GitLab)

Per-site connections to source repositories: track pull requests + CI, view/store CI logs, post a content-validation status back, reconcile declarative intents (GitOps), and run auto preview environments. Authenticated routes require site admin and ENCRYPTION_KEY (tokens are stored encrypted, never returned).

MethodPathDescription
GET/api/v1/integrations/gitList integrations for the site
POST/api/v1/integrations/gitCreate { provider (github|gitlab), repoFullName, displayName, authMethod (app|pat), token?, installationId? } → 409 on duplicate (provider, repo)
GET/api/v1/integrations/git/:idGet one
PATCH/api/v1/integrations/git/:idUpdate display name / token / status / sync config
DELETE/api/v1/integrations/git/:idDisconnect (forgets token)
POST/api/v1/integrations/git/:id/rotate-secretRotate the webhook secret
GET/api/v1/integrations/git/:id/oauth/authorizeReturns { authorizeUrl } (single-use cache state)
GET/api/v1/integrations/git/:id/pull-requestsList cached PRs
POST/api/v1/integrations/git/:id/pull-requests/refreshPull PRs live from the provider (upsert cache)
GET/api/v1/integrations/git/:id/pull-requests/:number/ciCI runs for the integration
POST/api/v1/integrations/git/:id/pull-requests/:number/validateValidate config + post lumibase/content-validation commit status
GET/api/v1/integrations/git/:id/ci-runs/:runId/logsFetch + cache CI log (blob storage)
POST/api/v1/integrations/git/:id/gitops/syncReconcile lumibase/intents.json into content intents (+ drift scan/reconcile)
GET/api/v1/integrations/git/:id/provenanceProvenance ?collection=&itemId= — which commit/PR changed an item

Public (no session; signature-verified or single-use state):

MethodPathDescription
GET/api/v1/integrations/git/oauth/:provider/callbackOAuth code → token exchange (bound to cache state)
POST/api/v1/integrations/git/webhook/:provider/:siteId/:integrationIdWebhook receiver — GitHub HMAC-SHA256 (X-Hub-Signature-256) / GitLab token (X-Gitlab-Token); idempotent by delivery id

Preview environments are opt-in per integration (sync_config.preview = true): on PR open/sync LumiBase seeds an ephemeral site (${siteId}__pr-${number}) served at /api/v1/deliver/page/${ephemeralSiteId}/...; on close/merge it is torn down. CI failure records an agent_incident (role git-sync) + a git_ci_failed audit entry.


13. Delivery (Public)

No Authorization header needed. Permission applied via public role.

MethodPathDescription
GET/api/v1/deliver/page/:slug1-roundtrip page hydration
GET/api/v1/deliver/items/:collectionPublic item list
GET/api/v1/deliver/menu/:keyMenu config

HTTP caching (GET /deliver/page/:site_id/:slug): responses without credentials are shared-cacheable so any CDN/proxy can absorb repeat reads.

RequestResponse headers
No credentials (default)Cache-Control: public, s-maxage=60, stale-while-revalidate=300 · ETag: W/"…" · Vary: X-Lumi-Site
Authorization header presentCache-Control: private, no-store (no shared ETag)
Page not found404 + Cache-Control: no-store

Conditional requests: send If-None-Match with the last ETag; a match returns 304 Not Modified with an empty body and skips section hydration entirely. The ETag is a site-level content fingerprint — any item write (or a scheduled publish/unpublish taking effect) rotates it, so a stale 304 is never served at the cost of a lower revalidation hit-rate.

Tunables (env): LUMIBASE_DELIVER_SMAXAGE (seconds, default 60, 0 disables public caching), LUMIBASE_DELIVER_SWR (seconds, default 300).


14. Utility endpoints

MethodPathDescription
GET/api/v1/utils/healthHealth check (DB, cache, storage, search, queue)
GET/api/v1/utils/versionAPI version info
POST/api/v1/utils/render-templateRender a display template server-side
POST/api/v1/utils/jsonata/testEvaluate JSONata expression
GET/api/v1/metricsPrometheus metrics (Docker mode only)

Health response:

json
{
  "data": {
    "status": "healthy",
    "checks": {
      "database": "ok",
      "cache": "ok",
      "storage": "ok",
      "search": "ok",
      "queue": "ok"
    },
    "version": "1.0.0",
    "runtime": "cloudflare"
  }
}

15. Rate limits

ScopeLimit
Auth endpoints30 req/min per IP
Items write600 req/min per user
Items read6,000 req/min per user
File upload100 req/min per user
Realtime connections50 concurrent per site
AI Chat60 req/min per user

Rate limit headers:

code
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
X-RateLimit-Reset: 1749254460

16. Versioning

Breaking changes get a new path prefix (/api/v2). The previous version is maintained for at least 12 months.

Send X-Lumi-API-Version: 1 to pin to a specific API version. Default is the latest stable.

Change Feed (/api/v1/cdc — spec cdc-extension-integration)

Mounted on the authenticated api app BEFORE the ClickHouse CDC control-plane router. Frontend-realm tokens are rejected on every route (ADR-011).

MethodPathGuardDescription
GET/cdc/eventscapability cdc:subscribe (admin implies)Keyset-paginated change events. Query: cursor, collections (CSV), operations (CSV), limit (≤500), wait (long-poll seconds ≤25 — holds an empty first read until an event arrives). Returns { data, meta: { nextCursor, hasMore } }. Envelope type is <resource>.<operation> (items.* content, collections.*/fields.* schema). 400 malformed cursor; 410 CURSOR_EXPIRED + earliestCursor past retention.
GET/POST/cdc/subscriptionssite adminList (with per-subscription lag) / create (max 50 per site → 403; duplicate name → 409; kind=webhook requires a webhook with a secret → 400).
GET/PATCH/DELETE/cdc/subscriptions/:idsite adminDetail / update filters + pause/resume (invalid transition → 409) / delete (audited).
POST/cdc/subscriptions/:id/ackcapability cdc:subscribeCommit a pull checkpoint. Forward-only — rewind → 409 ACK_REGRESSION.
POST/cdc/subscriptions/:id/replaysite adminRewind inside retention ({cursor} xor {occurred_after}); resets dead/stale → active. Audited.
POST/cdc/subscriptions/:id/dispatchsite adminOn-demand dispatch (no-queue fallback). 202.
GET/cdc/subscriptions/:id/deliveriessite adminDelivery-attempt history, newest first (limit, page; meta.total).

Webhook deliveries are signed: X-Lumibase-Signature: t=<unix>,v1=<hmac_sha256_hex> over `${t}.${rawBody}` — see docs/en/features/cdc-change-feed.md for the verify snippet and envelope reference.

Last modified: 23/07/2026