Docs / Contact / protocols/mqtt

MQTT Protocol Guide

Connect directly to Contact's MQTT broker to send and receive messages without using an SDK. This is useful for custom clients, edge devices, or any MQTT-compatible software.

Connection Details

Parameter Value
MQTT (TLS) mqtts://mqtt.tendrl.com:443
MQTT (WebSocket) wss://mqtt-ws.tendrl.com:443/mqtt
Protocol MQTT 3.1.1 / 5.0

Everything runs over port 443 with TLS, so no additional firewall rules or port openings are needed.

Authentication

Entities authenticate using their API key credentials in the MQTT CONNECT packet:

CONNECT Field Value
Client ID Your entity's resourcePath (e.g. 123456:us-1:entity:my-sensor)
Username Your API Key ID (the apiKeyId shown when the key was created)
Password The API key secret (shown once at creation)

Getting Your Credentials

Contact issues an API key automatically when you create the entity. To retrieve it:

  1. Open the entity in the Contact dashboard
  2. Click Connection Instructions
  3. Copy the API Key ID (username) and API Key secret (password)
Caution

The secret is shown once at creation. If you lose it, rotate the key from Access Control → API Keys.

Important

The Client ID must be set to your entity's resourcePath exactly as shown in the Connection Instructions dialog. The broker will reject connections with any other client ID. This ensures unique client IDs across all accounts and prevents connection conflicts.

Connection Limits

Topics

Contact uses three topic patterns per entity. Your account number, region, and API Key ID determine the topic paths:

code

{accountNumber}/{region}/{apiKeyId}/publish     ← send messages here
{accountNumber}/{region}/{apiKeyId}/messages    ← subscribe to receive messages
{accountNumber}/{region}/{apiKeyId}/state       ← subscribe to receive state updates

Finding accountNumber and region

Both values are embedded in your entity's resourcePath, which has the form {accountNumber}:{region}:entity:{name}. The resourcePath is the same value you use as the MQTT Client ID (shown in Connection Instructions).

Split it on :. The first segment is accountNumber, the second is region. For example, given 482910365:us-1:entity:my-sensor:

The apiKeyId is the API Key ID (your username), also shown in Connection Instructions.

For example, if your account number is 482910365, region is us-1, and your API Key ID is the UUID d4f9e0c2-1f81-4a3c-b2e7-9f5b21d8c4a3:

code

482910365/us-1/d4f9e0c2-1f81-4a3c-b2e7-9f5b21d8c4a3/publish      ← publish to this topic
482910365/us-1/d4f9e0c2-1f81-4a3c-b2e7-9f5b21d8c4a3/messages     ← subscribe to this topic
482910365/us-1/d4f9e0c2-1f81-4a3c-b2e7-9f5b21d8c4a3/state        ← subscribe to this topic
Info

You don't need to construct these topics yourself. After authenticating, the broker handles topic authorization automatically: your entity can only access its own topics.

Publish Topic

Publish to {accountNumber}/{region}/{apiKeyId}/publish to send messages from your entity.

Messages Topic

Subscribe to {accountNumber}/{region}/{apiKeyId}/messages to receive messages sent to your entity (from other entities or via the REST API).

State Topic

Subscribe to {accountNumber}/{region}/{apiKeyId}/state to receive state table updates for your entity. State messages are retained, so you'll receive the current state immediately on subscribe.

---

Sending Messages

Publish a JSON payload to your publish topic ({account}/{region}/{apiKeyId}/publish). Every message should include msg_type and data. If msg_type is omitted it defaults to publish. If timestamp is omitted the server uses the current time.

Message Types

msg_type Purpose
publish Standard data message for telemetry, events, or any data
heartbeat Health check / keep-alive signal
state_new Create a new state table entry
state_update Update an existing state table entry

Publish a Standard Message

json

