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 should be set to your entity's resourcePath exactly as shown in the Connection Instructions dialog. (The broker also accepts your API Key ID as the Client ID — an accommodation for constrained clients like OpenMV — but resourcePath is recommended.) Using the resourcePath keeps client IDs unique 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": "482910365:us-1:entity:sender-name",
  "msg_type": "publish",
  "data": {
    "diagnostic_request": true,
    "reason": "validation failure"
  },
  "context": {
    "tags": ["maintenance"]
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

The context object is present only when the message carried tags.

Messages that queued while you were offline are drained on reconnect and arrive in a slightly different shape: tags sit at the top level as tags, and any validation results are merged into data as data.actionResults.

json

{
  "source": "482910365:us-1:entity:sender-name",
  "msg_type": "publish",
  "data": {
    "temperature": 41.8,
    "actionResults": [
      { "name": "climate:publish:validate", "valid": true, "service": "climate-monitor" }
    ]
  },
  "tags": ["maintenance"],
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

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

Validation Status#

A message sent by an entity with a service assigned is stored with a validationStatus. This is a stored field: read it over REST, since it is not part of the MQTT delivery envelope.

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#

Validation results are forwarded whenever the sending entity has a service assigned: the actionResults array rides along with the message delivered to the destination entity. To suppress it, set "forwardValidation": false on the dynamic action itself. There is no service-level toggle that persists.

Passed Validation#

When every rule in a dynamic action passes, the stored validationStatus is "passed" and the action contributes one compact result. The diagnostic fields (message, ruleName, field, actual, expected) appear on failures only.

json

{
  "source": "482910365:us-1:entity:temp-sensor-01",
  "msg_type": "publish",
  "data": {
    "temperature": 23.5,
    "unit": "celsius",
    "actionResults": [
      {
        "name": "validate-temperature",
        "valid": true,
        "service": "climate-monitor"
      }
    ]
  },
  "tags": ["sensor"],
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Failed Validation#

When one or more rules fail, the stored validationStatus is "failed". The actionResults carry diagnostic details, and any tags from the failed dynamic action are merged into the delivered tags:

json

{
  "source": "482910365:us-1:entity:temp-sensor-01",
  "msg_type": "publish",
  "data": {
    "temperature": 150,
    "unit": "celsius",
    "actionResults": [
      {
        "name": "validate-temperature",
        "valid": false,
        "message": "temperature is not between 0 and 100",
        "ruleName": "temperature_validation",
        "service": "climate-monitor",
        "field": "temperature",
        "actual": 150,
        "expected": [0, 100],
        "tags": ["out-of-range"]
      }
    ]
  },
  "tags": ["sensor", "out-of-range"],
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

Skipped Validation#

Heartbeats, state messages, and messages from entities without a service assigned are stored with validationStatus: "skipped" and carry no actionResults:

json

{
  "source": "482910365:us-1:entity:temp-sensor-01",
  "msg_type": "publish",
  "data": {
    "temperature": 23.5
  },
  "context": {
    "tags": ["sensor"]
  },
  "timestamp": "2024-01-15T10:30:45.123456Z"
}

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
msg_type string Message type
data object Message payload. On reconnect-drained messages this also carries actionResults
context object Present on live delivery when the message had tags. Carries tags only
tags string[] Present at the top level on reconnect-drained messages instead of context.tags
timestamp string Original message timestamp

Inbound messages do not carry dest, request_id or validation_status. Look the message up over REST if you need its stored validationStatus.

Inbound Context Object#

Field Type Description
tags string[] Tags from the original message, plus any tags from failed validation actions

Action Result Object#

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

Field Type Description
name string Dynamic action name. Always present
valid boolean Whether the action passed. Always present
service string Service name. Always present
message string Human-readable description. Failures only
ruleName string Server-generated, as {field}_validation or {field}_required_field. Failures only
field string The data field that was validated. Failures only
actual any The value found in the message. Failures only
expected any The value the rule required. Failures only
tags string[] Tags from the dynamic action that triggered validation. Failures only

A passing action produces one compact result per action, not one per rule:

json

{ "name": "climate:publish:validate", "valid": true, "service": "climate-monitor" }