CPI Documentation
Partner Integration

Partner MQTT Integration Guide

Connect partner IoT devices to CPI using JWT-authenticated MQTT (TOKEN auth method). Covers registration, provisioning, telemetry, commands, and HMAC signing.

Protocol version: 1.0

This guide is for IoT partners who want to connect their devices to the CPI platform using JWT-authenticated MQTT (the TOKEN auth method). It covers everything from device registration to publishing telemetry, receiving commands, and handling token lifecycle.

Overview

The CPI platform provides a secure MQTT broker for IoT devices to publish telemetry, report status, and receive commands in real time. The platform supports two authentication methods:

Auth MethodTransportUse Case
MTLSPort 8883, mutual TLS with client certificatesHigh-security deployments where each device has a unique X.509 certificate
TOKEN (this guide)Port 8885, server-side TLS + JWT authenticationPartner integrations where certificate management is impractical

Both methods share the same security layers for message integrity:

  • TLS encryption — all traffic is encrypted in transit (server-side TLS on port 8885)
  • JWT identity — device identity verified via signed JWT token
  • Per-device ACLs — JWT encodes exactly which topics a device can publish/subscribe to
  • HMAC-SHA256 signatures — every message payload is signed with a per-device secret
  • Replay protection — nonce + timestamp validation prevents message replay attacks

Prerequisites

Before you begin, ensure you have:

  • An admin account on the CPI platform with API access
  • API base URL: https://api.cpi.lovinka.com
  • MQTT broker: mqtts://mqtt.cpi.lovinka.com:8885
  • An MQTT client library that supports TLS and username/password authentication
  • A cryptographic library that supports HMAC-SHA256

Quick Start

Authenticate with the API

curl -X POST https://api.cpi.lovinka.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{"email": "your@email.com", "password": "your-password"}'

Register a device

curl -X POST https://api.cpi.lovinka.com/api/v1/devices \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "name": "Partner Sensor 001",
    "serialNumber": "PARTNER-SN-001",
    "type": "SENSOR",
    "authMethod": "TOKEN"
  }'

Save the returned id (a UUID).

Provision the device

curl -X POST https://api.cpi.lovinka.com/api/v1/devices/{deviceId}/provision \
  -b cookies.txt \
  -o provisioning-package.json

Save this response securely. The hmacSecret and mqttToken are only returned once and are not stored on the platform in plaintext.

Connect and publish telemetry

const mqtt = require('mqtt');
const crypto = require('crypto');
const pkg = require('./provisioning-package.json');

const client = mqtt.connect(pkg.mqtt.broker, {
  port: pkg.mqtt.port,
  username: pkg.deviceId,
  password: pkg.security.mqttToken,
  protocol: 'mqtts',
  rejectUnauthorized: true,
});

client.on('connect', () => {
  const ts = Date.now();
  const nonce = crypto.randomUUID();
  const data = { temperature: 22.5, humidity: 45.2 };
  const msg = `${pkg.deviceId}|${ts}|${nonce}|${JSON.stringify(data)}`;
  const sig = crypto.createHmac('sha256', pkg.security.hmacSecret)
    .update(msg).digest('hex');

  client.publish(pkg.mqtt.topics.telemetry,
    JSON.stringify({ ts, nonce, data, sig }));
  console.log('Telemetry published!');
});
import paho.mqtt.client as mqtt
import json, hmac, hashlib, time, uuid, ssl

with open('provisioning-package.json') as f:
    pkg = json.load(f)

device_id = pkg['deviceId']
secret = pkg['security']['hmacSecret']

client = mqtt.Client(client_id=device_id, protocol=mqtt.MQTTv311)
client.username_pw_set(device_id, pkg['security']['mqttToken'])
client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2)

def on_connect(client, userdata, flags, rc):
    ts = int(time.time() * 1000)
    nonce = str(uuid.uuid4())
    data = {"temperature": 22.5, "humidity": 45.2}
    msg = f"{device_id}|{ts}|{nonce}|{json.dumps(data, separators=(',', ':'))}"
    sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
    client.publish(pkg['mqtt']['topics']['telemetry'],
        json.dumps({"ts": ts, "nonce": nonce, "data": data, "sig": sig}))
    print("Telemetry published!")

