Skip to main content

Sending a command

This guide covers the operator's half of two-way command dispatch: issuing a command, telling an accepted one from a refused one, and following it to an outcome. The device's half — receiving a command and reporting what happened — is in Connecting a device. The lifecycle those two halves move a command through is in Commands.

Issuing, reading and cancelling are on the command-delivery endpoint, https://<your-host>/api/command-delivery/graphql, with a tenant access token. Issuing and cancelling need the command:write authority; reading command history needs command:read.

The one exception is the first step below. Finding out what a device accepts is a device-management query — a different endpoint, https://<your-host>/api/device-management/graphql, and a different authority, device:read.

Find out what the device accepts

A device's command vocabulary comes from its profile, so ask device-management rather than guessing:

query {
deviceCommandVocabulary(deviceToken: "sensor-001") {
constrained
commands { commandKey name description parameterSchema }
}
}
commandKey is the identifier; name is a label

A PublishedCommand carries both. The enqueue gate matches on commandKey, and that is the value you put in createCommand's field — which is confusingly called name. The name on the vocabulary entry is a human-readable display label and is matched against nothing. Send the label and you get COMMAND_NOT_IN_VOCABULARY for a command the device plainly supports.

Read constrained, not the length of commands. When constrained is false the list is empty and any command key is accepted — an empty list does not mean the device takes nothing, it means its profile declares no vocabulary. When constrained is true, the key must match one of the entries exactly, including case, and the payload is validated against that command's parameter schema.

Issue it

mutation {
createCommand(request: {
token: "6f1c0f8e-6d1e-4a1a-9a3f-1f2b0d0a5c11",
deviceToken: "sensor-001",
name: "reboot", # the commandKey, not the display name
payload: "{\"delaySeconds\":5}",
expiresAt: "2026-08-15T00:00:00Z"
}) {
command { token status queuedTime }
rejection { code reason }
}
}

token is yours to choose and is how you refer to the command afterwards. payload and metadata are JSON strings. expiresAt is optional — see Set a TTL.

Re-issuing with a token already in use does not create a second command: the original is returned unchanged. That makes a retry after a network failure safe, which matters, because a command is a physical actuation and you do not want a dropped response to reboot a device twice.

That replay applies only to commands you own. A token held by a command the platform minted for a batch is refused with TOKEN_IN_USE rather than returned — handing you another device's actuation as though it were your own would be worse than saying no.

When an enqueue is refused

Check rejection, not just for errors

createCommand returns exactly one of command or rejection. A refused enqueue is a successful GraphQL response carrying a rejectionnot a GraphQL error. A client that only checks the errors array reads a refusal as a success and reports a command that was never created.

A rejection is a decided verdict rather than a failure, and the distinction is deliberate: a rejection says the request is wrong and describes exactly how, while a GraphQL error says the platform could not answer at all. A machine caller that cannot tell them apart retries a permanently-invalid command until its redelivery cap gives up — which looks identical to an outage.

Branch on code. Never on reason — the reason is prose for a person and its wording may change.

codeMeaningRetry?
HELD_CEILING_EXCEEDEDThe tenant is at its limit of undelivered commands — everything still QUEUED, HELD or PARKED, not only what is held for absent devices.Yes — it clears as those commands go out
DEVICE_NOT_FOUNDNo device with that token in this tenant.No
COMMAND_NOT_IN_VOCABULARYThe profile constrains commands and this key is not one. Check the casing.No
PAYLOAD_SCHEMA_VIOLATIONThe payload broke the command's parameter schema — unknown parameter, wrong type, out of range, or a required one missing.No
PAYLOAD_NOT_JSON / METADATA_NOT_JSONThe string is not valid JSON.No
EXPIRES_AT_INVALIDexpiresAt is not an RFC3339 timestamp.No
TOKEN_IN_USEThe token is held by a command you do not own — in practice one the platform minted for a batch.No — pick another token
COMMAND_REJECTEDA rejection arrived carrying no classification.No

The list is open. Treat a code you do not recognize as a refusal you cannot classify — never as a success.

Only HELD_CEILING_EXCEEDED is temporary. Every other code describes a request that will be just as wrong next time, so retrying it wastes attempts and hides a real defect from whoever could fix it.

