MQTT Topic Schema

Canonical topic reference for jettyd devices. Source of truth: apps/platform/src/mqtt.rs and apps/platform/src/routes/mqtt_auth.rs.

Connection Reference

Broker
mqtt.jettyd.com
Port
8883 (TLS required)
client_id
device UUID
password
device_key (from provisioning)
TLS is required. The broker does not accept plaintext connections on port 8883. Your MQTT client must enable TLS. The standard plaintext port (1883) is not exposed publicly.

The device_key is returned during provisioning (see QuickStart) as dk_…. It is also visible in the dashboard under Device → Credentials.

Topic Routing Table

All device topics are scoped to jettyd/{tenant_id}/{device_id}/… where tenant_id and device_id are UUIDs returned at provisioning time. Media upload topics use a shorter jettyd/{device_id}/media/… pattern without a tenant segment.

Topic Direction QoS Payload
jettyd/{tenant_id}/{device_id}/telemetry device → broker 1 {"readings": {"<metric>": <number|string>}} confirmed
jettyd/{tenant_id}/{device_id}/command broker → device 1 {"id": "<uuid>", "action": "<string>", "params": {…}} confirmed
jettyd/{tenant_id}/{device_id}/config broker → device 1 payload format not yet confirmed in codebase TBD
jettyd/{tenant_id}/{device_id}/config/ack device → broker 1 ack payload not yet confirmed in codebase TBD
jettyd/{tenant_id}/{device_id}/command/response device → broker 1 {"id": "<command_uuid>", "status": "<string>"} confirmed
Two payload formats are TBD. config and config/ack payloads are not yet confirmed in the platform source. These topics are authenticated and ACL-enforced but the payload contracts are still being finalised. The command topic payload is now confirmed — see below.

Payload Details

Command — …/command

The platform publishes a command envelope to the device. The action field determines what the device should do; command arguments are carried in params. The device ACKs via …/command/response.

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "action": "reboot",
  "params": {}
}

OTA firmware update command

When a firmware rollout is dispatched, action is firmware_update. The params object includes the download URL, SHA-256 integrity hash, binary size, and — when the firmware was uploaded with an Ed25519 signing key configured — a detached signature over the SHA-256 digest.

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "action": "firmware_update",
  "params": {
    "version": "1.2.0",
    "url": "https://cdn.jettyd.com/firmware/abc123/firmware.bin",
    "sha256": "e3b0c44298fc1c149afb…",
    "size": 524288,
    "signature": "a1b2c3d4…",
    "signature_alg": "ed25519"
  }
}

Signature verification: when signature_alg is ed25519, verify the signature (hex-encoded 64-byte Ed25519 signature) against the raw 32-byte SHA-256 digest of the downloaded binary using the tenant's public key, available from GET /v1/firmware/public-key. Skip verification if signature is absent — the firmware is integrity-checked by SHA-256 regardless. See the OTA guide for a complete implementation example.

Telemetry — …/telemetry

Devices publish readings as a map under the readings key. Metric names are arbitrary strings; values are either numbers (stored in value_numeric) or strings (stored in value_text).

{
  "readings": {
    "temperature": 23.4,
    "humidity": 61,
    "door_state": "open"
  }
}

Each key-value pair is inserted as a row in the telemetry time-series table and merged into the device's shadow.reported field.

Schema validation (F-004) Readings are validated against the device type's telemetry_schema when one is set.
  • Type mismatch: the entire message is dropped — acknowledged with a 2xx response but no data is written. A structured WARN log is emitted containing device_id, metric, expected, and actual types.
  • Unknown metric (not in schema): accepted and stored, logged at WARN level.
  • Empty or absent schema: validation is skipped entirely (backward compatible).
Supported type vocabulary: float/number, int/integer, string, bool/boolean. The range and unit fields in the schema are not enforced.

Command Response — …/command/response

After executing a command the device publishes a response with the command's UUID and a status string. The platform matches this to the commands table by id and marks it acknowledged.

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "ok"
}

Common status values: ok, error, acked (default when omitted).

Media Upload Topics

These topics implement the three-step media grant flow: device requests an upload slot, platform issues a presigned PUT URL (or quota refusal), and device confirms completion.

Topic Direction QoS Payload
jettyd/{device_id}/media/request device → broker 1 {"trigger": "interval|command|gpio", "content_type": "image/jpeg", "size_hint": <bytes>} confirmed
jettyd/{device_id}/media/grant broker → device 1 Success: {"media_id": "<uuid>", "put_url": "<presigned-url>", "expires_in_seconds": 900}
Quota exceeded: {"error": "quota_exceeded", "retry_after_sec": 900} confirmed
jettyd/{device_id}/media/complete device → broker 1 {"media_id": "<uuid>", "size_bytes": <number>, "sha256": "<hex>"} confirmed

Media Request — …/media/request

Device requests an upload slot. trigger must be one of interval, command, or gpio. content_type must be image/jpeg in v1. size_hint is used for quota pre-check.

{
  "trigger": "interval",
  "content_type": "image/jpeg",
  "size_hint": 102400
}

Media Grant — …/media/grant

Platform responds with a presigned PUT URL (valid 900 seconds) or a quota refusal.

// Success — upload to put_url using HTTP PUT with Content-Type: image/jpeg
{
  "media_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "put_url": "https://media.r2.jettyd.com/media/…?X-Amz-Signature=…",
  "expires_in_seconds": 900
}

// Quota exceeded
{
  "error": "quota_exceeded",
  "retry_after_sec": 900
}

Media Complete — …/media/complete

After uploading to R2, device notifies the platform. The platform HEAD-checks the object. If present and size matches → complete; if missing → failed. Duplicate completions on an already-complete row are idempotent.

{
  "media_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "size_bytes": 102400,
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

Additional Topics

These topics are used internally by the platform but are not part of the standard device ACL.

Topic Direction Notes
jettyd/{tenant_id}/{device_id}/status device → broker Online/offline heartbeat. Payload: {"status": "online"} or {"status": "offline"}
jettyd/provision/request device → broker Initial provisioning. Uses fleet token as MQTT password. Payload: {"fleet_token": "…", "device_type": "…", "firmware_version": "…", "mac_address": "…"}
jettyd/provision/response/{device_id} broker → device Returns device_id, device_key, tenant_id, and mqtt_uri for subsequent connections.

CLI Examples

Publish telemetry

mosquitto_pub \
  --host mqtt.jettyd.com \
  --port 8883 \
  --tls-version tlsv1.2 \
  --capath /etc/ssl/certs \
  --username "$DEVICE_ID" \
  --pw "$DEVICE_KEY" \
  --topic "jettyd/$TENANT_ID/$DEVICE_ID/telemetry" \
  --message '{"readings":{"temperature":23.4,"humidity":61}}' \
  --qos 1

Subscribe to commands

mosquitto_sub \
  --host mqtt.jettyd.com \
  --port 8883 \
  --tls-version tlsv1.2 \
  --capath /etc/ssl/certs \
  --username "$DEVICE_ID" \
  --pw "$DEVICE_KEY" \
  --topic "jettyd/$TENANT_ID/$DEVICE_ID/command" \
  --qos 1
Environment variables. Set DEVICE_ID, DEVICE_KEY, and TENANT_ID from the values returned at provisioning time (or from the dashboard under Device → Credentials).

Testing Against Staging

The validation script at apps/docs/scripts/validate-mqtt-topics.sh checks that this page exists and contains all canonical topic patterns. Run it locally:

bash apps/docs/scripts/validate-mqtt-topics.sh

To perform a live broker smoke-test against staging, set the following environment variables and run the mosquitto_pub example above: