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

Spring Boot example

A complete, copy-pasteable Spring Boot (Java) integration that consumes realtime telemetry over AMQPS and publishes commands back to Voke, including HMAC signing for high-stakes commands.

Overview

This page is an end-to-end Spring Boot example for the two flows partners use most:

  1. Consume realtime telemetry from your AMQPS queue.
  2. Publish commands back to Voke (setpoints, battery charge/discharge, …).

It uses Spring AMQP (spring-boot-starter-amqp) and matches the VCP 1.1 wire contract exactly, including the canonical-JSON HMAC signature required for high-stakes commands.

This example as a runnable project: voke-partner-examples/java/spring-boot — clone, copy .env.example to .env, mvn spring-boot:run. (TypeScript version: typescript/node-amqp.)

The queues and the vcp topic exchange already exist in your per-key vhost — your app only consumes and publishes; it never declares topology.

AMQP topology

Detail
Connectamqps://{orgSlug}:{apiKey}@amqp.voke.turena.cz:5671/partner-{keyId} (TLS; vhost is per API key)
Consume realtime telemetryqueue vcp.{slug}.event.telemetry, routing key …telemetry.realtime.{siteId}. The 1-minute meter readings share the queue on …telemetry.meter.{siteId}. The default queue is bound with #, so it receives every site — match on the routing-key prefix, not an exact suffix (see below). To stream a single PLC, bind your own queue per site.
Publish commandstopic exchange vcp, routing key {slug}.command.site-setpoint (also .device, .mode, .emergency, plus {slug}.schedule.create / .cancel)
Acks / resultsqueues vcp.{slug}.event.status (command ACK/NACK) and vcp.{slug}.event.execution, correlate by correlationId

Every message is a VCP envelope:

{
  "version": "1.1",
  "messageId": "<uuid v4 — must be unique per message>",
  "timestamp": "2026-06-13T09:00:00.000Z",
  "source": "obzor-energy-flems",
  "siteId": "<plant UUID from GET /vcp/sites>",
  "payload": { "...": "..." },
  "signatureAlgo": "HMAC-SHA256",
  "signature": "<base64url — only on signed commands>"
}

HMAC is required for command.device, command.mode, and schedule.*. command.site-setpoint and command.emergency do not need a signature. messageId must be unique — Voke rejects replays.

Dependencies

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Required by the JSON listeners below. `spring-boot-starter-amqp` does NOT
     pull jackson-databind transitively (spring-amqp declares it optional), so
     a telemetry-only app must add it explicitly. -->
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
</dependency>
<!-- java.time + crypto are JDK -->

Configuration

# application.yml
spring:
  rabbitmq:
    host: amqp.voke.turena.cz
    port: 5671
    username: obzor-energy            # your org slug (AMQP user)
    password: ${VOKE_API_KEY}         # the API key from the bundle (AMQP password)
    virtual-host: partner-<your-key-id>
    ssl:
      enabled: true                   # AMQPS / TLS on 5671
      algorithm: TLSv1.3              # REQUIRED — see TLS note below. `enabled: true`
                                      # alone negotiates TLS 1.2 and the broker
                                      # (TLS-1.3-only) rejects it with `protocol_version`.
voke:
  org-slug: obzor-energy
  source: obzor-energy-flems          # free-form publisher id, goes in envelope.source
  hmac-key-hex: ${VOKE_HMAC_KEY}      # signing key (hex) — only for device/mode/schedule

Envelope + HMAC signing

The envelope is built as a Map so we control exactly which fields are present. The signature is an HMAC-SHA256 over the canonical JSON (keys sorted lexicographically, no whitespace) of the envelope without signature but with signatureAlgo, encoded base64url without padding.

The signing key is a hex string — hex-decode it to raw bytes before using it as the HMAC key (do not use the UTF-8 bytes of the hex string). This is the most common cause of INVALID_SIGNATURE.

package com.obzor.voke;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;

/** Builds + (optionally) signs a VCP 1.1 envelope. */
public final class VcpEnvelope {