client.on_connect = on_connect
client.connect("mqtt.cpi.lovinka.com", port=8885, keepalive=60)
client.loop_forever()
# MQTT requires a persistent connection — use an MQTT CLI tool instead:
# npm install -g mqtt
mqtt pub -h mqtt.cpi.lovinka.com -p 8885 \
  -u "{deviceId}" -P "{mqttToken}" \
  --protocol mqtts \
  -t "cpi/{deviceId}/telemetry" \
  -m '{"ts":1700000000000,"nonce":"uuid","data":{"temperature":22.5},"sig":"..."}'

Verify in the dashboard

Open https://cpi.lovinka.com, navigate to your device, and confirm the telemetry data appears on the live chart.

Device Registration

Register a new device by sending a POST request to the devices endpoint.

Request

POST /api/v1/devices
Content-Type: application/json
Cookie: access_token=<jwt>
{
  "name": "Weather Station Alpha",
  "serialNumber": "WS-ALPHA-001",
  "type": "SENSOR",
  "authMethod": "TOKEN",
  "metadata": {
    "firmware": "1.2.0",
    "location": "Building A, Floor 3"
  }
}

Fields

FieldTypeRequiredDescription
namestringYesHuman-readable device name (1-100 chars)
serialNumberstringYesUnique manufacturer identifier (1-50 chars)
typeenumYesSENSOR, ACTUATOR, GATEWAY, or CONTROLLER
authMethodenumNoTOKEN for JWT auth (defaults to MTLS)
metadataobjectNoArbitrary key-value pairs

Device Lifecycle

PENDING  →  ACTIVE  →  SUSPENDED  →  ACTIVE (reactivated)
                |
                └→  DECOMMISSIONED (permanent, revoked)
  • PENDING — newly registered, awaiting provisioning
  • ACTIVE — provisioned and operational
  • SUSPENDED — temporarily disabled (e.g., auth failures), can be reactivated
  • DECOMMISSIONED — permanently revoked, credentials destroyed

Device Provisioning

Provisioning generates the device's MQTT credentials and HMAC signing secret. This is a one-time operation — the secrets are returned only once.

POST /api/v1/devices/{deviceId}/provision
Cookie: access_token=<jwt>

Requires ADMIN role.

Response (TOKEN auth)

{
  "deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "authMethod": "TOKEN",
  "mqtt": {
    "broker": "mqtts://mqtt.cpi.lovinka.com",
    "port": 8885,
    "topics": {
      "telemetry": "cpi/a1b2c3d4-.../telemetry",
      "status": "cpi/a1b2c3d4-.../status",
      "commands": "cpi/a1b2c3d4-.../commands",
      "ack": "cpi/a1b2c3d4-.../ack"
    }
  },
  "security": {
    "hmacSecret": "4a7f...64-char-hex-string...b3e1",
    "mqttToken": "eyJhbGciOiJIUzI1NiIs...",
    "tokenExpiresAt": "2025-01-15T11:30:00.000Z"
  },
  "protocol": {
    "version": "1.0",
    "signatureAlgorithm": "HMAC-SHA256",
    "timestampToleranceMs": 300000
  }
}

Credential Storage

FieldSensitivityStorage Recommendation
security.hmacSecretSecret — never transmitted againHSM, encrypted keystore, or secure enclave
security.mqttTokenShort-lived — expires in 1 hourMemory or secure storage; refresh before expiry
mqtt.topicsConfigurationFirmware config or flash storage
deviceIdIdentifierFirmware config or flash storage

MQTT Connection

Connection Parameters

ParameterValue
Hostmqtt.cpi.lovinka.com
Port8885
Protocolmqtts (MQTT over TLS)
TLSServer-side TLS (no client certificates)
UsernameDevice ID (UUID from provisioning)
PasswordMQTT JWT token
Clean Sessiontrue (recommended)
Keep Alive60 seconds

Topic Permissions (JWT ACLs)

