Skip to content
Free shipping on orders over $2,000.

Heartbeat, telemetry and commands

The fleet plane your server implements, identical in all three modes: a 30-second heartbeat, health telemetry, self-reported incidents, and the acknowledge-then-act remote command lifecycle.

10 min read

Everything on this page applies to every device regardless of mode. These are calls the device makes to your server on its own timers — you implement the endpoints, and the payloads below are what will arrive. All routes take the MAC in the URL path, and the device sends it uppercase colon-separated.

Heartbeat

bashPOST /api/devices/heartbeat
curl -X POST https://api.yourcompany.com/api/devices/heartbeat \
  -H "Content-Type: application/json" \
  -d '{
    "mac_address": "AA:BB:CC:DD:EE:FF",
    "ip_address": "192.168.1.88",

    "firmware_version": "2.1.4",
    "ota_version": "2.1.4",
    "hardware_version": "rev-C",
    "cpu_usage_percent": 34.2,
    "memory_usage_percent": 61.0,
    "temperature_celsius": 44.5,
    "uptime_seconds": 84213,
    "wifi_rssi": -58,
    "free_storage_bytes": 2097152,
    "battery_percent": 87.5
  }'

# → { "status": "ok", "last_seen_at": "2026-09-10T10:22:31Z", "pending_commands": 2 }
  • Only mac_address is required. Every telemetry field is optional, so older firmware that sends none still works.
  • pending_commands in the response is your cue to poll for commands. Do not poll the command endpoint on a blind timer when this field tells you whether it is worth a round trip.
  • Any heartbeat, bare or telemetry-bearing, auto-resolves an open OFFLINE incident for that device.
  • A BLOCKED device gets 403. Treat that as terminal: stop, show a fault indicator, do not retry in a loop.

Health is computed from what you send

Conditionhealth_status
temperature_celsius > 70CRITICAL
memory_usage_percent > 90DEGRADED
cpu_usage_percent > 85DEGRADED
free_storage_bytes < 524288DEGRADED
otherwiseHEALTHY

Incident reporting

bashPOST /api/devices/{mac}/incident
curl -X POST https://api.yourcompany.com/api/devices/AA:BB:CC:DD:EE:FF/incident \
  -H "Content-Type: application/json" \
  -d '{
    "incident_type": "WS_CONNECT_FAIL",
    "severity": "HIGH",
    "description": "TLS handshake failed after 6 attempts",
    "metadata_json": { "host": "api.yourcompany.com", "errno": -9984 }
  }'

# → { "incident_id": 442, "status": "recorded",
#     "firmware_version_captured": "2.1.4", "ota_version_captured": "2.1.4" }

The firmware version at the moment of the fault is frozen into the incident record by the server. That is why an incident from a device that has since been updated still tells you which build broke.

incident_type is a free string, uppercased on write. The recognised set is CRASH, HARDWARE_ERROR, NETWORK_ERROR, LOW_BATTERY, LOW_STORAGE, HIGH_TEMP, OTA_FAIL, UNEXPECTED_REBOOT, OFFLINE and UNKNOWN, plus WS_CONNECT_FAIL, AUDIO_UNDERRUN and TLS_FAIL in use by existing firmware. Adding your own is fine — it will group and report correctly — but keep them stable, because dashboards aggregate by this string.

severity is one of CRITICAL, HIGH, MEDIUM or LOW. CRITICAL and HIGH immediately push the device's health_status to CRITICAL and DEGRADED respectively.

Remote commands

httpThe three command routes
GET   /api/devices/{mac}/commands/pending
PATCH /api/devices/{mac}/commands/{command_id}/ack
PATCH /api/devices/{mac}/commands/{command_id}/result
jsonGET …/commands/pending
{ "commands": [
  { "id": 88,
    "command_type": "REBOOT",
    "payload_json": null,
    "issued_at": "2026-09-10T10:00:00Z",
    "expires_at": "2026-09-11T10:00:00Z" }
]}

