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', // legacy backward-compat AMQP scope
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 |
Legacy keys that only have vcp:connect are expanded server-side to keep old integrations working. New keys should opt into the smallest required publish scopes.
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 a legacy backward-compatibility alias accepted by the AMQP auth backend for
older keys. For all new integrations use vcp:connect exclusively.
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.
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}@mqtt.voke.lovinka.com:5671/partner-{keyId}. Username is the org slug (notvoke); vhost is the per-keypartner-{keyId}(not the legacy/). - Raw API key — the plaintext secret. Doubles as the AMQP password and the
X-API-KeyREST header. - HMAC signing key — only shown when the key carries any
vcp:write:*scope; needed for high-risk routes (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.
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.lovinka.com/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": { "..." }
}
}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.
// 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, otherwise/for legacy keys
// AMQP connection URL
const vhostPath = apiKey.vhost ? `/${encodeURIComponent(apiKey.vhost)}` : '/';
const url = `amqp://${encodeURIComponent(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 that require API key auth check this header. If a valid JWT session cookie is also present, JWT takes precedence (OR logic — both auth paths are accepted).
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 |
For the Voke ESM sandbox, the same vcp:connect scope applies — Voke ESM does not require any additional scope. The Voke ESM admin controls (enable/disable, plant CRUD) are gated by the SuperAdmin JWT role, not by API key scopes.