DirectionTopicPermission
Device → Platformcpi/{deviceId}/telemetryPublish
Device → Platformcpi/{deviceId}/statusPublish
Device → Platformcpi/{deviceId}/ackPublish
Platform → Devicecpi/{deviceId}/commandsSubscribe

Connection Examples

const mqtt = require('mqtt');

const client = mqtt.connect('mqtts://mqtt.cpi.lovinka.com', {
  port: 8885,
  username: deviceId,
  password: mqttToken,
  protocol: 'mqtts',
  rejectUnauthorized: true,
  clean: true,
  keepalive: 60,
  clientId: deviceId,
  reconnectPeriod: 5000,
});
import paho.mqtt.client as mqtt
import ssl

client = mqtt.Client(client_id=device_id, protocol=mqtt.MQTTv311)
client.username_pw_set(username=device_id, password=mqtt_token)
client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2)

client.connect("mqtt.cpi.lovinka.com", port=8885, keepalive=60)
client.loop_start()
struct mosquitto *mosq = mosquitto_new(device_id, true, NULL);
mosquitto_username_pw_set(mosq, device_id, mqtt_token);
mosquitto_tls_set(mosq, NULL, NULL, NULL, NULL, NULL);
mosquitto_tls_opts_set(mosq, 1, "tlsv1.2", NULL);

mosquitto_connect(mosq, "mqtt.cpi.lovinka.com", 8885, 60);
mosquitto_loop_start(mosq);

Message Formats

All messages are JSON-encoded. Every message includes an HMAC-SHA256 signature in the sig field. The deviceId is inferred from the MQTT topic (cpi/{deviceId}/...).

Telemetry

Direction: Device → Platform | Topic: cpi/{deviceId}/telemetry

{
  "ts": 1700000000000,
  "nonce": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "data": {
    "temperature": 22.5,
    "humidity": 45.2,
    "pressure": 1013.25
  },
  "sig": "a1b2c3d4e5f6...64-char-hex-string"
}
FieldTypeDescription
tsintegerMillisecond epoch timestamp (Date.now())
noncestringUnique message ID (UUID v4 recommended)
dataobjectSensor readings (string keys, numeric values)
sigstringHMAC-SHA256 hex signature

Signature order: deviceId|ts|nonce|JSON.stringify(data)

Status

Direction: Device → Platform | Topic: cpi/{deviceId}/status

{
  "ts": 1700000000000,
  "nonce": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
  "status": "ONLINE",
  "sig": "b2c3d4e5f6a7...64-char-hex-string"
}

Values: ONLINE, OFFLINE, MAINTENANCE, ERROR

Signature order: deviceId|ts|nonce|JSON.stringify({status}) — note the status is wrapped in an object.

Command (Received)

Direction: Platform → Device | Topic: cpi/{deviceId}/commands

{
  "cmdId": "e5f6a7b8-c9d0-1234-e5f6-a7b8c9d01234",
  "ts": 1700000000000,
  "action": "REBOOT",
  "payload": {},
  "sig": "c3d4e5f6a7b8...64-char-hex-string"
}

Signature order: deviceId|cmdId|ts|action|JSON.stringify(payload)

Always verify the command signature before executing any action. See the HMAC signing section below.

Command ACK (Sent)

Direction: Device → Platform | Topic: cpi/{deviceId}/ack

{
  "cmdId": "e5f6a7b8-c9d0-1234-e5f6-a7b8c9d01234",
  "status": "COMPLETED",
  "ts": 1700000000000,
  "sig": "d4e5f6a7b8c9...64-char-hex-string"
}

Values: RECEIVED, IN_PROGRESS, COMPLETED, FAILED

Signature order: deviceId|cmdId|ts|status

HMAC Signing

Every message is signed with HMAC-SHA256 using the per-device hmacSecret from the provisioning package.

How It Works

  1. Construct the signing string by joining fields with | (pipe)
  2. Compute HMAC-SHA256 using the hmacSecret
  3. Encode as lowercase hex (64 characters)
  4. Include as the sig field in the JSON message

