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:
- Open the entity in the Contact dashboard
- Click Connection Instructions
- Copy the API Key ID (username) and API Key secret (password)
The secret is shown once at creation. If you lose it, rotate the key from Access Control → API Keys.
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
- Max connections per API key: 10
- Publish rate limit: 50 messages/second per client (configurable per deployment)
Topics
Contact uses three topic patterns per entity. Your account number, region, and API Key ID determine the topic paths:
{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:
accountNumber=482910365region=us-1
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:
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
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
{
"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:
{
"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).
{
"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:
{
"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):
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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
- QoS 1 (at-least-once) is used for all message delivery
- Messages on the state topic are retained; messages on the messages topic are not
- The broker tracks delivery: once your client acknowledges a message, it's marked as processed
- Max message size: 5 KB per message (the
datafield, serialized as JSON) - Publish rate: 50 messages/second per client (configurable per deployment)
---
Entity Status
Your entity's online/offline status is updated automatically:
- Online when your MQTT client connects and authenticates
- Offline when your client disconnects (clean or unexpected)
This status is visible in the Contact dashboard and queryable via the API.
---
Quick Start Examples
Python (paho-mqtt)
# 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):
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"
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)
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)
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:
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:
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 |
Tendrl