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
| Method | Path | Permission | Does |
|---|---|---|---|
| POST | /api/iot/devices/{deviceId}/rpc | iot_rpc.read plus the command’s tier | Send a command |
| GET | /api/iot/devices/{deviceId}/rpc | iot_rpc.read | The device’s command history |
| GET | /api/iot/rpc/{rpcId} | iot_rpc.read | One command with its answer |
| POST | /api/iot/rpc/{rpcId}/cancel | iot_rpc.read plus the command’s tier | Cancel a queued or sent command |
| GET | /api/iot/devices/{deviceId}/rpc-commands | iot_rpc.send.basic | The commands a dashboard control may send to this device |
| GET | /api/iot/rpc-commands/{profileId} | iot_rpc.read | A profile’s command catalog (?format=export for the import shape) |
| POST | /api/iot/rpc-commands/{profileId} | iot_rpc.commands.manage | Add a command to the catalog |
| PUT | /api/iot/rpc-commands/{profileId} | iot_rpc.commands.manage | Replace the whole catalog |
| PATCH, DELETE | /api/iot/rpc-commands/{profileId}/{commandId} | iot_rpc.commands.manage | Change or remove one command |
Who may send what
Each command in a profile’s catalog has a tier. The tier decides the permission:
| Tier | Permission | Default role | Can an API key hold it? |
|---|---|---|---|
| basic | iot_rpc.send.basic | User | Yes |
| privileged | iot_rpc.send.privileged | Admin | Yes |
| critical | iot_rpc.send.critical | Admin | Yes |
| raw (a method not in the catalog) | iot_rpc.send.raw | Owner | No |
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 toexpiresInS, then endsEXPIRED. - wait:
trueforces 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);falsealways answers202at once. - High-impact commands need
"confirm": true, and the typed phrase inconfirmTextwhen 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.
| State | Meaning |
|---|---|
SUCCESSFUL | The answer arrived, or a one-way command was delivered |
FAILED | The device refused it, or it could not be sent |
TIMEOUT | No answer in time. The outcome is unknown: the device may have done it. outcome is unknown. |
EXPIRED | It never reached the device before its expiry |
CANCELLED | Cancelled 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
| Limit | Default |
|---|---|
| Commands per device | 10 per minute (429, Retry-After: 60) |
| Commands per organisation | 600 per minute (429 “Too many requests for your plan. Please upgrade or try again later.”) |
| Parameters | 16 KiB of JSON |
| Answer kept | 64 KiB |
| Commands per profile catalog | 200 |
Refusals
| Status | details.reason | Message |
|---|---|---|
| 400 | bad_params | The params do not match the command. |
| 400 | wait_too_long | A synchronous call waits at most 30 s; use wait: false for a longer timeout. |
| 400 | confirm_required, confirm_text_mismatch | This command needs confirm: true. / The typed confirmation does not match. |
| 400 | method_refused | Factory reset, pairing, Wi-Fi forget and forgetting the cloud are never sent from the cloud. |
| 400 | payload_too_large | The request is larger than the device accepts. |
| 403 | rpc_method_reserved | The method belongs to a module of its own (for example remote access) and is sent only by it; details.owner names the module. |
| 404 | Device not found. | |
| 409 | rpc_not_supported_by_profile | The device profile speaks only the Legacy topics, which carry no RPC. |
| 409 | device_not_active | The device is disabled or quarantined. |
| 409 | not_cancellable | Only a queued or sent request can be cancelled. |
| 429 | device_rate_limited | Too 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.