Signature Formats

Message TypeSigning String
Telemetry{deviceId}|{ts}|{nonce}|{JSON.stringify(data)}
Status{deviceId}|{ts}|{nonce}|{JSON.stringify({status})}
Command{deviceId}|{cmdId}|{ts}|{action}|{JSON.stringify(payload)}
ACK{deviceId}|{cmdId}|{ts}|{status}

JSON.stringify notes: Use compact JSON with no extra whitespace. In Python, use json.dumps(data, separators=(',', ':')). In C, ensure no whitespace between keys, colons, values, or commas.

Verifying Incoming Commands

function verifyCommandSignature(deviceId, cmd, secret) {
  const msg = `${deviceId}|${cmd.cmdId}|${cmd.ts}|${cmd.action}|${JSON.stringify(cmd.payload)}`;
  const expected = crypto.createHmac('sha256', secret).update(msg).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(cmd.sig, 'hex')
  );
}
def verify_command(device_id: str, cmd: dict, secret: str) -> bool:
    msg = f"{device_id}|{cmd['cmdId']}|{cmd['ts']}|{cmd['action']}|{json.dumps(cmd['payload'], separators=(',', ':'))}"
    expected = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, cmd['sig'])
int verify_command(const char *device_id, const char *cmd_id,
                   long long ts, const char *action,
                   const char *payload_json, const char *sig,
                   const char *secret) {
    char message[4096], expected[65];
    snprintf(message, sizeof(message), "%s|%s|%lld|%s|%s",
             device_id, cmd_id, ts, action, payload_json);
    hmac_sign(secret, message, expected, 65);
    return CRYPTO_memcmp(expected, sig, 64) == 0;
}

Timestamp and Nonce Validation

  • Timestamp tolerance: ±5 minutes (300,000 ms) from current time
  • Nonce uniqueness: Duplicate nonces within a 5-minute window are rejected
  • Recommendation: Use UUID v4 (crypto.randomUUID() in Node.js, uuid.uuid4() in Python)

Signing Code Examples

const crypto = require('crypto');

function signTelemetry(deviceId, ts, nonce, data, secret) {
  const msg = `${deviceId}|${ts}|${nonce}|${JSON.stringify(data)}`;
  return crypto.createHmac('sha256', secret).update(msg).digest('hex');
}

function signAck(deviceId, cmdId, ts, status, secret) {
  const msg = `${deviceId}|${cmdId}|${ts}|${status}`;
  return crypto.createHmac('sha256', secret).update(msg).digest('hex');
}

function signStatus(deviceId, ts, nonce, status, secret) {
  const msg = `${deviceId}|${ts}|${nonce}|${JSON.stringify({ status })}`;
  return crypto.createHmac('sha256', secret).update(msg).digest('hex');
}
import hmac, hashlib, json

def sign_telemetry(device_id, ts, nonce, data, secret):
    msg = f"{device_id}|{ts}|{nonce}|{json.dumps(data, separators=(',', ':'))}"
    return hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()

def sign_ack(device_id, cmd_id, ts, status, secret):
    msg = f"{device_id}|{cmd_id}|{ts}|{status}"
    return hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()

def sign_status(device_id, ts, nonce, status, secret):
    status_json = json.dumps({"status": status}, separators=(',', ':'))
    msg = f"{device_id}|{ts}|{nonce}|{status_json}"
    return hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
#include <openssl/hmac.h>
#include <stdio.h>
#include <string.h>

int hmac_sign(const char *secret, const char *message,
              char *out, size_t out_len) {
    unsigned char digest[EVP_MAX_MD_SIZE];
    unsigned int digest_len = 0;

    HMAC(EVP_sha256(), secret, (int)strlen(secret),
         (const unsigned char *)message, strlen(message),
         digest, &digest_len);

    if (out_len < digest_len * 2 + 1) return -1;

    for (unsigned int i = 0; i < digest_len; i++)
        snprintf(out + i * 2, 3, "%02x", digest[i]);
    out[digest_len * 2] = '\0';
    return 0;
}

