New: the AI firewall watches every module and explains each incident in plain words. See how it works

Commands API

Send commands to devices, wait for or poll their results, cancel them, and manage a profile's command catalog over REST – fields, states, limits and errors.

A command (an RPC) asks a device to do something and, unless it is one-way, waits for the device’s answer. Every command is stored as a request with a state, so you can send it now and read the result later. How commands work for people in the app is in Sending commands to devices. Paths are under https://<your host>/tenant/<tenantId>.

In development: Commands to devices behind a gateway (a gateway’s child devices) are in development. Send commands to devices that connect themselves.

Routes

MethodPathPermissionDoes
POST/api/iot/devices/{deviceId}/rpciot_rpc.read plus the command’s tierSend a command
GET/api/iot/devices/{deviceId}/rpciot_rpc.readThe device’s command history
GET/api/iot/rpc/{rpcId}iot_rpc.readOne command with its answer
POST/api/iot/rpc/{rpcId}/canceliot_rpc.read plus the command’s tierCancel a queued or sent command
GET/api/iot/devices/{deviceId}/rpc-commandsiot_rpc.send.basicThe commands a dashboard control may send to this device
GET/api/iot/rpc-commands/{profileId}iot_rpc.readA profile’s command catalog (?format=export for the import shape)
POST/api/iot/rpc-commands/{profileId}iot_rpc.commands.manageAdd a command to the catalog
PUT/api/iot/rpc-commands/{profileId}iot_rpc.commands.manageReplace the whole catalog
PATCH, DELETE/api/iot/rpc-commands/{profileId}/{commandId}iot_rpc.commands.manageChange or remove one command

Who may send what

Each command in a profile’s catalog has a tier. The tier decides the permission:

TierPermissionDefault roleCan an API key hold it?
basiciot_rpc.send.basicUserYes
privilegediot_rpc.send.privilegedAdminYes
criticaliot_rpc.send.criticalAdminYes
raw (a method not in the catalog)iot_rpc.send.rawOwnerNo

So an API key can send only methods that are in the device profile’s catalog.

Send a command

POST/tenant/{tenantId}/api/iot/devices/{deviceId}/rpc

Fields: method (required, namespace.verb, up to 64 characters), params (any JSON, at most 16 KiB), oneway, persistent, timeoutMs (1,000 to 600,000; default the catalog’s or the profile’s, else 10,000), expiresInS (1 to 604,800, for a persistent command; default 86,400), wait, confirm, confirmText, source (api, console or dashboard_control). A two-way, non-persistent command with a timeout of at most 30 seconds waits for its end and answers 200; anything else answers 202 and runs on.

Request body application/json

{
    "method": "pump.start",
    "params": {
        "speed": 80
    },
    "timeoutMs": 15000
}

Example curl

curl -X POST 'https://<your-host>/tenant/{tenantId}/api/iot/devices/{deviceId}/rpc' \
  -H 'Content-Type: application/json' \
  -d '{"method":"pump.start","params":{"speed":80},"timeoutMs":15000}'

Responses

200OK
{
    "rpc": {
        "rpcId": "c0de…",
        "deviceId": "6f1c…",
        "method": "pump.start",
        "status": "SUCCESSFUL",
        "outcome": null,
        "response": {
            "ok": true
        },
        "error": null,
        "tier": "privileged",
        "highImpact": false,
        "source": "api",
        "createdAt": "2026-11-09T09:00:00.000Z",
        "completedAt": "2026-11-09T09:00:01.200Z",
        "…": "…"
    }
}
202Accepted
{
    "rpc": {
        "rpcId": "c0de…",
        "status": "QUEUED",
        "…": "…"
    }
}
403Forbidden
{
    "message": "You are not permitted to perform this action",
    "code": "FORBIDDEN"
}
409Conflict
{
    "message": "The device is disabled or quarantined; it takes no commands.",
    "code": "CONFLICT",
    "details": {
        "reason": "device_not_active"
    }
}
429Too Many Requests
{
    "message": "Too many commands to this device; try again in a minute.",
    "code": "RATE_LIMIT_EXCEEDED",
    "details": {
        "reason": "device_rate_limited"
    }
}
  • One-way (oneway: true): the command succeeds when it is delivered; no answer is expected.
  • Persistent (persistent: true): if the device is offline, the command waits for it up to expiresInS, then ends EXPIRED.
  • wait: true forces waiting (refused with “A synchronous call waits at most 30 s; use wait: false for a longer timeout.” when the command cannot finish in 30 s); false always answers 202 at once.
  • High-impact commands need "confirm": true, and the typed phrase in confirmText when the catalog command has one. Without it: “This command needs confirm: true.” They are written to the audit log when sent and when they end, and are sent at most once.

