---
title: Sending commands to devices with the API
description: "Sending commands to devices with the API. Everything else in the API edits records. This sends an instruction to a physical device: force a check-in, reboot it, shut it down, restart its agent, or run a script."
---

[Skip to content](https://k12panel.com/kb/developer-api/sending-commands-to-devices-with-the-api#main-content)

![test-full-word-logo.png\]](https://k12panel.com/hs-fs/hubfs/test-full-word-logo.png?height=35&name=test-full-word-logo.png)

Open main navigation

Close main navigation

- [Go to k12panel.com](https://k12panel.com)

[Go to k12panel.com](https://k12panel.com?hsLang=en)

 Hello. How can we help you?

- There are no suggestions because the search field is empty.

1. [Online Help and Knowledge Base](https://k12panel.com/kb?hsLang=en)
2. [Developer API](https://k12panel.com/kb/developer-api?hsLang=en)

# Sending commands to devices with the API

## Everything else in the API edits records. This sends an instruction to a physical device: force a check-in, reboot it, shut it down, restart its agent, or run a script.

> Device commands need the k12panel **Windows agent**. Chromebooks and MDM-managed devices have no agent, so they cannot receive commands — see [Which devices can take a command](https://k12panel.com/kb/developer-api/sending-commands-to-devices-with-the-api#which-devices-can-take-a-command).

#### Permissions

Commands use three separate permissions, because the three things they cover carry very different risk:

| Permission | Allows |
| --- | --- |
| `commands:read` | List and read commands, **including the output** a device returned |
| `commands:execute` | Force poll, reboot, shut down, restart agent, **run a command from your Quick Command library by name**, and cancel |
| `commands:script` | Send arbitrary command text |

These are not a ladder — each `type` needs its own permission. A key with `commands:script` cannot reboot a device unless it also has `commands:execute`.

**Most integrations should never need `commands:script`.** Put the script in your Quick Command library (**Settings → Quick Commands**), where an administrator reviews and names it, and let the integration run it by name with `commands:execute`. Output is a separate permission again, because a script can print anything.

#### Queue a command

```
POST /api/public/v1/assets/{id}/commands 
```

```
{ "type": "reboot", "expires_in_minutes": 60 } 
```

```
{ "type": "library", "library_command": { "name": "Clear Print Spooler" } } 
```

```
{ "type": "custom", "command": "Get-Service Spooler" } 
```

The response is **`202 Accepted`**, not `201 Created` — the command exists, but nothing has happened on the device yet:

```
{  "id": 88213,  "asset": { "id": 4471, "name": "LIB-1042" },  "type": "reboot",  "status": "pending",  "command": "PREROLL_REBOOT",  "library_command": null,  "created_by": { "type": "api_key", "name": "Ticketing System" },  "expiration": "2026-08-15T15:30:00Z",  "started_at": null,  "completed_at": null,  "results": "",  "created_at": "2026-08-15T14:30:00Z"}
```

`expires_in_minutes` is how long the device has to pick the command up — default **10**, maximum **1440** (24 hours). A device that is asleep, off, or off-network when the window closes never receives it, and the command ends as `expired`.

#### Retries: the `Idempotency-Key` header

A network timeout does not tell you whether the command was queued. Send an `Idempotency-Key` header with a value you generate, and repeating the same request returns the **original** command (`200`) instead of queueing a second one.

The header is **required** for `reboot`, `shutdown`, and `restart_agent` — the three where a retry does the damage twice — and optional everywhere else.

```
curl -X POST -H "Authorization: Bearer k12_live_..." \     -H "Idempotency-Key: ticket-55012-reboot" \     -H "Content-Type: application/json" \     -d '{"type":"reboot"}' \     https://<your-panel-domain>/api/public/v1/assets/4471/commands
```

#### Running a command from your library

A library command is referenced by name (or id), and only **enabled** entries are runnable:

```
{ "type": "library", "library_command": { "name": "Clear Print Spooler" } } 
```

Every library command carries a **revision** that increases each time an administrator edits it. The command record keeps the revision that actually ran, so history can tell you *which version* of "Clear Print Spooler" executed on a device — not just the name.

If you want a call to fail rather than silently run an edited script, pin the revision:

```
{ "type": "library", "library_command": { "name": "Clear Print Spooler" }, "expect_revision": 7 } 
```

A mismatch returns `409 library_command_changed` and tells you the current revision:

```
{  "error": "library_command_changed",  "message": "Library command 'Clear Print Spooler' is now at revision 9 (you pinned 7). Re-review the script and re-pin.",  "current_revision": 9}
```

Pinning is optional on purpose — without it you always get the latest approved version, which is what most integrations want.

#### Check first with `?dry_run=true`

Add `?dry_run=true` to run every check — permission tier, device capability, device state, library lookup, expiry limits — and get back the command that *would* be queued, without queueing it:

```
POST /api/public/v1/assets/4471/commands?dry_run=true 
```

This answers "would this device accept a reboot?" without rebooting anything, which is worth doing before a large batch.

#### Which devices can take a command

A device must have the k12panel agent (version 0.2 or newer), must have reported its encryption key, and must be **active or inactive** (not archived, in the trash, or awaiting enrollment approval).

When it can't, the API says so instead of accepting work that will never happen:

| Status | `error` | Meaning |
| --- | --- | --- |
| 422 | `device_cannot_receive_commands` | No agent, an agent older than 0.2, or no encryption key reported yet — the message says which |
| 422 | `asset_state_not_commandable` | The device is archived, trashed, or awaiting enrollment |

For a Chromebook-heavy district this is the common answer, and it is not an error in your code: those devices are managed through their MDM, not through the agent.

#### Watching what happened

```
GET /api/public/v1/commands?asset=4471GET /api/public/v1/commands/88213
```

Filters: `asset`, `status`, `type`, `job`, `created_after`, `created_before`. Newest first.

| `status` | Means |
| --- | --- |
| `pending` | Queued; the device has not picked it up yet |
| `running` | **The agent has retrieved it** — not that it is executing right now |
| `completed` | The agent reported a result |
| `timed_out` | Delivered, but the device never reported back |
| `expired` | Never picked up before `expires_in_minutes` ran out |
| `canceled` | Cancelled in k12panel before the device ran it |

`running` is worth reading twice. It is stamped when the agent **collects** the command, so a dashboard that shows "running" as "in progress right now" will be subtly wrong — a device that collects a command and then loses power sits in `running` until the recovery job (every five minutes) resets or times it out.

**Output is previewed in lists and complete on a single command.** A list gives you `results_preview` (the first 200 characters) and `results_truncated`; fetch the command by id for the full `results`. Script output has no size limit, so a page of complete outputs could be enormous.

Finished commands are kept for **90 days** and then removed.

#### Cancelling

```
DELETE /api/public/v1/commands/88213 
```

Cancelling needs `commands:execute` — stopping a fleet operation is not a read.

If the agent already retrieved the command, the cancel still succeeds but the response carries a warning that it may run anyway:

```
{  "id": 88213,  "status": "canceled",  "warnings": [    { "code": "command_already_retrieved",      "message": "The agent has already retrieved this command; it may still execute on the device." }  ]}
```

A command that already finished is left exactly as it is (its status is history), with a `command_already_finished` warning.

#### Many devices at once

```
POST /api/public/v1/commands/bulk 
```

```
{  "op": "create",  "items": [    { "asset": { "id": 4471 },            "type": "force_poll" },    { "asset": { "serial": "5CD1234ABC" }, "type": "force_poll" }  ]}
```

Batches follow the same rules as every other bulk endpoint — up to 25 items run inline and return the completed job; larger batches return `202` with a `job_id` to poll at `GET /jobs/{id}`; the cap is 1,000 items; `?dry_run=true` validates the whole batch. See [Doing things in bulk](https://k12panel.com/kb/developer-api/KB_API_BULK.md?hsLang=en).

Two things are specific to commands:

- **A batch needs the highest permission any item needs.** One `custom` item makes the whole batch require `commands:script`.
- **A finished job means the commands were queued, not that the devices did anything.** That is the point of keeping the two separate: rows are written in seconds, devices report back in minutes, hours, or never. Poll `GET /commands?job={job_id}` for what actually happened.

- [Getting started](https://k12panel.com/kb/getting-started?hsLang=en)
- [Menu Functions](https://k12panel.com/kb/menu-functions?hsLang=en)
- [Settings](https://k12panel.com/kb/settings?hsLang=en)
- [3rd Party Integrations](https://k12panel.com/kb/3rd-party-integrations?hsLang=en)
- [PC Health and SOAR](https://k12panel.com/kb/pc-health-and-soar?hsLang=en)
- [FAQ](https://k12panel.com/kb/faq?hsLang=en)
- [K12Panel Agent](https://k12panel.com/kb/k12panel-agent?hsLang=en)
- [Blueprints Catalog](https://k12panel.com/kb/blueprints-catalog?hsLang=en)
- [Writing your own Blueprint](https://k12panel.com/kb/writing-your-own-blueprint?hsLang=en)
- [MSP Tools](https://k12panel.com/kb/msp-tools?hsLang=en)
- [Developer API](https://k12panel.com/kb/developer-api?hsLang=en)

# K12Panel Inc

k12panel.com Help Center

Copyright © 2026, K12Panel Inc