int sign_telemetry(const char *device_id, long long ts,
                   const char *nonce, const char *data_json,
                   const char *secret, char *sig_out) {
    char message[4096];
    snprintf(message, sizeof(message), "%s|%lld|%s|%s",
             device_id, ts, nonce, data_json);
    return hmac_sign(secret, message, sig_out, 65);
}

int sign_ack(const char *device_id, const char *cmd_id,
             long long ts, const char *status,
             const char *secret, char *sig_out) {
    char message[1024];
    snprintf(message, sizeof(message), "%s|%s|%lld|%s",
             device_id, cmd_id, ts, status);
    return hmac_sign(secret, message, sig_out, 65);
}

int sign_status(const char *device_id, long long ts,
                const char *nonce, const char *status,
                const char *secret, char *sig_out) {
    char message[4096];
    snprintf(message, sizeof(message),
             "%s|%lld|%s|{\"status\":\"%s\"}",
             device_id, ts, nonce, status);
    return hmac_sign(secret, message, sig_out, 65);
}

Token Refresh

MQTT JWT tokens are short-lived by design:

ParameterValue
Default expiry1 hour (3,600 seconds)
Maximum expiry24 hours (86,400 seconds)

Refresh Endpoint

POST /api/v1/devices/{deviceId}/mqtt-token
Cookie: access_token=<admin-jwt>

Requires ADMIN role.

Since the device itself cannot call the REST API, token refresh involves a backend-to-backend flow:

Partner Cloud                    CPI API                  MQTT Broker
    |                               |                         |
    |-- POST /devices/:id/token --> |                         |
    |<-- { token, expiresAt } ----- |                         |
    |                                                         |
    |-- Push new token to device (out-of-band) ------>        |
    |                                                  Device reconnects

Recommendations:

  1. Refresh at 80% of token lifetime (48 minutes for 1-hour tokens)
  2. Handle disconnects gracefully — trigger immediate refresh
  3. Retry with exponential backoff (1s, 2s, 4s, 8s, max 60s)
  4. Deliver new tokens via your management channel (BLE, cellular, etc.)

Error Handling

MQTT Connection Errors

ErrorCauseResolution
Connection refused (5)Invalid/expired JWT tokenRefresh the token via API
Connection refused (4)Malformed username/passwordVerify username = deviceId, password = JWT
TLS handshake failureTLS config issueEnsure TLS 1.2+ and rejectUnauthorized: true
Unexpected disconnectToken expired mid-sessionImplement automatic token refresh

HMAC Validation Errors

The platform silently drops messages that fail HMAC validation:

SymptomCauseResolution
Telemetry not appearingInvalid signatureVerify signing string format exactly
Telemetry not appearingTimestamp out of toleranceSync device clock (NTP)
Telemetry not appearingDuplicate nonceGenerate fresh UUID per message
Telemetry not appearingJSON serialization mismatchUse compact JSON: {"key":value}

Device Suspension

10 auth failures in 5 minutes triggers automatic suspension. The device must be manually reactivated by an admin via POST /api/v1/devices/{id}/reactivate.

