This is a development version of the documentation. Content may change without notice.
Voke Documentation
ESM Partners

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:

PresetScopes included
Read Onlyplants:read, telemetry:read, commands:read, config:read, trading:read
OperatorAll of the above, plus plants:write, telemetry:write, commands:execute, config:write, trading:write
Full AccessAll 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 familyRequired publish scope
{slug}.command.site-setpointvcp:write:setpoint
{slug}.command.device / {slug}.command.device.*vcp:write:device-command
{slug}.command.modevcp: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.

PresetPreselected scopesIP allowlist required?
Trading platform partnerAll vcp:* scopes (vcp:connect, vcp:read, every vcp:write:*)Recommended
Telemetry consumer (read-only)vcp:connect, vcp:readRecommended
HTTP read-onlyvcp:read (no AMQP)Recommended
Internal toolNon-VCP scopes (plants:read, telemetry:read, etc.)Optional
CustomNone — pick scopes manuallyOptional

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 URIamqps://{org-slug}:{url-encoded-key}@mqtt.voke.lovinka.com:5671/partner-{keyId}. Username is the org slug (not voke); vhost is the per-key partner-{keyId} (not the legacy /).
  • Raw API key — the plaintext secret. Doubles as the AMQP password and the X-API-Key REST 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 same ConnectionBundleBuilder and CodeSamplesBuilder that 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:

EventEmitted when
api_key.createdWizard finishes (mint succeeds)
api_key.revokedDetail page Revoke button confirmed
api_key.test_connectionDetail page Test connection button runs a broker probe
api_key.bundle_acknowledgedReveal screen I've saved these button clicked
partner.connectedFirst successful broker auth on this key
partner.connection_refusedBroker 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):

FieldTypeRequiredDescription
namestring (1–100 chars)YesHuman-readable label for the key
partnerIdstring (1–100 chars)YesExternal partner identifier (your system's reference)
scopesstring[]YesArray of ApiKeyScope values
allowedPlantIdsstring[] (UUIDs)NoRestrict to specific plant UUIDs; omit for all-plants access
allowedIpsstring[] (CIDR)NoRestrict REST and AMQP auth to listed CIDR ranges. Empty/omitted = no IP restriction.
expiresAtstring (ISO 8601 datetime)NoKey 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 enabled

Storage 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.vhost if 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/:id

Revocation 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):

  1. Create a new key with the same scopes (POST /api/v1/partner/api-keys).
  2. Update your application or secret manager with the new key and apiKey.id.
  3. Deploy and verify the new key is working.
  4. 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=20

Returns paginated ApiKeyResponseDto objects. The keyPrefix field lets you identify which key is which without exposing the secret.

Minimum scope sets

Use caseMinimum scopes
VCP AMQP consume-only integrationvcp:connect
VCP REST read endpointsvcp:read
Publish site setpointsvcp:connect, vcp:write:setpoint
Publish device/mode/schedule commandsvcp:connect, matching vcp:write:* scope, plus HMAC signature
Read-only REST data accessplants: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.

On this page