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
409and503often describe temporary state. Inspect the error code and delivery outcome before retrying.400,404,413and422need the route-specific explanation. A malformed command must be fixed, butinvalid_bodycan also mean a failed body read, and a missing boot-log record is different from an unknown route.401needs a credential. See auth.500means 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".
Print outcomes and retries
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
| 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, /name, /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, POST /compose; POST /raw when the decoded bytes pass 8,192 | no — send less |
render_alloc_failed | 409 on /print and /compose; 503 on /raw | those three | 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, POST /compose | 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, /compose | outcome may be partial — inspect output; no automatic replay |
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 | 422 | POST /print, POST /compose | no — 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_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 |
custody_hold | 409 | POST /provision, POST /network (credentials), PUT /cloud | no — 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_slots | 503 | GET /events, GET /camera/stream | yes — pure capacity. It clears the moment somebody disconnects |
restarting | 503 | POST /print, POST /drawer/kick, POST /raw | yes — nothing was printed. The node stopped accepting new jobs to finish an update and is back in seconds |
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 (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/unknown | sometimes — 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_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 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:
| 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 |
conflict | a 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
- The Node HTTP API: Scope, Auth, and LimitsPrint a receipt, read status and handle errors with the local HTTP API. Includes curl examples, authentication and network requirements.
- 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, PrintingWork through power, Wi-Fi, printer detection and print failures. Read status, recover safely and collect the details support needs.