{
  "msg_type": "publish",
  "data": {
    "temperature": 23.5,
    "humidity": 65,
    "location": "Building A"
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Send to a Specific Entity

Add the dest field to route the message to another entity by name:

json

{
  "msg_type": "publish",
  "data": {
    "alert": "Temperature threshold exceeded",
    "value": 98.6
  },
  "dest": "control-panel-01",
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Send a Heartbeat

Heartbeats keep the entity's online status fresh and surface basic system metrics. The expected fields are mem_free, mem_total, disk_free, and disk_size (they can sit at the top level or under data; both are accepted for embedded clients).

json

{
  "msg_type": "heartbeat",
  "data": {
    "mem_free": 1024.0,
    "mem_total": 4096.0,
    "disk_free": 50000.0,
    "disk_size": 100000.0
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Create or Update State

Create a new state table:

json

{
  "msg_type": "state_new",
  "data": {
    "firmware_version": "1.2.0",
    "mode": "active",
    "last_calibrated": "2024-01-10"
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Update an existing state table (merges with current state):

json

{
  "msg_type": "state_update",
  "data": {
    "mode": "standby"
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Optional Context and Tags

You can attach tags and context to any message for routing and filtering:

json

{
  "msg_type": "publish",
  "data": {
    "temperature": 23.5
  },
  "context": {
    "tags": ["sensor", "building-a", "floor-3"]
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Tags are used by fanouts and flows for filtering and fan-out delivery.

---

Receiving Messages

Incoming Message Format

When you subscribe to your messages topic, incoming messages look like this:

json

{
  "source": "account/region/entity:sender-name",
  "dest": "your-entity-name",
  "msg_type": "publish",
  "data": {
    "diagnostic_request": true,
    "reason": "validation failure"
  },
  "context": {
    "tags": ["maintenance"],
    "actionResults": []
  },
  "timestamp": "2024-01-15T10:30:45.123456Z",
  "request_id": "msg_abc123",
  "validation_status": "skipped"
}

On MicroPython, route inbound messages with @client.on() instead of subscribing to the MQTT topic directly. See MicroPython Message Receiving.

Validation Status

Messages from entities with a service assigned will include a validation_status:

Status Meaning
passed All validation rules passed
failed One or more validation rules failed
skipped No validation applied (heartbeats, state messages, or no service assigned)

Forward Validation

Services have a Forward Validation setting (enabled by default). When enabled, the detailed actionResults array is included in the context object of messages delivered to the destination entity. When disabled, validation still runs and validation_status is still set, but the actionResults are omitted from the delivered message.

Passed Validation

When all service validation rules pass, validation_status is "passed" and each action result shows valid: true:

json

{
  "source": "account/region/entity:temp-sensor-01",
  "dest": "your-entity-name",
  "msg_type": "publish",
  "data": {
    "temperature": 23.5,
    "unit": "celsius"
  },
  "context": {
    "tags": ["sensor"],
    "actionResults": [
      {
        "name": "validate-temperature",
        "valid": true,
        "message": "temperature is between 0 and 100",
        "ruleName": "temp-range-check",
        "service": "climate-monitor",
        "field": "temperature",
        "actual": 23.5,
        "expected": [0, 100]
      }
    ]
  },
  "timestamp": "2024-01-15T10:30:45.123456Z",
  "request_id": "msg_abc123",
  "validation_status": "passed"
}

Failed Validation

When one or more rules fail, validation_status is "failed". The actionResults include diagnostic details and any tags from the failed dynamic action are merged into context.tags:

json

{
  "source": "account/region/entity:temp-sensor-01",
  "dest": "your-entity-name",
  "msg_type": "publish",
  "data": {
    "temperature": 150,
    "unit": "celsius"
  },
  "context": {
    "tags": ["sensor", "out-of-range"],
    "actionResults": [
      {
        "name": "validate-temperature",
        "valid": false,
        "message": "temperature is not between 0 and 100",
        "ruleName": "temp-range-check",
        "service": "climate-monitor",
        "field": "temperature",
        "actual": 150,
        "expected": [0, 100],
        "tags": ["out-of-range"]
      }
    ]
  },
  "timestamp": "2024-01-15T10:30:45.123456Z",
  "request_id": "msg_def456",
  "validation_status": "failed"
}

Skipped Validation

Heartbeats, state messages, and messages from entities without a service assigned have validation_status: "skipped" with no actionResults:

json

{
  "source": "account/region/entity:temp-sensor-01",
  "dest": "your-entity-name",
  "msg_type": "publish",
  "data": {
    "temperature": 23.5
  },
  "context": {
    "tags": ["sensor"]
  },
  "timestamp": "2024-01-15T10:30:45.123456Z",
  "request_id": "msg_ghi789",
  "validation_status": "skipped"
}

State Updates

Messages on the state topic have this format:

json

{
  "state_table": {
    "firmware_version": "1.2.0",
    "mode": "active",
    "last_calibrated": "2024-01-10"
  },
  "updated_at": "2024-01-15T10:30:45.123456Z"
}

State messages are retained, so you'll receive the latest state immediately when you subscribe.

Pending Messages

If messages were sent to your entity while it was offline (via the REST API or from other entities), they are delivered automatically when you reconnect and subscribe. Up to 10 pending messages are delivered per subscription event, with more following as each batch is acknowledged.

---

QoS, Delivery, and Limits

---

Entity Status

Your entity's online/offline status is updated automatically:

This status is visible in the Contact dashboard and queryable via the API.

---

Quick Start Examples

Python (paho-mqtt)

python


# Your credentials
API_KEY_ID = "your-api-key-id"
API_KEY_SECRET = "your-api-key-secret"
ACCOUNT = "your-account-number"
REGION = "us-1"

PUB_TOPIC = f"{ACCOUNT}/{REGION}/{API_KEY_ID}/publish"
SUB_TOPIC = f"{ACCOUNT}/{REGION}/{API_KEY_ID}/messages"
STATE_TOPIC = f"{ACCOUNT}/{REGION}/{API_KEY_ID}/state"

def on_connect(client, userdata, flags, rc):
    print(f"Connected with result code {rc}")
    client.subscribe(SUB_TOPIC, qos=1)
    client.subscribe(STATE_TOPIC, qos=1)

def on_message(client, userdata, msg):
    payload = json.loads(msg.payload)
    if msg.topic.endswith("/state"):
        print(f"State update: {payload['state_table']}")
    else:
        print(f"Message from {payload.get('source', 'unknown')}: {payload['data']}")

RESOURCE_PATH = "your-account-number:us-1:entity:your-entity-name"  # from Connection Instructions

client = mqtt.Client(client_id=RESOURCE_PATH)
client.username_pw_set(API_KEY_ID, API_KEY_SECRET)

client.on_connect = on_connect
client.on_message = on_message


client.tls_set()  # Use default CA certs for TLS
client.connect("mqtt.tendrl.com", 443, keepalive=60)
client.loop_start()

# Publish a message
message = {
    "msg_type": "publish",
    "data": {"temperature": 23.5, "humidity": 65},
    "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000000Z", time.gmtime())
}
client.publish(PUB_TOPIC, json.dumps(message), qos=1)

# Update state table
state_msg = {
    "msg_type": "state_update",
    "data": {"firmware": "1.2.0", "mode": "active"},
    "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.000000Z", time.gmtime())
}
client.publish(PUB_TOPIC, json.dumps(state_msg), qos=1)

CircuitPython (adafruit_minimqtt)

There is no tendrl package for CircuitPython, because the MicroPython SDK relies on mip, which CircuitPython doesn't have. That's fine: Contact's wire protocol is plain MQTT + JSON, so any board with native Wi-Fi (ESP32-S2/S3/C3, Raspberry Pi Pico W, etc.) can talk to it directly with adafruit_minimqtt.

Install the library with circup install adafruit_minimqtt, then put credentials in settings.toml (CircuitPython loads this into os.getenv automatically, so secrets never end up in code.py):

toml

CIRCUITPY_WIFI_SSID = "your-wifi"
CIRCUITPY_WIFI_PASSWORD = "your-password"
TENDRL_API_KEY_ID = "your-api-key-id"
TENDRL_API_KEY_SECRET = "your-api-key-secret"
TENDRL_ACCOUNT = "your-account-number"
python


API_KEY_ID = os.getenv("TENDRL_API_KEY_ID")
API_KEY_SECRET = os.getenv("TENDRL_API_KEY_SECRET")
ACCOUNT = os.getenv("TENDRL_ACCOUNT")
REGION = "us-1"
RESOURCE_PATH = f"{ACCOUNT}:{REGION}:entity:your-entity-name"  # from Connection Instructions

PUB_TOPIC = f"{ACCOUNT}/{REGION}/{API_KEY_ID}/publish"
SUB_TOPIC = f"{ACCOUNT}/{REGION}/{API_KEY_ID}/messages"
STATE_TOPIC = f"{ACCOUNT}/{REGION}/{API_KEY_ID}/state"

wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD"))
pool = socketpool.SocketPool(wifi.radio)

def on_connect(mqtt_client, userdata, flags, rc):
    print("Connected to Tendrl")
    mqtt_client.subscribe(SUB_TOPIC, qos=1)
    mqtt_client.subscribe(STATE_TOPIC, qos=1)

def on_message(mqtt_client, topic, message):
    payload = json.loads(message)
    if topic.endswith("/state"):
        print("State update:", payload["state_table"])
    else:
        print("Message from", payload.get("source"), ":", payload["data"])

mqtt_client = MQTT.MQTT(
    broker="mqtt.tendrl.com",
    port=443,
    username=API_KEY_ID,
    password=API_KEY_SECRET,
    client_id=RESOURCE_PATH,
    socket_pool=pool,
    ssl_context=ssl.create_default_context(),
)
mqtt_client.on_connect = on_connect
mqtt_client.on_message = on_message
mqtt_client.connect()

# timestamp is optional; Contact fills it in with server time on receipt
message = {
    "msg_type": "publish",
    "data": {"temperature": 23.5, "humidity": 65},
}
mqtt_client.publish(PUB_TOPIC, json.dumps(message), qos=1)

while True:
    mqtt_client.loop()
    time.sleep(1)
Info

CircuitPython's time module has no strftime. The examples above simply omit timestamp; Contact defaults it to server time when the field is missing.

JavaScript (MQTT.js via WebSocket)

javascript

const mqtt = require("mqtt");

const API_KEY_ID = "your-api-key-id";
const API_KEY_SECRET = "your-api-key-secret";
const ACCOUNT = "your-account-number";
const REGION = "us-1";

const PUB_TOPIC = `${ACCOUNT}/${REGION}/${API_KEY_ID}/publish`;
const SUB_TOPIC = `${ACCOUNT}/${REGION}/${API_KEY_ID}/messages`;
const STATE_TOPIC = `${ACCOUNT}/${REGION}/${API_KEY_ID}/state`;

const RESOURCE_PATH = "your-account-number:us-1:entity:your-entity-name"; // from Connection Instructions

const client = mqtt.connect("wss://mqtt-ws.tendrl.com:443/mqtt", {
  clientId: RESOURCE_PATH,
  username: API_KEY_ID,
  password: API_KEY_SECRET,
});

client.on("connect", () => {
  console.log("Connected");
  client.subscribe(SUB_TOPIC, { qos: 1 });
  client.subscribe(STATE_TOPIC, { qos: 1 });

  // Publish a message
  client.publish(
    PUB_TOPIC,
    JSON.stringify({
      msg_type: "publish",
      data: { temperature: 23.5, humidity: 65 },
      timestamp: new Date().toISOString(),
    }),
    { qos: 1 }
  );

  // Update state table
  client.publish(
    PUB_TOPIC,
    JSON.stringify({
      msg_type: "state_update",
      data: { firmware: "1.2.0", mode: "active" },
      timestamp: new Date().toISOString(),
    }),
    { qos: 1 }
  );
});

client.on("message", (topic, payload) => {
  console.log("Received:", JSON.parse(payload.toString()));
});

mosquitto_pub / mosquitto_sub (CLI)

Subscribe to messages:

bash

mosquitto_sub \
  -h mqtt.tendrl.com \
  -p 443 \
  --capath /etc/ssl/certs \
  -i "ACCOUNT:REGION:entity:ENTITY_NAME" \
  -u "your-api-key-id" \
  -P "your-api-key-secret" \
  -t "ACCOUNT/REGION/API_KEY_ID/messages" \
  -q 1

Publish a message:

bash

mosquitto_pub \
  -h mqtt.tendrl.com \
  -p 443 \
  --capath /etc/ssl/certs \
  -i "ACCOUNT:REGION:entity:ENTITY_NAME" \
  -u "your-api-key-id" \
  -P "your-api-key-secret" \
  -t "ACCOUNT/REGION/API_KEY_ID/publish" \
  -q 1 \
  -m '{"msg_type":"publish","data":{"temperature":23.5},"timestamp":"2024-01-15T10:30:45Z"}'

---

Payload Reference

Outbound (Entity to Contact)

Field Type Required Description
msg_type string Yes publish, heartbeat, state_new, or state_update
data object Yes* Your message payload (arbitrary key-value pairs). *Optional for heartbeat messages.
timestamp string No RFC 3339 timestamp (e.g. 2024-01-15T10:30:45.123456Z). Defaults to server time if omitted.
dest string No Destination entity name for directed messages (destination also accepted)
context object No Additional context (see below)

Context Object

Field Type Description
tags string[] Tags for routing, filtering, and fanout fan-out

Inbound (Contact to Entity)

Field Type Description
source string Sender entity resource path
dest string Your entity name
msg_type string Message type
data object Message payload
context object Tags and validation results (see below)
timestamp string Original message timestamp
request_id string Unique message ID for tracing
validation_status string passed, failed, or skipped

Inbound Context Object

Field Type Description
tags string[] Tags from the original message, plus any tags from failed validation actions
actionResults object[] Validation results per rule (present when a service is assigned). See below.

Action Result Object

Each entry in actionResults describes the outcome of a single validation rule:

Field Type Description
name string Dynamic action name
valid boolean Whether the rule passed
message string Human-readable result description
ruleName string Name of the rule that was evaluated
service string Service name
field string The data field that was validated
actual any The actual value found in the message
expected any The expected value defined by the rule
tags string[] Tags from the dynamic action that triggered validation