API Keys & Auth
How Voke API keys work — scopes, creation, storage, REST and AMQP usage, rotation, and revocation.
Overview
API keys are Voke's integrator authentication primitive. Each key belongs to exactly one organisation and carries an explicit set of scopes that limit what the key can do. The plaintext key is returned once — at creation time, on the reveal screen of the Connect partner wizard at /orgs/<orgId>/settings/connections — and Voke hashes it immediately so it cannot be recovered afterwards. Revoking a key disables both REST and AMQP access in the same request, with no propagation lag.
Org admins normally mint keys through the wizard (see Creating a key (Connect Partner Wizard)); the underlying POST /api/v1/partner/api-keys endpoint documented further down is what the wizard calls and is also available to scripts.
The ApiKeyScope enum
Every scope is an explicit string constant defined in @cpi/shared:
// packages/shared/src/types/api-key-scopes.ts
export enum ApiKeyScope {
PLANTS_READ = 'plants:read',
PLANTS_WRITE = 'plants:write',
TELEMETRY_READ = 'telemetry:read',
TELEMETRY_WRITE = 'telemetry:write',
COMMANDS_READ = 'commands:read',
COMMANDS_EXECUTE = 'commands:execute',
CONFIG_READ = 'config:read',
CONFIG_WRITE = 'config:write',
TRADING_READ = 'trading:read',
TRADING_WRITE = 'trading:write',
TRADING_CONNECT = 'trading:connect', // alias accepted by the AMQP auth backend; prefer vcp:connect
VCP_CONNECT = 'vcp:connect', // VCP AMQP access (current)
VCP_READ = 'vcp:read', // VCP REST read endpoints
VCP_WRITE_SETPOINT = 'vcp:write:setpoint',
VCP_WRITE_DEVICE_COMMAND = 'vcp:write:device-command',
VCP_WRITE_SCHEDULE = 'vcp:write:schedule',
VCP_WRITE_MODE = 'vcp:write:mode',
VCP_WRITE_CONFIG = 'vcp:write:config',
}Scope presets
Three named presets are available in SCOPE_PRESETS — they are convenience bundles, not enforced server-side:
| Preset | Scopes included |
|---|---|
| Read Only | plants:read, telemetry:read, commands:read, config:read, trading:read |
| Operator | All of the above, plus plants:write, telemetry:write, commands:execute, config:write, trading:write |
| Full Access | All scopes including vcp:connect, vcp:read, and each vcp:write:* scope |
When creating a key you always specify the scope array explicitly; the preset names exist for the admin UI checkboxes only.
The vcp:connect scope
This is the scope required for AMQP-based VCP integration.
What it grants by itself:
- Authenticate to RabbitMQ using an org slug + API key pair.
- Consume messages from the four outbound queues (
vcp.{slug}.event.*). - Access the org's own queues, subject to queue-prefix checks.
Publishing to the vcp topic exchange requires a matching vcp:write:* scope:
| Routing key family | Required publish scope |
|---|---|
{slug}.command.site-setpoint | vcp:write:setpoint |
{slug}.command.device / {slug}.command.device.* | vcp:write:device-command |
{slug}.command.mode | vcp:write:mode |
{slug}.schedule.* | vcp:write:schedule |
{slug}.config.* | vcp:write:config |
Keys that only have vcp:connect are expanded server-side to grant all vcp:write:* scopes so simple integrations don't have to ask for each individually. Mint with the explicit vcp:write:* subset when you want a least-privilege key.
What it does NOT grant:
- Any REST API access — REST endpoints require their own scope (e.g.
plants:read). - Cross-org AMQP access — the auth backend rejects routing keys that do not start with the authenticated org's slug.
- Admin operations — those require a JWT session with the appropriate org role.
trading:connect is an alias accepted by the AMQP auth backend for keys provisioned before
the vcp:* scope family existed. Always use vcp:connect on new keys.
Creating a key (Connect Partner Wizard)
Org admins mint partner keys at Organization settings → Connections → API keys → Connect partner (/orgs/<orgId>/settings/connections?tab=api-keys). The wizard is a three-step routed flow (Use case → Permissions → Network) that ends with a one-time reveal banner on the API key detail page (/orgs/<orgId>/settings/connections/api-keys/<id>). Each step is its own URL — connect-partner, connect-partner/permissions, connect-partner/network — backed by a sessionStorage-persisted store, so refreshing or briefly switching tabs never loses progress. It is the supported path for production partners; the raw POST /api/v1/partner/api-keys endpoint below is what the wizard calls.
Preset profiles
Step 1 picks a preset that pre-selects scopes and toggles for the rest of the wizard. All presets stay editable in step 2 if you need a tighter set.
| Preset | Preselected scopes | IP allowlist required? |
|---|---|---|
| Trading platform partner | All vcp:* scopes (vcp:connect, vcp:read, every vcp:write:*) | Recommended |
| Telemetry consumer (read-only) | vcp:connect, vcp:read | Recommended |
| HTTP read-only | vcp:read (no AMQP) | Recommended |
| Internal tool | Non-VCP scopes (plants:read, telemetry:read, etc.) | Optional |
| Custom | None — pick scopes manually | Optional |
Server-side broker self-test
Before the reveal screen renders, the API runs a real probe against the broker using the freshly-minted credentials and the assembled AMQPS URI. If the probe fails — bad vhost, missing queue, broker auth mismatch — the mint call returns 500 and the bundle is not shown. You will never receive a broken URI from the wizard.
The probe runs only when the key carries one or more vcp:* scopes (i.e. the key is provisioned a per-key vhost). Keys with only REST scopes skip the probe — there's no AMQP credential to test.
What the reveal banner exposes (once)
After Generate succeeds the wizard redirects to /orgs/<orgId>/settings/connections/api-keys/<id> and the one-time reveal banner is rendered above the regular detail body. The banner shows three monospace boxes plus a tabbed code-sample block:
- AMQPS URI —
amqps://{org-slug}:{url-encoded-key}@amqp.voke.turena.cz:5671/partner-{keyId}. Username is the org slug; vhost is the per-keypartner-{keyId}. - Raw API key — the plaintext secret. Doubles as the AMQP password and the
X-API-KeyREST header. - HMAC signing key — always returned exactly once at mint time for every key; required to sign high-risk publishes (
command.device,command.mode,schedule.*) (see VCP signing). - Code samples — Node, Python, and curl snippets pre-filled with the real org slug and a
<your-key>placeholder; assembled server-side from the sameConnectionBundleBuilderandCodeSamplesBuilderthat the Quickstart tab uses.
Clicking I've saved these dismisses the banner (router state is cleared so a refresh won't re-show it) and emits an api_key.bundle_acknowledged audit event. The plain detail body — Scopes / Network / Activity / Test connection / Revoke — remains in place underneath.
The bundle is shown exactly once. There is no overlap window in v1 — losing any of the three values means rotating the key (mint a new one with the same scopes, deploy, then revoke the old one). A grace-period rotation flow with two simultaneously-valid keys is on the roadmap.
Refreshing the reveal page wipes the secrets. The reveal banner carries
the AMQPS URI / raw key / HMAC signing key in router navigation state — not
in sessionStorage. If you reload /orgs/<orgId>/settings/connections/api-keys/<id>
before copying the values, they are gone and the key must be rotated.
Audit trail
The connections page Audit log tab surfaces these event types:
| Event | Emitted when |
|---|---|
api_key.created | Wizard finishes (mint succeeds) |
api_key.revoked | Detail page Revoke button confirmed |
api_key.test_connection | Detail page Test connection button runs a broker probe |
api_key.bundle_acknowledged | Reveal screen I've saved these button clicked |
partner.connected | First successful broker auth on this key |
partner.connection_refused | Broker auth rejection (bad key, wrong vhost, IP block) |
The detail page (/orgs/<orgId>/settings/connections/api-keys/<apiKeyId>) also exposes a Test connection action: paste the raw key and the API runs a real broker probe, returning ok plus the duration or an actionable broker error.
Creating an API key (raw endpoint)
This is what the Connect partner wizard calls. Use it directly only for scripted/CI key minting; production onboarding should go through the wizard so the broker self-test runs and the audit events fire correctly.
API key management requires an ORG_ADMIN role and a valid org context header.
Endpoint: POST /api/v1/partner/api-keys
Required header: x-org-id: <org-uuid>
Request body (CreateApiKeyDto):
| Field | Type | Required | Description |
|---|---|---|---|
name | string (1–100 chars) | Yes | Human-readable label for the key |
partnerId | string (1–100 chars) | Yes | External partner identifier (your system's reference) |
scopes | string[] | Yes | Array of ApiKeyScope values |
allowedPlantIds | string[] (UUIDs) | No | Restrict to specific plant UUIDs; omit for all-plants access |
allowedIps | string[] (CIDR) | No | Restrict REST and AMQP auth to listed CIDR ranges. Empty/omitted = no IP restriction. |
expiresAt | string (ISO 8601 datetime) | No | Key expiration date; omit for no expiry |
Example — create a key with vcp:connect scope:
curl -X POST https://api.voke.turena.cz/api/v1/partner/api-keys \
-H "Content-Type: application/json" \
-H "x-org-id: <ORG_UUID>" \
-H "Cookie: <jwt-session-cookie>" \
-d '{
"name": "ESM integration key",
"partnerId": "my-esm-platform",
"scopes": ["vcp:connect", "vcp:read", "vcp:write:setpoint"],
"allowedIps": ["203.0.113.5/32"]
}'Response (CreateApiKeyResponseDto):
{
"key": "voke_a1b2c3...",
"signingKey": "64-hex-character-hmac-key...",
"apiKey": {
"id": "3f8a...",
"keyPrefix": "voke_a1b2",
"name": "ESM integration key",
"partnerId": "my-esm-platform",
"scopes": ["vcp:connect", "vcp:read", "vcp:write:setpoint"],
"allowedIps": ["203.0.113.5/32"],
"vhost": "partner-3f8a...",
"isActive": true,
"expiresAt": null,
"lastUsedAt": null,
"createdBy": "<user-uuid>",
"organizationId": "<org-uuid>",
"createdAt": "2026-04-18T10:00:00Z",
"updatedAt": "2026-04-18T10:00:00Z",
"creator": { "..." }
},
"connectionBundle": {
"amqps": {
"uri": "amqps://acme:URL-ENCODED-KEY@amqp.voke.turena.cz:5671/partner-3f8a...",
"host": "amqp.voke.turena.cz",
"port": 5671,
"username": "acme",
"vhost": "partner-3f8a..."
},
"queues": {
"telemetry": "vcp.acme.event.telemetry",
"alarms": "vcp.acme.event.alarm",
"ack": "vcp.acme.event.status",
"command": "vcp.acme.command",
"schedule": "vcp.acme.schedule",
"config": "vcp.acme.config"
},
"rest": { "baseUrl": "https://api.voke.turena.cz/api/v1/vcp/sites" },
"codeSamples": { "node": "...", "python": "...", "curl": "..." }
}
}The key field at the top level is the plaintext API key. Save it immediately — after this response Voke cannot recover it.
The signingKey field is the plaintext HMAC signing key for high-risk VCP envelopes. It is also returned only once. Store it separately from the API key where possible.
The connectionBundle field is only present when the key was provisioned with a per-key vhost (i.e. it carries vcp:* scopes). It bundles the assembled AMQPS URI, the partner-visible queue names, the REST base URL, and starter node/python/curl snippets — exactly what the wizard's reveal screen displays. Keys with only REST scopes get connectionBundle: null and should use the apiKey.id + raw key to call REST directly.
// Save both fields from the response before doing anything else.
const { key: apiKeySecret, apiKey } = response;
// apiKeySecret → AMQP password (store in a secret manager)
// apiKey.vhost → AMQP vhost path when per-key vhost provisioning is enabledStorage model
The plaintext key is hashed server-side before storage. The algorithm is HMAC-SHA256 with a server-side pepper (API_KEY_PEPPER env var). Deployments without a pepper fall back to plain SHA-256 — this path exists for backward compatibility only. Both hash forms are checked on every auth request so old keys continue to work after a pepper is added.
Voke stores only the hash. The keyPrefix column (voke_XXXX…) lets you identify a key in the admin UI without exposing the secret. If you lose the plaintext key, you must rotate: create a new key, deploy it, then revoke the old one.
Using the key
For AMQP (VCP integration)
Connect to RabbitMQ using:
- Username: the org slug (not the key ID)
- Password: the plaintext API key secret
- Vhost:
apiKey.vhostif non-null (per-key vhost — the common case), otherwise/(default vhost)
// AMQPS connection URL — always use amqps:// against the public listener.
// host = 'amqp.voke.turena.cz' (or PUBLIC_AMQPS_HOST override)
// port = 5671 (or PUBLIC_AMQPS_PORT override)
const vhostPath = apiKey.vhost ? `/${encodeURIComponent(apiKey.vhost)}` : '/';
const url = `amqps://${orgSlug}:${encodeURIComponent(apiKeySecret)}@${host}:${port}${vhostPath}`;The AMQP username is the organisation slug, not the apiKey.id. The auth backend validates
that the slug matches the organization the key belongs to — using the key ID as the username will
result in a deny response.
See Per-org AMQP queues for the full connection guide.
For REST endpoints
Attach the key as a request header:
X-API-Key: <plaintext-api-key-secret>REST endpoints under /api/v1/vcp/* (the partner read-back surface) require the X-API-Key header always — they are guarded exclusively by ApiKeyGuard. A JWT session cookie alone will not authenticate; a 401 with code: UNAUTHORIZED and message: "Missing X-API-Key header" is returned even from a browser session signed into the admin app.
Some internal admin endpoints elsewhere in the API accept either an X-API-Key header or a JWT cookie (the ApiKeyOrJwtGuard pattern), but that's not the case for the VCP partner surface — assume API-key-only.
Rotation and revocation
Revoke a key:
DELETE /api/v1/partner/api-keys/:idRevocation is immediate. Active AMQP connections using the revoked key will be rejected on the next broker auth check. There is no grace period.
Rotation pattern (zero-downtime):
- Create a new key with the same scopes (
POST /api/v1/partner/api-keys). - Update your application or secret manager with the new
keyandapiKey.id. - Deploy and verify the new key is working.
- Revoke the old key (
DELETE /api/v1/partner/api-keys/:old-id).
There is no atomic rotate endpoint — the two-step pattern above is the intended approach.
Expiry: Keys do not expire by default (expiresAt: null). To set an expiry, pass expiresAt at creation time. Expired keys are rejected the same way as revoked keys; the lastUsedAt timestamp is not updated after expiry.
Listing keys
GET /api/v1/partner/api-keys?page=1&limit=20Returns paginated ApiKeyResponseDto objects. The keyPrefix field lets you identify which key is which without exposing the secret.
Minimum scope sets
| Use case | Minimum scopes |
|---|---|
| VCP AMQP consume-only integration | vcp:connect |
| VCP REST read endpoints | vcp:read |
| Publish site setpoints | vcp:connect, vcp:write:setpoint |
| Publish device/mode/schedule commands | vcp:connect, matching vcp:write:* scope, plus HMAC signature |
| Read-only REST data access | plants:read, telemetry:read |
| Full REST + AMQP (operator key) | plants:read, telemetry:read, commands:read, commands:execute, config:read, config:write, trading:read, trading:write, vcp:connect, vcp:read, all vcp:write:* scopes |