    // Canonical mapper: sort every object's keys, compact output (no whitespace).
    private static final ObjectMapper CANONICAL = JsonMapper.builder()
            .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
            .build();

    /** Build an unsigned envelope (use for site-setpoint / emergency). */
    public static Map<String, Object> build(String source, String siteId, Object payload) {
        Map<String, Object> env = new HashMap<>();
        env.put("version", "1.1");
        env.put("messageId", UUID.randomUUID().toString()); // MUST be unique per message
        env.put("timestamp", Instant.now().toString());     // ISO-8601 UTC
        env.put("source", source);
        env.put("siteId", siteId);
        env.put("payload", payload);
        return env;
    }

    /** Build a signed envelope (required for command.device / command.mode / schedule.*). */
    public static Map<String, Object> buildSigned(String source, String siteId,
                                                  Object payload, String hmacKeyHex) {
        Map<String, Object> env = build(source, siteId, payload);
        env.put("signatureAlgo", "HMAC-SHA256");
        try {
            // Sign canonical JSON of the envelope WITHOUT `signature` (but WITH signatureAlgo).
            String canonical = CANONICAL.writeValueAsString(env);
            byte[] keyBytes = HexFormat.of().parseHex(hmacKeyHex); // hex -> raw bytes
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(keyBytes, "HmacSHA256"));
            byte[] sig = mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));
            env.put("signature", Base64.getUrlEncoder().withoutPadding().encodeToString(sig));
            return env;
        } catch (Exception e) {
            throw new IllegalStateException("Failed to sign VCP envelope", e);
        }
    }

    private VcpEnvelope() {}
}

RabbitMQ config

The queue and exchange already exist in your vhost, so we only wire a JSON converter.

package com.obzor.voke;

import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitConfig {
    @Bean
    Jackson2JsonMessageConverter jsonConverter() { return new Jackson2JsonMessageConverter(); }

    @Bean
    RabbitTemplate rabbitTemplate(ConnectionFactory cf, Jackson2JsonMessageConverter conv) {
        RabbitTemplate t = new RabbitTemplate(cf);
        t.setMessageConverter(conv);
        return t;
    }
}

Consume realtime telemetry

Realtime snapshots (~10 s) and the 1-minute meter readings arrive on the same queue — switch on the routing key. The key ends with the site UUID (…telemetry.realtime.{siteId}), so match the type segment with contains, not endsWith — the trailing siteId also gives you the site without parsing the envelope.

package com.obzor.voke;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
public class TelemetryListener {

    // Parse the raw JSON body directly. (Do NOT use
    // Jackson2JsonMessageConverter.fromMessage(msg, JsonNode.class) — it returns a
    // LinkedHashMap, so casting to JsonNode throws ClassCastException.)
    private final ObjectMapper mapper = new ObjectMapper();

    @RabbitListener(queues = "vcp.${voke.org-slug}.event.telemetry")
    public void onTelemetry(Message message) throws Exception {
        JsonNode env = mapper.readTree(message.getBody());
        String rk = message.getMessageProperties().getReceivedRoutingKey();
        JsonNode p = env.get("payload");
        String siteId = env.path("siteId").asText();

        if (rk.contains(".telemetry.realtime")) {
            // Instantaneous snapshot. gridPowerKw > 0 = import, < 0 = export (přetok).
            // Routing key is …telemetry.realtime.{siteId} — siteId also in the envelope.
            System.out.printf("[realtime] site=%s grid=%s kW  fve=%s kW  soc=%s%%  mode=%s%n",
                siteId, p.path("gridPowerKw").asText("null"),
                p.path("fvePowerKw").asText("null"),
                p.path("socPercent").asText("null"),
                p.path("currentOperatingMode").asText());
        } else if (rk.contains(".telemetry.meter")) {
            // 1-min cumulative meter registers. Field is `deviceId` (was meterId);
            // payload now also carries eanCons / eanProd.
            System.out.printf("[meter] site=%s eanCons=%s%n",
                siteId, p.path("eanCons").asText("null"));
            for (JsonNode m : p.path("meters")) {
                System.out.printf("   %s (%s)  import=%s  export=%s kWh%n",
                    m.path("deviceId").asText(), m.path("role").asText(),
                    m.path("importRegisterKwh").asText("-"),
                    m.path("exportRegisterKwh").asText("-"));
            }
        }
    }
}

