A read-only, secret-safe API for platform configuration. It lets your
backend scripts, monitoring, and AI agents read the same settings you manage
under Settings — without ever exposing a secret.
The same capability is served over two surfaces:
| Surface | Endpoint | Auth |
|---|
| REST | GET /api/v2/config and GET /api/v2/config/{path} | Personal access token (PAT) with the api:full scope |
| MCP | Tools config.list / config.get at POST /mcp | Personal access token with the mcp:config:read scope |
Both go through one authoritative read seam, so the allowlist, the
permission checks, and the scope rules are identical regardless of which
surface you call. There is no write surface — configuration is edited only
in the admin UI.
Not the Public read APIThis is the authenticated, admin-grade configuration API. The
anonymous, cacheable widget endpoint
(GET /api/public/v1/config/{section}) is a different surface with a
different allowlist (public_readable) — see
Public read API.
What is readable
Exposure is a strict opt-in allowlist, filtered twice more at call time:
- Schema allowlist — a field is visible to the API only when the module
that ships it declares
api_readable: true in its configuration schema.
There is no admin toggle; everything else simply does not exist as far as
the API is concerned.
- Your section permission — you only see fields of sections your role
lets you view (
comerix.config.<section>.view). The list endpoint filters
them out; reading one directly behaves like an unknown path.
- Scope policy — the value is resolved at a scope you are allowed to
read (see Scope targeting).
Secrets are never readable
Secret configuration — encrypted fields (encrypted_string, e.g. the SMTP
password) and fields marked sensitive — can never be exposed:
- The platform rejects a schema outright that tries to combine
api_readable with a secret field, so the combination cannot ship.
- As defence in depth, the reader re-checks secrecy at call time and refuses
to return a secret value even if one were ever flagged.
- There is no reveal endpoint over the API at all; revealing an encrypted
value exists only in the admin UI, gated by the section’s edit permission.
A secret path therefore responds exactly like a path that does not exist.
REST surface
Authentication
Requests are authenticated by a personal access token presented as a
bearer credential on the stateless /api/v2/ firewall:
GET /api/v2/config HTTP/1.1
Host: flow.example.com
Authorization: Bearer cfp_xxxxxxxxxxxxxxxxxxxxxxxx
Three conditions must hold:
| Requirement | Failure |
|---|
A valid, unexpired, unrevoked token (cfp_…) | 401 |
The token carries the api:full scope | 403 |
The token’s owner holds an admin role (ROLE_ADMIN) | 403 |
Mint the token under Account → Personal access tokens
(/admin/account/tokens) and tick API: full access — see
Your account & security › Personal access tokens.
The token acts as you: the readable field list and the scope rules below
are evaluated against your role and your workspace.
List readable fields
Returns the catalog of fields you may read — the schema allowlist
intersected with your per-section view permissions. No values are returned;
this is the discovery call.
curl -sS https://flow.example.com/api/v2/config \
-H "Authorization: Bearer cfp_…"
Response (200)
{
"fields": [
{
"path": "general/identity/site_name",
"kind": "string",
"label": "config.field.general.identity.site_name.label",
"allowed_scopes": ["global", "organization", "tenant"]
},
{
"path": "general/locale/default_timezone",
"kind": "enum",
"label": "config.field.general.locale.default_timezone.label",
"allowed_scopes": ["global", "organization", "tenant"]
},
{
"path": "general/locale/default_locale",
"kind": "enum",
"label": "config.field.general.locale.default_locale.label",
"allowed_scopes": ["global", "organization", "tenant"]
}
]
}
| Field | Meaning |
|---|
path | The full slash path (<section>/<group>/<field>) — what you pass to the read endpoint. |
kind | The field type: string, int, bool, enum, multiselect, color, url, duration, json, tags, rows, or feature_flag. (encrypted_string fields can never appear here.) |
label | The field’s label translation key, stable across releases — useful for matching against the admin UI. |
allowed_scopes | The scope levels the field may be set at: any subset of global, organization, tenant. |
Read one value
GET /api/v2/config/{path}
{path} is the full slash path from the listing — slashes included, no
encoding needed:
curl -sS https://flow.example.com/api/v2/config/general/identity/site_name \
-H "Authorization: Bearer cfp_…"
Response (200)
{
"path": "general/identity/site_name",
"kind": "string",
"value": "Comerix Flow",
"present": true,
"source_scope": null
}
| Field | Meaning |
|---|
path | The path you asked for. |
kind | The field type (see the listing). |
value | The resolved, typed, JSON-encodable value. A feature flag returns its state object ({ "enabled": …, "rolloutPercent": …, "audience": …, "enabledFor": … }); null when no value is set at any scope and no schema default applies. |
present | true when an effective value exists (i.e. value is not null). |
source_scope | The scope key the winning override lives at (e.g. "global", "tenant:019e6fb7-…") — or null when the value is the schema default (no override anywhere in the chain). |
Reads are pure: nothing is written, no value row is created, and the
response carries Cache-Control: no-store so configuration is never retained
by a browser, proxy, or CDN cache.
Scope targeting
Values resolve along the same inheritance chain as the admin UI — Global →
Organization → Workspace, most specific wins (see
Settings › Scopes & inheritance). The
optional ?scope= query parameter selects where in the chain to resolve:
curl -sS 'https://flow.example.com/api/v2/config/general/identity/site_name?scope=tenant:019e6fb7-3da4-76fe-b62b-dd9a3dcea5c4' \
-H "Authorization: Bearer cfp_…"
| Selector | Resolves at |
|---|
global | The global level only. |
organization:<uuid> | The organization, inheriting from global. |
tenant:<uuid> | The workspace, inheriting from its organization and global. |
Who may target what is decided by the scope-access policy, evaluated
against the authenticated user — never against anything in the request:
- A regular admin is pinned to their own workspace. Omitting
scope
resolves at your workspace; explicitly passing your own
tenant:<your-workspace-uuid> also works. Requesting any other scope —
global, an organization, or a different tenant — returns
403 scope_denied, never a silently downgraded answer.
- A super-admin may target any scope. Omitting
scope defaults to
global.
The workspace comes from the tokenThe workspace identity is derived from the token’s owning user. There is
no header or body field that can re-point a read at another workspace —
a leaked token is confined to the workspace (and role) of its owner.
Errors
Errors use a compact {error, message} envelope:
| HTTP | error | When |
|---|
| 400 | invalid_scope | The scope selector is malformed — not global, organization:<uuid>, or tenant:<uuid>. |
| 401 | — | Missing, invalid, expired, or revoked token (plain 401, before any routing). |
| 403 | — | The token lacks the api:full scope, or its owner is not an admin. |
| 403 | scope_denied | The scope is well-formed but not yours to read (see Scope targeting). |
| 404 | not_found | The path cannot be read — see below. |
One 404, no oracleAn unknown path, a path that exists but is not declared
api_readable, a secret field, and a section your role may not view
all return the same 404 not_found. The API deliberately does not
distinguish them, so a caller can never use it to enumerate which settings,
sections, or modules exist. Use GET /api/v2/config to discover
what you can read instead of probing paths.
MCP surface
The same reads are available to AI agents as tools on the
MCP gateway (POST /mcp, JSON-RPC 2.0). Grant the token the
mcp:config:read scope (label Config: read); the tools also require the
comerix.config permission, and the per-section view ACL filters results
exactly as on REST.
| Tool | Type | Scope | Purpose |
|---|
config.list | read | mcp:config:read | List the configuration fields readable over the API (non-secret only), with their kind and label. No arguments. |
config.get | read | mcp:config:read | Read one non-secret configuration value by path, optionally at a scope. |
config.get arguments:
| Argument | Required | Meaning |
|---|
path | yes | Full slash path, e.g. general/identity/site_name. |
scope | no | Scope selector (global, organization:<uuid>, tenant:<uuid>); defaults to the caller’s own scope. Same policy as REST. |
Example call:
curl -sS https://flow.example.com/mcp \
-H "Authorization: Bearer cfp_…" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "config.get",
"arguments": { "path": "general/identity/site_name" }
}
}'
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "{\"path\":\"general/identity/site_name\",\"kind\":\"string\",\"value\":\"Comerix Flow\",\"present\":true,\"source_scope\":null}" }],
"isError": false,
"structuredContent": {
"path": "general/identity/site_name",
"kind": "string",
"value": "Comerix Flow",
"present": true,
"source_scope": null
}
}
}
The payload fields are identical to the REST read. Failures follow
the MCP convention: a malformed path/scope argument is a JSON-RPC
-32602 Invalid params error, while a denied scope or an unreadable path
comes back as a normal result with isError: true ("Configuration path not found." — the same non-enumerable not-found as REST). See
MCP gateway › Error codes.
Permissions
| Requirement | REST | MCP |
|---|
| Token scope | api:full | mcp:config:read |
| Role | ROLE_ADMIN (admin panel access) | comerix.config permission |
| Per-section filter | comerix.config.<section>.view | comerix.config.<section>.view |
The bearer token itself is managed under your account
(comerix.account.tokens.manage — see
Your account & security).
Related pages