Security Best Practices

  • Never hardcode the hmacSecret in source code — use HSM, secure enclave, or encrypted storage
  • Never log JWT tokens in plaintext
  • Always verify incoming command signatures before executing
  • Always use TLS (mqtts:// on port 8885) — never plain MQTT
  • Keep device clock synced via NTP (±5 minute tolerance)
  • Rotate secrets if compromise is suspected via POST /devices/:id/rotate-secret (60-minute grace period)

Complete Example: Node.js Simulator

#!/usr/bin/env node
const mqtt = require('mqtt');
const crypto = require('crypto');
const fs = require('fs');

const pkg = JSON.parse(fs.readFileSync(process.argv[2], 'utf-8'));
const { deviceId } = pkg;
const { hmacSecret, mqttToken } = pkg.security;
const topics = pkg.mqtt.topics;

function signTelemetry(ts, nonce, data) {
  const msg = `${deviceId}|${ts}|${nonce}|${JSON.stringify(data)}`;
  return crypto.createHmac('sha256', hmacSecret).update(msg).digest('hex');
}

function signStatus(ts, nonce, status) {
  const msg = `${deviceId}|${ts}|${nonce}|${JSON.stringify({ status })}`;
  return crypto.createHmac('sha256', hmacSecret).update(msg).digest('hex');
}

function signAck(cmdId, ts, status) {
  const msg = `${deviceId}|${cmdId}|${ts}|${status}`;
  return crypto.createHmac('sha256', hmacSecret).update(msg).digest('hex');
}

const client = mqtt.connect(pkg.mqtt.broker, {
  port: pkg.mqtt.port,
  username: deviceId,
  password: mqttToken,
  protocol: 'mqtts',
  rejectUnauthorized: true,
  clean: true,
  keepalive: 60,
  clientId: deviceId,
  reconnectPeriod: 5000,
});

client.on('connect', () => {
  console.log('Connected to CPI MQTT broker');
  const ts = Date.now();
  const nonce = crypto.randomUUID();
  client.publish(topics.status,
    JSON.stringify({ ts, nonce, status: 'ONLINE', sig: signStatus(ts, nonce, 'ONLINE') }));
  client.subscribe(topics.commands);
});

client.on('message', (topic, payload) => {
  if (topic !== topics.commands) return;
  const cmd = JSON.parse(payload.toString());
  console.log(`Command: ${cmd.action} (${cmd.cmdId})`);

  const ackTs = Date.now();
  client.publish(topics.ack, JSON.stringify({
    cmdId: cmd.cmdId, status: 'RECEIVED', ts: ackTs,
    sig: signAck(cmd.cmdId, ackTs, 'RECEIVED'),
  }));

  setTimeout(() => {
    const doneTs = Date.now();
    client.publish(topics.ack, JSON.stringify({
      cmdId: cmd.cmdId, status: 'COMPLETED', ts: doneTs,
      sig: signAck(cmd.cmdId, doneTs, 'COMPLETED'),
    }));
  }, 2000);
});

setInterval(() => {
  const ts = Date.now();
  const nonce = crypto.randomUUID();
  const data = {
    temperature: Math.round((20 + Math.random() * 10) * 100) / 100,
    humidity: Math.round((40 + Math.random() * 20) * 100) / 100,
  };
  client.publish(topics.telemetry,
    JSON.stringify({ ts, nonce, data, sig: signTelemetry(ts, nonce, data) }));
}, 10_000);

process.on('SIGINT', () => {
  const ts = Date.now();
  const nonce = crypto.randomUUID();
  client.publish(topics.status,
    JSON.stringify({ ts, nonce, status: 'OFFLINE', sig: signStatus(ts, nonce, 'OFFLINE') }),
    {}, () => client.end(false, () => process.exit(0)));
});

Run with: node device-simulator.js ./provisioning-package.json

API Reference Summary

EndpointMethodAuthDescription
/api/v1/auth/loginPOSTPublicAuthenticate
/api/v1/devicesPOSTCookieRegister a device
/api/v1/devices/:idGETCookieGet device details
/api/v1/devices/:id/provisionPOSTADMINGenerate provisioning package
/api/v1/devices/:id/mqtt-tokenPOSTADMINIssue fresh MQTT JWT
/api/v1/devices/:id/rotate-secretPOSTADMINRotate HMAC secret
/api/v1/devices/:id/suspendPOSTADMINSuspend a device
/api/v1/devices/:id/reactivatePOSTADMINReactivate a device
/api/v1/devices/:id/revokePOSTADMINDecommission permanently

Troubleshooting Checklist

  • Is the device in ACTIVE lifecycle status?
  • Is the MQTT token still valid?
  • Is the device clock synchronized (±5 minutes)?
  • Are you connecting to port 8885 (TOKEN auth)?
  • Is username set to the device UUID (not serial number)?
  • Is password set to the JWT token (not HMAC secret)?
  • Are you using mqtts:// protocol?
  • Is JSON.stringify producing compact JSON?
  • Is each nonce unique (UUID v4)?
  • Is deviceId in the signing string but not in the message payload?

On this page