Node API Error Codes and Retry Advice

Look up local API errors and recovery steps. Learn when to retry and when a print may already have reached the printer.

Published 2026-09-09 · Updated 2026-09-21 · All documentation →

When a node returns a JSON error, inspect its error code alongside the HTTP status and the operation you requested. This reference explains the current codes and recovery options. A connection failure or unreadable response is a separate, uncertain outcome; it does not establish whether a print reached the printer.

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, render_too_large and unsupported_line_type — the job's shape or size, which only the caller can fix — and 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.

Not every refusal on those three routes is a command result. A body that does not parse, a raw payload past its cap, or a raw decode buffer the node could not allocate is refused before any command exists, and answers with the plain error envelope — no commandId, and nothing on /events. POST /compose always answers with the plain envelope, including for the printer refusals it shares with POST /print.

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

  • 409 and 503 often describe temporary state. Inspect the error code and delivery outcome before retrying.
  • 400, 404, 413 and 422 need the route-specific explanation. A malformed command must be fixed, but invalid_body can also mean a failed body read, and a missing boot-log record is different from an unknown route.
  • 401 needs a credential. See auth.
  • 500 means the operation failed. Configuration or storage writes may have partially changed state. Follow the code-specific recovery instructions before repeating a mutation.

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 on /print and /compose and a 503 on /raw despite reading like a request problem — an explicit allocation refusal can be retried after capacity recovers. A timeout or missing response does not establish that the request failed before output.

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".

Command IDs correlate results; the device does not deduplicate repeated IDs. A successful command response means transport acceptance, not proof that a complete receipt emerged. After a timeout, disconnect or write_failed, bytes may already have reached the printer. Do not automatically retry print, raw, drawer or compose mutations: inspect the paper and let the operator authorize a clearly marked reprint with a fresh ID. A later status read cannot establish the earlier outcome. The table's retry column describes whether the condition may clear, not a blanket replay policy.

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, /name, /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 /print, POST /compose; POST /raw when the decoded bytes pass 8,192no — send less
render_alloc_failed409 on /print and /compose; 503 on /rawthose threeyes — 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 /raw, POST /composeno
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/kick, /composeoutcome may be partial — inspect output; no automatic replay
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_type422POST /print, POST /composeno — this build cannot encode that line shape. It was a 409 on hardware until the node was held to the same status the digital twin answers; the line’s shape is the problem, not the node
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
custody_hold409POST /provision, POST /network (credentials), PUT /cloudno — not until somebody is at the node. Its stored configuration was erased to recover unusable storage, so it refuses the three writes that decide who it answers to until the five-second setup-button hold attests physical possession. Everything else — the portal, the LAN API, printing, the node name — still works
no_slots503GET /events, GET /camera/streamyes — pure capacity. It clears the moment somebody disconnects
restarting503POST /print, POST /drawer/kick, POST /rawyes — nothing was printed. The node stopped accepting new jobs to finish an update and is back in seconds
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 (PUT /config, /camera/config, /cloud, /name, /network/ipv4, /update, POST /network, /restart), the DELETE /bootlog/* pair, and — as an out-of-memory JSON build — GET /camera, /peers, /scans, /devices/unknownsometimes — some call sites are an out-of-memory JSON build and clear on their own; the rest carry a storage error. On a GET it only ever means the reply could not be built. Do not assume that nothing changed: a multi-key write can leave earlier keys stored before a later write fails. Reconcile the current configuration and the route-specific recovery instructions before another write
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 48 error strings. 7 of them only the digital twin can produce, so 41 can come off a physical node — and those 41 are the table above. These three numbers are read out of the protocol package when this page is built, not typed into it.

The 7 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
conflicta PUT /config that sets one printer setting both at the top level and under printers.receipt. Node firmware does not accept the printers map yet, so it never meets the conflict

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 uses the same envelopes on the routes it implements, 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. It does not implement every route — see the API overview for the gaps — so a code that only a missing route emits has to be met on hardware.

Frequently asked questions

Can I switch on the HTTP status alone and ignore the error string?
No. A 409 can mean a missing drawer, a failed transfer, a stream already in use or a memory shortage. Read the error code and check whether the operation may already have produced output. Do not automatically replay a print or drawer command after an uncertain result.
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 most capacity refusals are a 409?
The event stream has four slots. After a subscriber disconnects, a refused subscription can be attempted again with backoff. The render_alloc_failed code on POST /raw is also a 503: that explicit response means allocation failed before output. Treat a missing response or a different error separately; an HTTP status alone is not a replay policy.
A print returned 200 but nothing came out. Which code should I have got?
A successful response confirms software or transport acceptance, not a complete physical receipt. Check the paper, printer self-test and GET /status. Do not automatically send the same job again: a late or partial print can turn that retry into a duplicate.

Still stuck? Contact support with your node model, firmware version, and what happened. Include an error code if you have one.

Related reading