Node API Error Codes and Retry Advice
Every error string the node API can answer with, the HTTP status it arrives on, the routes that emit it, and whether retrying the same request can ever work.
Published 2026-09-09 · All documentation →
Every failed response from a node carries an error string, and that string is the only part of
the response your software should branch on. This page is the whole vocabulary: what each code
means, which HTTP status it arrives on, which routes emit it, and — the column to read first —
whether sending the identical request again can ever work.
Two envelopes
Most routes answer with an error envelope: ok, error, and an optional free-text detail.
{
"ok": false,
"error": "image_digest_mismatch",
"detail": "X-PN-Image-Sha256 must be 64 hex characters"
}The three command routes — POST /print, POST /drawer/kick, POST /raw — answer a command
result instead, which carries commandId and endpoint alongside the same error string. Its
status is derived from the code rather than chosen per call site: 422 for unknown_endpoint and
render_too_large, 409 for everything else. Every one of those outcomes is also mirrored onto
the /events stream as a command_result with the same error, so a POS watching the stream and
the one that made the request see the same story.
detail is for a human. It is optional, it is free text, and it is not a second machine-readable
field — do not parse it.
The rule, before the table
If you want one sentence rather than forty rows:
409and503describe the node’s current state. Worth retrying.400,404,413and422describe your request. Retrying identical bytes will not help.401needs a credential. See auth.500means the node took the request and failed itself. The twoprofile_*codes want a reboot rather than a retry.
The exceptions are all in the table, and there are two worth knowing before you write the branch:
unknown_endpoint is a 409 on /compose and a 422 everywhere else, and render_alloc_failed
is a 409 despite reading like a request problem — the job was fine and the node was out of heap.
Do not treat the set as closed
The wire type for error is an open string, deliberately, and it is not an enum. A node is very
often older or newer than the checkout parsing it, and a strict enum would turn a well-formed
unrecognised code into a parse failure — reporting a node that is ahead as one speaking a broken
protocol. Branch on the codes you handle; treat anything else as "this failed, show the detail".
The codes a node can send
| code | status | routes that emit it | retry? |
|---|---|---|---|
invalid_json | 400 | every route that parses a body, and the setup portal’s POST /provision | no — fix the JSON |
invalid_body | 400 | POST /print, /raw, /drawer/kick, /compose, /network, /update, PUT /network/ipv4 | sometimes — an oversized body will not change; a socket that died mid-read will. On POST /update it means the upload ended early |
band_unsupported | 400 | POST /network | no — the value is well-formed and this radio is what refused it. Refused, never clamped |
not_found | 404 | any unrouted path; also the DELETE /bootlog/* routes when there is nothing stored | no |
method_not_allowed | 405 | a path this node serves, asked with a verb it does not | no. Distinct from not_found on purpose: reaching a real route with the wrong verb means the caller found us, which is a different investigation |
capture_off | 409 | POST /capture/mark with the diagnostic ring not recording | no — answered before the body is parsed, because the answer does not depend on it |
invalid_command | 422 | POST /print, /raw, /drawer/kick, /compose, /restart | no |
invalid_config | 422 | PUT /config, /camera/config, /cloud, /network/ipv4, /update | no |
invalid_provision | 422 | POST /network, and POST /provision on the setup portal | no |
unknown_endpoint | 422 on /print, /raw, /drawer/kick; 409 on /compose | those four | no |
render_too_large | 422 | POST /print | no — send less |
render_alloc_failed | 409 | POST /print, POST /compose | yes — the job was fine and the node was out of heap. Do not permanently shrink a job that fits |
ota_locked | 401 | POST /update; PUT /update when the body carries a new update key and one is already stored | no without the current key |
api_locked | 401 | any mutating route this node’s tier gates; also PUT /config replacing a stored API key, in every mode | no without the right X-PN-Api-Key |
ota_busy | 409 | POST /update, and reverting to the factory image | yes — nothing failed |
no_printer | 409 | POST /print, POST /raw | no |
no_drawer | 409 | POST /drawer/kick | no |
no_camera | 409 | GET /camera/frame, GET /camera/stream, PUT /camera/config | no — there is no sensor |
capture_failed | 409 | GET /camera/frame, PUT /camera/config | yes — there is a sensor and the next request may succeed. Nothing was persisted |
write_failed | 409 | POST /print, /raw, /drawer/kick | sometimes — the transfer to the printer failed |
unsupported_command_set | 409 | POST /print | no — the printer’s own device ID named a language this build does not speak, so the job would have printed garbage. A fact about that printer. POST /raw is deliberately not subject to it |
unsupported_line_type | 409 | POST /print | no — this build cannot encode that line shape |
update_disabled | 409 | PUT /update asking for a check or an apply | no — the local kill switch is off; turn it back on in the same request |
cloud_not_configured | 409 | POST /compose, PUT /update | no — there is nothing to ask. Set the cloud URL and token |
restart_pending | 409 | PUT /update asking for a check or an apply | yes — see below |
compose_failed | 500 out of memory, 502 on the upstream leg | POST /compose | yes, then check the node’s cloud settings. The upstream status travels in detail, never as this response’s status |
stream_busy | 409 | GET /camera/stream | yes, once the other stream closes |
no_slots | 503 | GET /events, GET /camera/stream | yes — pure capacity. It clears the moment somebody disconnects |
image_too_large | 413 | POST /update | no |
image_invalid | 422 | POST /update, including a missing Content-Length | no — send the right file |
image_wrong_variant | 422 | POST /update | no, unless you meant it: retry with X-PN-Allow-Variant-Change: 1. The file is not wrong, the target is. See hardware variants |
image_digest_mismatch | 422 | POST /update | no — see below |
ota_write_failed | 500 | POST /update | no — the flash refused, or there is no slot to write into |
persist_failed | 500 | every route that writes settings, and the DELETE /bootlog/* pair | sometimes — some call sites are an out-of-memory JSON build and clear on their own; the rest carry a storage error. It means the change was not applied, not that storage is untouched: flash has no transaction, so a multi-key write that failed partway left earlier keys written. Re-read the config after a reboot |
profile_switch_failed | 500 | PUT /config | no — mDNS could not re-advertise. Reboot |
profile_rollback_failed | 500 | PUT /config | no — reboot. The switch failed and the undo failed, so node state is inconsistent and only a reboot re-syncs it |
capture_alloc_failed | 500 | PUT /config | rarely — the diagnostic ring is one allocation, and a node that cannot find that block now usually will not in a minute either. Never answered as a 200 that reports capture on while recording nothing |
record_alloc_failed | 500 | GET /devices/unknown | yes — a small copy failed. Deliberately not answered as "no device present", because a node that has met unknown hardware and cannot report it must not look identical to one that never met any |
save_failed | 500 | POST /network, and POST /provision on the setup portal | sometimes — the credentials did not reach storage |
The shared vocabulary declares 45 error strings. 6 of them only the digital twin can produce, so 39 can come off a physical node — and those 39 are the table above. These three numbers are read out of the protocol package when this page is built, not typed into it.
The 6 the twin adds are listed here so a client author does not go hunting for an emitter that does not exist. Each is a real behavioural difference with a written reason, not a naming accident:
| code | why no node sends it |
|---|---|
body_too_large | the twin's bounded streaming reader can tell an oversized body from an unparseable one; the firmware's reader collapses both into invalid_json |
wrong_endpoint_kind | the endpoint exists but is the wrong kind for the command. The firmware collapses this into unknown_endpoint, so it cannot tell 'no such endpoint' from 'you sent a print job to the cash drawer' |
cover_open | the twin refuses the job up front. On hardware the cover can open mid-job, so the firmware reports it as printer status instead — there is no honest moment to fail the request at |
paper_out | the same as cover_open: a telemetry state on hardware, a command refusal on the twin |
no_scale | the twin's fault-injection plane only, which no node serves |
no_scanner | the twin's fault-injection plane only, which no node serves |
Two that look alike and are opposites
409 restart_pending is transient, and it fires on a node nobody touched. Asking for an
update check or an apply is refused while a restart is already held — and the gate has two callers,
not one: an installed image waiting for a quiet moment, and the USB recovery ladder’s reboot
rung. The deadline is 900 seconds, so the window can stand for up to fifteen minutes. Retry works.
This branch used to answer cloud_not_configured for the same condition, which was harmless while
an update was the only thing that could hold a restart and actively misleading once the recovery
ladder could ask for one too: a node that had never seen an update told its operator the cloud was
unconfigured.
422 image_digest_mismatch is permanent. Every declared byte arrived and hashed to something
other than the digest the caller promised — or the digest header was present and malformed.
Retrying the identical bytes never works. It is split out of image_invalid precisely so it does
not read as "wrong file": nothing is wrong with the file that was chosen, and what to go and look
at is the transfer, or the artifact behind it.
Where this list comes from
The codes are read from the shared protocol package, which firmware, the digital twin, this site and the tooling all compile against. A contract check compares that list against the string literals the firmware actually passes to its error helpers, and fails both ways — a code the firmware emits that the list does not declare, and a code the list declares that no handler produces. That is what keeps a vocabulary from becoming a wish list.
Try the codes before you have hardware: the digital twin answers on the same routes with the same envelopes, and its fault-injection plane can produce paper-out and cover-open, which a real node reports as printer status rather than as a refusal.
Frequently asked questions
- Can I switch on the HTTP status alone and ignore the error string?
- Only for the coarse decision. 409 and 503 are worth retrying and 400/404/413/422 are not, which the status does tell you. But 409 covers a drawer that is not attached, a printer that refused a transfer, a stream already in use and a node that is out of heap — four different actions for the reader. The string is what tells them apart.
- What happens if a node sends a code my client has never heard of?
- Nothing breaks. The wire type for `error` is an open string rather than an enum precisely so an unrecognised code parses cleanly instead of failing validation. Handle the codes you branch on and fall through to showing `error` and `detail` for the rest.
- Is `detail` stable enough to match on?
- No. It is free text written for a human standing in front of the hardware, it is optional, and it changes when a message gets clearer. The `error` string is the contract.
- Why is `no_slots` a 503 when everything else about capacity is a 409?
- Because it is the one refusal that is purely about how many people are already connected — four event-stream slots, and a fifth subscriber gets it. Nothing about the request is wrong and nothing about the node is broken; it clears the moment somebody disconnects. That is what 503 means.
- A print returned 200 but nothing came out. Which code should I have got?
- None — check whether a printer is bound at all. `GET /status` separates a peripheral that was never plugged in, one refused for want of a USB channel, one seen and gone, and one bound and silent. See troubleshooting for reading it.
Related reading
- The Node HTTP API: Scope, Auth, and LimitsWhat a node's local HTTP/JSON API covers, which tier gates each route, and the limits worth knowing before you integrate. The route table is linked, not copied.
- Node API Auth: open, admin, and strictHow a node is locked down: the three apiMode tiers, the X-PN-Api-Key header, the separate update key, and the 15-second button hold that clears both.
- Troubleshooting a Node: LEDs, USB, PrintingIt will not come up, will not print, or keeps restarting. Read the LED, read the status, work the USB recovery ladder — and know the one fault it cannot fix.