Command types are REBOOT, TRIGGER_OTA, CHANGE_MODE, FACTORY_RESET, MUTE, UNMUTE, COLLECT_LOGS and PING. payload_json is free-form, so a CHANGE_MODE carrying a communication_mode is how a unit is repurposed in the field without touching it.

jsonRepurpose a walkie as a terminal, remotely
{ "command_type": "CHANGE_MODE",
  "payload_json": { "communication_mode": "websocket" } }

The lifecycle is PENDING → SENT → ACKNOWLEDGED → COMPLETED or FAILED, with EXPIRED for anything past expires_at, which defaults to 24 hours.

bashAcknowledge, then report the outcome
curl -X PATCH https://api.yourcompany.com/api/devices/AA:BB:CC:DD:EE:FF/commands/88/ack

curl -X PATCH https://api.yourcompany.com/api/devices/AA:BB:CC:DD:EE:FF/commands/88/result \
  -H "Content-Type: application/json" \
  -d '{"status":"COMPLETED","response_json":{"uptime_before":84213}}'

Anything other than COMPLETED is recorded as FAILED.

ActivityIntervalWhy
Heartbeat30 sThe offline threshold is 60 s
Command pollWhen pending_commands > 0, else 60 sThe heartbeat response tells you when it matters
OTA checkBoot, then hourlyEvery check that finds an update creates a tracking row
Print-job poll (mode C)3 sKitchen latency; the claim timeout assumes it
Notification poll (mode B)2–5 sPerceived responsiveness
WebSocket ping/pong (mode C)20 s server ping, 10 s pong deadlineProxy idle timeouts
Reconnect backoff1→2→4→8→16→60 s, ±25 % jitterReconnect storms are real

A reference fleet-plane loop

This is the whole fleet plane in one worked example. It is written in Python for readability; the same shape is what a device implements natively, and the same shape is what you implement server-side if you are running your own backend.

pythonfleet_client.py — heartbeat, command drain, incident reporting
import random, time, requests

HOST = "https://api.yourcompany.com"
MAC  = "AA:BB:CC:DD:EE:FF"

def heartbeat(telemetry):
    r = requests.post(f"{HOST}/api/devices/heartbeat",
                      json={"mac_address": MAC, **telemetry}, timeout=10)
    if r.status_code == 403:
        raise SystemExit("device BLOCKED — terminal, do not retry")
    r.raise_for_status()
    return r.json()

def drain_commands():
    r = requests.get(f"{HOST}/api/devices/{MAC}/commands/pending", timeout=10)
    for cmd in r.json().get("commands", []):
        cid = cmd["id"]
        # Acknowledge first. A crash after this point loses the command either
        # way, and acknowledging late is how destructive commands get replayed.
        requests.patch(f"{HOST}/api/devices/{MAC}/commands/{cid}/ack", timeout=10)
        try:
            result = execute(cmd)
            status = "COMPLETED"
        except Exception as exc:
            result, status = {"error": str(exc)}, "FAILED"
        requests.patch(f"{HOST}/api/devices/{MAC}/commands/{cid}/result",
                       json={"status": status, "response_json": result}, timeout=10)

def report(incident_type, severity, description, **metadata):
    requests.post(f"{HOST}/api/devices/{MAC}/incident",
                  json={"incident_type": incident_type, "severity": severity,
                        "description": description, "metadata_json": metadata},
                  timeout=10)

backoff, last_ota = 1, 0
while True:
    try:
        reply = heartbeat(read_telemetry())
        backoff = 1
        if reply.get("pending_commands", 0) > 0:
            drain_commands()
        if time.time() - last_ota > 3600:      # hourly, never per-minute
            check_ota(); last_ota = time.time()
        time.sleep(30)                          # not 60 — 60 races the sweep
    except requests.RequestException as exc:
        report("NETWORK_ERROR", "MEDIUM", str(exc))
        time.sleep(backoff * (0.75 + random.random() * 0.5))   # ±25 % jitter
        backoff = min(backoff * 2, 60)

Something wrong or missing on this page? Tell us.