Publish commands

package com.obzor.voke;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.List;
import java.util.Map;

@Service
public class CommandSender {

    private static final String EXCHANGE = "vcp";

    private final RabbitTemplate rabbit;
    @Value("${voke.org-slug}")        String slug;
    @Value("${voke.source}")          String source;
    @Value("${voke.hmac-key-hex:}")   String hmacKeyHex;

    public CommandSender(RabbitTemplate rabbit) { this.rabbit = rabbit; }

    /** Site setpoint — NO signature required. Example: cap export to 50 kW. */
    public void sendSetpoint(String siteId, double targetKw, String direction) {
        Map<String, Object> payload = Map.of(
            "type", "POWER",
            "targetValueKw", targetKw,
            "direction", direction,            // "IMPORT" | "EXPORT"
            "includeConsumption", false,
            "priority", "NORMAL",
            "validFrom", Instant.now().toString()
        );
        Map<String, Object> env = VcpEnvelope.build(source, siteId, payload);
        rabbit.convertAndSend(EXCHANGE, slug + ".command.site-setpoint", env);
    }

    /** Battery charge/discharge — SIGNED (command.device requires HMAC). */
    public void sendBatteryCommand(String siteId, String deviceId, String command) {
        Map<String, Object> payload = Map.of(
            "commands", List.of(Map.of(
                "deviceId", deviceId,          // e.g. "BAT1"
                "assetType", "BESS",
                "command", command             // "BESS_CHARGE" | "BESS_DISCHARGE" | "BESS_STOP"
            ))
        );
        Map<String, Object> env = VcpEnvelope.buildSigned(source, siteId, payload, hmacKeyHex);
        rabbit.convertAndSend(EXCHANGE, slug + ".command.device", env);
    }
}

Acks and execution results

Voke replies on two queues — listen to them the same way as telemetry and correlate by correlationId:

  • vcp.{slug}.event.status — command ACK / NACK (accepted, rejected, partial).
  • vcp.{slug}.event.execution — per-device execution outcome once the PLC has acted.
// private final ObjectMapper mapper = new ObjectMapper();

@RabbitListener(queues = "vcp.${voke.org-slug}.event.status")
public void onAck(Message message) throws Exception {
    JsonNode env = mapper.readTree(message.getBody());
    System.out.printf("[ack] correlationId=%s status=%s%n",
        env.path("correlationId").asText("-"),
        env.path("payload").path("status").asText());
}

Notes

  • What changed recently on AMQP: only the meter-reading payload — MeterEntry.meterId was renamed to deviceId, and eanCons / eanProd were added. Realtime telemetry and the command contract are unchanged.
  • Number formatting in signed payloads: prefer integer values in command payloads. The canonical serializer must produce byte-identical output on both ends; integers are unambiguous, exotic floating-point formatting is not.
  • TLS (read this — it's the #1 connection failure): port 5671 is AMQPS and the broker is TLS 1.3-only. spring.rabbitmq.ssl.enabled: true on its own negotiates TLS 1.2, which the broker rejects — you get SSLHandshakeException: Received fatal alert: protocol_version and the app never connects. You must also set spring.rabbitmq.ssl.algorithm: TLSv1.3 (as in the config above). No client certificate is required — the API key is the credential, and the system truststore already trusts the broker's public Let's Encrypt cert. If you hand-roll a ConnectionFactory, set the protocol to TLS 1.3 explicitly; the no-arg useSslProtocol() of the RabbitMQ Java client pins TLS 1.2 and is rejected.

On this page