A tenant whose fleet is entirely present can still hit the ceiling: it bounds undelivered work, and queued commands count while they wait for the next delivery tick. See How much backlog a tenant may hold.

Follow it to an outcome

There is no subscription for commands — poll. Fetch a specific one by token:

query {
commandsByToken(tokens: ["6f1c0f8e-6d1e-4a1a-9a3f-1f2b0d0a5c11"]) {
token status sentTime respondedTime responsePayload error
}
}

Or search, filtering on one state with status or a set of them with statuses:

query {
commands(criteria: {
pageNumber: 1, pageSize: 50,
deviceToken: "sensor-001",
statuses: ["HELD", "PARKED", "SENT"]
}) {
results { token name status queuedTime }
pagination { totalRecords }
}
}

statuses is the one to reach for when what you care about is a set — "everything still in flight for this device" is HELD, PARKED and SENT: the commands withheld because the device is away, the ones published to a device that turned out not to be awake, and the ones dispatched and unanswered. An empty list is ignored rather than matching nothing.

What each terminal state tells you is in Commands; the pair worth internalizing is that EXPIRED means it never got to a device and TIMEOUT means it did — a run of the first points at dispatch, a run of the second points at the device.

Cancel one

mutation {
cancelCommand(token: "6f1c0f8e-6d1e-4a1a-9a3f-1f2b0d0a5c11") { token status }
}

Legal from QUEUED, HELD and PARKED — the states in which the platform is still holding the command. Those are the useful cases: the command was withheld for an absent device, or published to one that turned out to be asleep, and it can be called off before the platform delivers it, which is much of the point of holding it rather than firing it into the dark. It records CANCELLED.

A SENT command is not cancelled. Cancelling does not recall a dispatched command, and driving one to CANCELLED would stop no actuation — it would only make the platform discard the device's real answer when it arrives, so the device acts, the response vanishes, and the record says the operation was called off. The call therefore succeeds and returns the command unchanged, still SENT. Cancel races delivery, and losing that race is ordinary.

Cancelling an already-terminal command is not an error either. It too is returned unchanged, with whatever status it reached. So a cancel that loses the race with a response looks like a successful call that returned SUCCESSFUL.

Both of those are the same instruction: check the status you get back rather than assuming the cancel took effect. A token matching no command is an error.

This is exactly the brake cancelCommandBatch applies to a whole fleet write — same states cancelled, same line at SENT. See Cancelling a batch.

Set a TTL you can live with

Every command carries one. Pass expiresAt to set it, or the platform default of seven days applies.

Seven days is a long time to wait to learn a command failed. If your devices do not report outcomes, a command sits in SENT for the whole week before TIMEOUT records what you already suspected. Set your own expiresAt to whatever "still useful" means for that actuation — a reboot that has not landed in ten minutes is not going to.

Commanding many devices at once

Everything above issues one command to one device. To send one command to a whole fleet — named explicitly, or resolved from an entity group — as a single operation you can audit and call off, see Commanding a fleet. It is not a loop of this mutation: it pins the group's membership as of the moment it fires, records which devices were refused and why, and cancels as one operation.

Four operations that are not for you

markCommandSent, releaseHeldCommands and parkCommand appear on this schema but are gated on system-tier authorities (command:claim, command:wake and command:park) that a tenant access token does not carry. They exist for transports that own a device's connection — an LwM2M device draining its backlog over the session it just opened, a broker reporting that a device came back, or a transport handing a command back because the device it was published toward turned out to be unreachable — and calling them from an application would fight the delivery sweep for control of a physical actuation.

drainableCommands is the read those transports do first, and it is gated on command:claim — the same authority as markCommandSent rather than a fourth one of its own, since a caller entitled to claim a device's commands is exactly the caller entitled to find out which ones there are to claim. Given a device token it returns the commands still waiting for that device — HELD and PARKED, minus anything already past its expiry horizon — oldest first, bounded by limit: absent or not positive gives 32, and 1000 is the ceiling.

The ordering is the substance of the query rather than a nicety. A firmware update's write has to reach the device before its execute, so a backlog drained in any other order does not merely arrive late — it runs the rollout backwards. command:read does not open this query, and an application has no use for it in any case: to see what a device has waiting, use the commands query with statuses: ["HELD", "PARKED"].