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

Quick start — Voke ESM

End-to-end integration walkthrough against a live Voke org. Estimated time: 15 minutes.

This guide walks you from zero to live telemetry against a Voke org. By the end you will have AMQP queues confirmed, a command published, and a telemetry consumer running — all using credentials minted from the Connect partner wizard at /orgs/<orgId>/settings/connections.

Prerequisites

  • A running Voke instance (local: bun run docker:up && bun run dev:api, or a remote deployment).
  • An org admin on the target Voke organisation who can mint partner keys for you.
  • Node 18 or later for running the TypeScript examples.

Step 1: Get partner credentials from your org admin

Production partners do not enable AMQP themselves. Ask your org admin to mint a key for your integration via the Connect partner wizard:

  1. Sign in to https://voke.lovinka.com and pick the right organisation in the org switcher.
  2. Open Organization settings → Connections (/orgs/<orgId>/settings/connections?tab=api-keys).
  3. Click Connect partner and walk through the three-step wizard. Each step has its own URL so you can deep-link or refresh without losing progress; state is held in sessionStorage and cleared automatically on successful Generate or Cancel:
    • Use case/orgs/<orgId>/settings/connections/connect-partner — pick the preset that matches your integration (Trading platform partner, Telemetry consumer (read-only), HTTP read-only, Internal tool, or Custom).
    • Permissions.../connect-partner/permissions — review the preselected ApiKeyScope set; tighten it if needed.
    • Network.../connect-partner/network — optionally pin the key to a CIDR allowlist, fill in the human-readable name + partner ID, and click Generate. Voke runs a server-side broker self-test before redirecting to the API key detail page where the one-time reveal banner appears.
  4. Hand the partner the bundle from the reveal banner: the AMQPS URI, the raw API key, and (when vcp:write:* scope is present) the HMAC signing key. Click I've saved these to dismiss; refreshing the detail page after that will not re-show the secrets. Code samples on the Quickstart tab of the same page are pre-filled with the org slug — copy them as a starting point.

The bundle is shown exactly once. Save the AMQPS URI, raw key, and signing key into a secret manager before clicking I've saved these on the reveal screen. There is no recovery — if any value is lost, rotate the key (mint a new one, revoke the old one). See API keys & auth for the rotation pattern.

The Quickstart tab on /orgs/<orgId>/settings/connections?tab=quickstart is the source of truth for ready-to-paste Node, Python, and curl snippets — they are server-assembled with the org's real slug and a <your-key> placeholder. The samples below mirror that output with extra inline commentary. See Organization settings routes for the complete admin route map.

Step 2: Set up your integrator project

Create a fresh Node project and install the AMQP client library:

mkdir voke-esm-quickstart && cd voke-esm-quickstart
bun init
bun add amqplib
bun add -d @types/amqplib tsx typescript

Step 3: Add the connection helper

Create amqp-connect.ts. The simplest path is to pass the AMQPS URI straight from the reveal screen — it already encodes the org slug as the AMQP username, the raw key as the password, and the per-key vhost (partner-{keyId}) as the path:

// amqp-connect.ts
// Connect to Voke's RabbitMQ using the AMQPS URI from the Connect partner wizard.
// The URI shape is:
//   amqps://{org-slug}:{url-encoded-key}@mqtt.voke.lovinka.com:5671/partner-{keyId}
import * as amqp from 'amqplib';

export interface VokeAmqpCreds {
  /**
   * The full AMQPS URI from the Connections reveal screen.
   * Contains org slug (username), raw API key (password), host, port, and
   * per-key vhost (`partner-{keyId}`) — no manual assembly required.
   */
  amqpsUri: string;
  /** Your organization slug — used for queue/routing-key prefixes only. */
  orgSlug: string;
}

export async function connectVoke(creds: VokeAmqpCreds) {
  const conn = await amqp.connect(creds.amqpsUri);
  const ch = await conn.createChannel();
  const prefix = `vcp.${creds.orgSlug}`;

  // Queues are asserted (and bound to the `vcp` exchange) by Voke when
  // trading is enabled for your org. checkQueue verifies they exist before
  // any publish or consume.
  const inbound = {
    commands: `${prefix}.command`,
    config: `${prefix}.config`,
    schedule: `${prefix}.schedule`,
  };
  const outbound = {
    telemetry: `${prefix}.event.telemetry`,
    status: `${prefix}.event.status`,
    alarm: `${prefix}.event.alarm`,
    execution: `${prefix}.event.execution`,
  };

  for (const q of [...Object.values(inbound), ...Object.values(outbound)]) {
    await ch.checkQueue(q);
  }

  return { conn, ch, exchange: 'vcp', prefix, inbound, outbound };
}

Step 4: Verify the connection

Create run-connect.ts and run it to confirm that AMQP authentication works and all queues exist:

// run-connect.ts
import { connectVoke } from './amqp-connect';

(async () => {
  const { conn, inbound, outbound } = await connectVoke({
    // Paste the AMQPS URI from the Connections reveal screen.
    amqpsUri: 'amqps://YOUR-ORG-SLUG:URL-ENCODED-KEY@mqtt.voke.lovinka.com:5671/partner-XXXX',
    orgSlug: 'YOUR-ORG-SLUG',
  });
  console.log('inbound queues OK:', inbound);
  console.log('outbound queues OK:', outbound);
  await conn.close();
})();
npx tsx run-connect.ts
# Expected:
# inbound queues OK: { commands: 'vcp.acme.command', config: 'vcp.acme.config', schedule: 'vcp.acme.schedule' }
# outbound queues OK: { telemetry: 'vcp.acme.event.telemetry', status: 'vcp.acme.event.status', ... }