There is no idempotency key on this route: every POST creates a new command. If your call times out on the network, read the device’s history before you send again.

States

QUEUED → SENT → DELIVERED → SUCCESSFUL, or one of FAILED, TIMEOUT, EXPIRED, CANCELLED.

StateMeaning
SUCCESSFULThe answer arrived, or a one-way command was delivered
FAILEDThe device refused it, or it could not be sent
TIMEOUTNo answer in time. The outcome is unknown: the device may have done it. outcome is unknown.
EXPIREDIt never reached the device before its expiry
CANCELLEDCancelled while queued or sent

outcome adds detail: running (the device accepted a long-running command), unknown (a timeout) or late (an answer arrived after the end). Results are kept 90 days by default.

Read results

Poll GET /api/iot/rpc/{rpcId} until the state is final, or subscribe to the webhook events rpc.completed and rpc.failed and read the answer when one arrives. See the Webhook event reference.

History of one device, newest first:

GET /api/iot/devices/{deviceId}/rpc?status=FAILED&limit=50

Without page, the history is read with a cursor: status, method, from, to (dates), limit (1 to 100, default 25) and cursor. The answer is { "requests": [...], "nextCursor": "…" }; pass nextCursor back as cursor until it is null. With page (from 0), the list follows the usual list rules: pageSize, search, sort (method, status, source, createdAt, completedAt), status and source as comma lists, createdFrom, createdTo; the answer is { "requests": [...], "total": n }. Secret parameters are masked in every answer.

Cancel

POST /api/iot/rpc/{rpcId}/cancel moves a QUEUED or SENT command to CANCELLED and answers { "rpc": {...} }. Other states: 409 “Only a queued or sent request can be cancelled.” Cancelling needs the same tier permission as sending. A cancel sends no webhook.

Limits

LimitDefault
Commands per device10 per minute (429, Retry-After: 60)
Commands per organisation600 per minute (429 “Too many requests for your plan. Please upgrade or try again later.”)
Parameters16 KiB of JSON
Answer kept64 KiB
Commands per profile catalog200

Refusals

Statusdetails.reasonMessage
400bad_paramsThe params do not match the command.
400wait_too_longA synchronous call waits at most 30 s; use wait: false for a longer timeout.
400confirm_required, confirm_text_mismatchThis command needs confirm: true. / The typed confirmation does not match.
400method_refusedFactory reset, pairing, Wi-Fi forget and forgetting the cloud are never sent from the cloud.
400payload_too_largeThe request is larger than the device accepts.
403rpc_method_reservedThe method belongs to a module of its own (for example remote access) and is sent only by it; details.owner names the module.
404Device not found.
409rpc_not_supported_by_profileThe device profile speaks only the Legacy topics, which carry no RPC.
409device_not_activeThe device is disabled or quarantined.
409not_cancellableOnly a queued or sent request can be cancelled.
429device_rate_limitedToo many commands to this device; try again in a minute.

The command catalog

A catalog command has: method, label (up to 120), description, paramsSchema (up to 50 fields, each with label, type of text, textarea, number, boolean, select, multi-select or json, and optional required, min, max, step, options), maskedParams (secret fields never shown back), tier (basic, privileged, critical), highImpact, oneway, long, defaultTimeoutMs, persistentDefault, confirmText (high-impact only).

{
  "method": "valve.set",
  "label": "Set valve position",
  "tier": "privileged",
  "paramsSchema": { "position": { "label": "Position (%)", "type": "number", "min": 0, "max": 100, "required": true } }
}

GET ...?format=export returns { "commands": [...] } without ids, which PUT takes back to copy a catalog to another profile.