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:

  • 409 and 503 describe the node’s current state. Worth retrying.
  • 400, 404, 413 and 422 describe your request. Retrying identical bytes will not help.
  • 401 needs a credential. See auth.
  • 500 means the node took the request and failed itself. The two profile_* 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

codestatusroutes that emit itretry?
invalid_json400every route that parses a body, and the setup portal’s POST /provisionno — fix the JSON
invalid_body400POST /print, /raw, /drawer/kick, /compose, /network, /update, PUT /network/ipv4sometimes — an oversized body will not change; a socket that died mid-read will. On POST /update it means the upload ended early
band_unsupported400POST /networkno — the value is well-formed and this radio is what refused it. Refused, never clamped
not_found404any unrouted path; also the DELETE /bootlog/* routes when there is nothing storedno
method_not_allowed405a path this node serves, asked with a verb it does notno. 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_off409POST /capture/mark with the diagnostic ring not recordingno — answered before the body is parsed, because the answer does not depend on it
invalid_command422POST /print, /raw, /drawer/kick, /compose, /restartno
invalid_config422PUT /config, /camera/config, /cloud, /network/ipv4, /updateno
invalid_provision422POST /network, and POST /provision on the setup portalno
unknown_endpoint422 on /print, /raw, /drawer/kick; 409 on /composethose fourno
render_too_large422POST /printno — send less
render_alloc_failed409POST /print, POST /composeyes — the job was fine and the node was out of heap. Do not permanently shrink a job that fits
ota_locked401POST /update; PUT /update when the body carries a new update key and one is already storedno without the current key
api_locked401any mutating route this node’s tier gates; also PUT /config replacing a stored API key, in every modeno without the right X-PN-Api-Key
ota_busy409POST /update, and reverting to the factory imageyes — nothing failed
no_printer409POST /print, POST /rawno
no_drawer409POST /drawer/kickno
no_camera409GET /camera/frame, GET /camera/stream, PUT /camera/configno — there is no sensor
capture_failed409GET /camera/frame, PUT /camera/configyes — there is a sensor and the next request may succeed. Nothing was persisted
write_failed409POST /print, /raw, /drawer/kicksometimes — the transfer to the printer failed
unsupported_command_set409POST /printno — 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_type409POST /printno — this build cannot encode that line shape
update_disabled409PUT /update asking for a check or an applyno — the local kill switch is off; turn it back on in the same request
cloud_not_configured409POST /compose, PUT /updateno — there is nothing to ask. Set the cloud URL and token
restart_pending409PUT /update asking for a check or an applyyes — see below
compose_failed500 out of memory, 502 on the upstream legPOST /composeyes, then check the node’s cloud settings. The upstream status travels in detail, never as this response’s status
stream_busy409GET /camera/streamyes, once the other stream closes
no_slots503GET /events, GET /camera/streamyes — pure capacity. It clears the moment somebody disconnects
image_too_large413POST /updateno
image_invalid422POST /update, including a missing Content-Lengthno — send the right file
image_wrong_variant422POST /updateno, 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_mismatch422POST /updateno — see below
ota_write_failed500POST /updateno — the flash refused, or there is no slot to write into
persist_failed500every route that writes settings, and the DELETE /bootlog/* pairsometimes — 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_failed500PUT /configno — mDNS could not re-advertise. Reboot
profile_rollback_failed500PUT /configno — reboot. The switch failed and the undo failed, so node state is inconsistent and only a reboot re-syncs it
capture_alloc_failed500PUT /configrarely — 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_failed500GET /devices/unknownyes — 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_failed500POST /network, and POST /provision on the setup portalsometimes — 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:

codewhy no node sends it
body_too_largethe 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_kindthe 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_openthe 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_outthe same as cover_open: a telemetry state on hardware, a command refusal on the twin
no_scalethe twin's fault-injection plane only, which no node serves
no_scannerthe 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