If checkQueue throws, the per-org queues are not asserted. The wizard's broker self-test should have caught this before the bundle was revealed — if you hit it in the wild, ask your org admin to use the Test connection button on the API key detail page (/orgs/<orgId>/settings/connections/api-keys/<apiKeyId>) to re-run the probe and surface the broker error.

Step 5: Add the command publisher

Create publish-command.ts. Commands are published to the vcp topic exchange with routing key {slug}.command.{commandType} — Voke's consumer picks them up off vcp.{slug}.command. Site setpoints are authenticated by the AMQP credential pair and vcp:write:setpoint scope. Higher-risk routes (command.device, command.mode, schedule.*) also require an HMAC signature; see VCP message integrity.

// publish-command.ts
// Publish a VCP command by routing it through the `vcp` exchange.
import { randomUUID } from 'node:crypto';
import { connectVoke, type VokeAmqpCreds } from './amqp-connect';

export interface VcpCommandInput {
  commandType: string;             // e.g. 'site-setpoint', 'mode-change'
  siteId: string;                  // target Voke site / plant identifier
  source: string;                  // your partner ID (e.g. 'obzor')
  payload: Record<string, unknown>; // shape depends on commandType
  correlationId?: string;          // echoed back in event.status for correlation
}

export async function publishCommand(creds: VokeAmqpCreds, input: VcpCommandInput) {
  const { ch, exchange, conn } = await connectVoke(creds);

  const envelope = {
    version: '1.1' as const,
    messageId: randomUUID(),
    correlationId: input.correlationId,
    timestamp: new Date().toISOString(),
    source: input.source,
    siteId: input.siteId,
    payload: input.payload,
  };

  const routingKey = `${creds.orgSlug}.command.${input.commandType}`;
  ch.publish(exchange, routingKey, Buffer.from(JSON.stringify(envelope)), {
    contentType: 'application/json',
    persistent: true,
    messageId: envelope.messageId,
    correlationId: envelope.correlationId,
    timestamp: Date.parse(envelope.timestamp),
  });

  await ch.close();
  await conn.close();
}

Then create run-publish.ts to send a site-setpoint command to your fake plant:

// run-publish.ts
import { publishCommand } from './publish-command';

(async () => {
  await publishCommand(
    {
      amqpsUri: 'amqps://YOUR-ORG-SLUG:URL-ENCODED-KEY@mqtt.voke.lovinka.com:5671/partner-XXXX',
      orgSlug: 'YOUR-ORG-SLUG',
    },
    {
      commandType: 'site-setpoint',
      siteId: 'YOUR-FAKE-PLANT-EXTERNAL-ID',
      source: 'YOUR-PARTNER-ID',
      payload: {
        type: 'POWER',
        targetValueKw: 10,
        direction: 'EXPORT',
        includeConsumption: true,
        priority: 'NORMAL',
        validFrom: new Date().toISOString(),
      },
    },
  );
  console.log('command published');
})();
npx tsx run-publish.ts
# Expected: command published

Check the Voke admin under Plants → [your plant] → Commands to see the command logged with status EXECUTED or REJECTED (rejected means the fake plant is not linked to a real Voke plant yet — that is fine for this step).

Step 6: Add the telemetry consumer

Create consume-telemetry.ts. Voke publishes telemetry events to vcp.{slug}.event.telemetry using routing keys {slug}.event.telemetry.realtime and {slug}.event.telemetry.meter. Each message is a VCP envelope containing the telemetry payload.

// consume-telemetry.ts
// Subscribe to telemetry events emitted by Voke on your org's outbound
// event.telemetry queue.
import { connectVoke, type VokeAmqpCreds } from './amqp-connect';

export interface VcpEnvelope<T = unknown> {
  version: '1.1';
  messageId: string;
  correlationId?: string;
  timestamp: string;
  source: string;
  siteId: string;
  payload: T;
}

export async function consumeTelemetry(
  creds: VokeAmqpCreds,
  onMessage: (envelope: VcpEnvelope) => void,
) {
  const { ch, outbound } = await connectVoke(creds);
  await ch.consume(outbound.telemetry, (msg) => {
    if (!msg) return;
    try {
      const envelope = JSON.parse(msg.content.toString()) as VcpEnvelope;
      onMessage(envelope);
      ch.ack(msg);
    } catch {
      // Malformed payload — dead-letter rather than redeliver.
      ch.nack(msg, false, false);
    }
  });
}

Then create a runner that logs telemetry for 30 seconds:

// run-consume.ts
import { consumeTelemetry } from './consume-telemetry';

(async () => {
  console.log('Listening for telemetry for 30 seconds...');
  await consumeTelemetry(
    {
      amqpsUri: 'amqps://YOUR-ORG-SLUG:URL-ENCODED-KEY@mqtt.voke.lovinka.com:5671/partner-XXXX',
      orgSlug: 'YOUR-ORG-SLUG',
    },
    (envelope) => {
      console.log('telemetry siteId:', envelope.siteId);
      console.log('telemetry payload:', JSON.stringify(envelope.payload, null, 2));
    },
  );
  await new Promise((resolve) => setTimeout(resolve, 30_000));
  process.exit(0);
})();
npx tsx run-consume.ts

You will only receive telemetry messages once a Voke plant has externalPlantId set to match the fake plant's externalPlantId and that plant is publishing live data over MQTT. For a zero-partner smoke test, see the PLC quick start to simulate a plant sending telemetry.

Next steps

  • Per-org AMQP queues — full queue semantics, routing keys, durability, and dead-letter config.
  • VCP data model — canonical payload shapes for commands and telemetry envelopes.
  • Voke ESM sandbox — managing fake plants, time-config blocks, and the credentials-shown-once pattern.

On this page