For developers & ISVs

Integrate restaurant hardware once.

Every POS team ends up owning a private zoo of printer drivers, certified-hardware whitelists, and 'printer offline' tickets. Proxy Nodes puts that whole problem behind one HTTP/JSON API on the restaurant's LAN — printer, cash-drawer pulse, and barcode scanner behind the same contract. A byte-compatible simulator and 146 conformance specs mean the integration passes in CI before a printer is on your bench; the same specs then run unchanged against real hardware.

PN-PROTO-1 concept render — a palm-size two-tone node with ethernet, printer and scanner USB ports, USB-C power, and a status LEDPN-PROTO-1 · concept

mDNS at proxynodes-<id>.local · JSON on :80 · raw ESC/POS on :9100

The API

One call. The node does the last mile.

Nodes announce themselves over mDNS and serve JSON on the LAN. Your software never touches ESC/POS, RS-232 framing, or USB descriptors — unless it wants to.

Print a receipt

curl http://proxynodes-7f3a.local/print \
  -X POST -H "content-type: application/json" \
  -d '{
    "lines": [
      { "type": "banner", "value": "ORDER", "align": "center" },
      { "type": "text", "value": "Table 4", "bold": true, "size": "2x" },
      { "type": "barcode", "symbology": "qr", "value": "ORDER-1042" },
      { "type": "cut" }
    ]
  }'

Seven line types: text (with bold, alignment, and double width or height), banner block lettering the node draws itself, rules, feeds, cuts, barcodes, and 1-bit rasters. The node wraps text at the paper’s real column count, so a line that will not fit comes back as an error rather than as a receipt missing its right-hand half.

Ask how the hardware actually is

curl http://proxynodes-7f3a.local/status

{
  "identity": {
    "deviceId": "pn-7f3a", "variant": "wifi",
    "firmwareVersion": "1.2.33"
  },
  "online": true,
  "uptimeSeconds": 86213,
  "printers": [{
    "type": "printer_status", "endpoint": "receipt",
    "online": true, "paperOut": false, "coverOpen": false,
    "drawerOpen": false
  }]
}

Live paper, cover, and drawer state, read over DLE EOT. And when a reading can't be taken — a write-only printer, a dead status line — the driver layer models it as known: false rather than inventing "no faults", so "paper is fine" and "I can't tell" never get conflated.

Already speak :9100?

If your software prints raw ESC/POS to network printers today, there is no integration at all: point it at the node's :9100 port. DLE EOT status queries are answered too.

Swap hardware, not code

Star to Epson to a $60 generic is a node-side config change. Your software keeps calling the same API.

Tune per printer, remotely

Cut clearance, print width, USB pacing — runtime config over PUT /config, persisted on the node. No reflash, no site visit.

Nodes also phone home on a schedule — a heartbeat, not a control channel — configured over GET/PUT /cloud. Point it at your own server instead of the Proxy Nodes service; see the self-hosted telemetry guide for the wire contract.

Under the hood

One classifier, four verdicts. Refusal is never silent.

Every USB device that appears gets classified, and every branch that turns a device away records why — VID:PID and make/model are logged before any refusal, so a device nobody wrote code for is still nameable by whoever is standing in front of it.

USB plug-in

Device enumerates · VID:PID + make/model recorded

pn_classify_device()

Printer

BOUND in 1,012 ms

Bulk-OUT for bytes, bulk-IN for DLE EOT status. Ready to print.

Scanner

BOUND · HID boot

Reports framed into barcodes; every read logged with a verdict.

Hub

Enumerate children

Powered hubs supported — printer + scanner behind one, simultaneously.

Unknown

Parked, then probed

VID:PID and model kept first, so a device nobody wrote code for is still nameable. Once the bus is quiet it is read — this is the branch a HID scale is recognized on.

A STALL and a silence are different: a stall completes the transfer and the device stays bound — one job lost, not the printer. A silence marks it not-ready. Every rejection names the reason GET /status will report.

Scanners

Every read gets a verdict.

A keyboard-wedge scanner types whatever it decodes and never explains itself. The node keeps the record it doesn't.

curl http://proxynodes-7f3a.local/scans

{
  "endpoint": "scanner",
  "accepted": 214, "rejected": 3, "capacity": 32,
  "scans": [
    { "value": "0123456789012", "verdict": "accepted",
      "symbology": "ean13", "symbologySource": "aim",
      "ageMs": 1180, "gapMs": 2 },
    { "value": "0123456789012", "verdict": "duplicate",
      "symbology": "code128", "symbologySource": "inferred",
      "ageMs": 5411, "gapMs": 640 }
  ]
}

GET /scans is a ring of recent reads with a verdict on each. Reads the node refused stay in the log with the reason — duplicate, too_short, burst — because a rule that silently drops a barcode is indistinguishable from a broken scanner.

Symbology is labeled by provenance: aim means the scanner transmitted it, inferred means the node worked it out from the payload — and an inference stays marked, so your software never routes on a guess without knowing it. Decode policy — read modes, timeouts, symbology allow-lists, keyboard layouts — is set over PUT /config, same as printer tuning.

And nodes find each other: GET /peers lists every other node heard on the LAN, so one known IP reaches the whole fleet.

The rest of the surface

What else a node answers.

Beyond print and status, and with the limits attached. Full reference in the docs — this is the shape of it.

Lock it down: open, admin, strict

A node ships open and stays that way until you set a key. Above open, an X-PN-Api-Key header gates the config class, then the operational class. Firmware replacement is gated in every mode by a separate credential — closed by default, not open.

LimitOne key per node. No per-caller identity, no scopes, no audit trail. Raw :9100 is outside it and cannot be inside it.

Auth model

Live events over SSE

GET /events streams telemetry, heartbeats, scans and command results, so your software reacts instead of polling.

LimitFour slots. A fifth subscriber gets 503 no_slots — and browser tabs on the node's own page take the same four. Budget for it, or use webhooks.

API overview

Webhooks: the node pushes

Set one URL over PUT /config and the node POSTs each event to it, signed X-PN-Signature: sha256=… over the exact body, with a secret it never returns.

LimitAt-most-once, one retry, a queue eight deep with the oldest dropped, no timestamp header, and no hosted relay. Reconcile from GET /scans, which is the authority.

Webhook contract

Composed printing

POST /compose takes a sentence and returns printable lines, already folded to ASCII and wrapped to the node's column width. The node's own page has an operator-facing form for it.

LimitThis is the one feature that needs a connection: the node relays to a hosted model with its device token, so composing needs the internet while printing never does. The model key stays in the cloud and is never handed to a browser or stored on a node.

How it fits

Weight from a USB scale

GET /scale answers weight, unit, stability and a status word, and the same block rides GET /status with a telemetry event beside it. A poll rather than a subscription, deliberately — see the slot count.

LimitThe surface, not a bench demonstration. Three absences are modelled separately on purpose: asleep, absent, and refused for want of a USB channel on a full bus.

Which SKUs declare one

The recipe store

A node that meets a device nobody wrote code for can be taught to drive it without a firmware release. It fetches a table whose rows select a codec already compiled into the image, and the account fleet view reports what each node has learned.

LimitA row selects a codec; the schema has no field a byte format could be written into. That is what makes editing a table a safe way to ship device support — and it means a device with no compiled codec is not reachable this way.

What has been driven

Capture ring for the awkward bugs

GET /capture streams the node's diagnostic traffic ring as NDJSON, and POST /capture/mark writes an operator annotation into it — the one record in a trace that says what the human was doing.

LimitA marker with the ring not recording is refused with 409 capture_off before the body is even parsed. A 200 for a marker that went nowhere is invisible until somebody reads the trace.

Refusals, explained

Boot history and the crash record

GET /bootlog carries the boot ring and the crash summary, kept in non-volatile storage — so it survives panics, power cycles and reflashes. A build that broke the boot can be flashed over and still interrogated afterwards.

Limit"Healthy" means it reached the state it was supposed to reach, and that includes setup mode — a node's successful self-heal is not a failed boot.

Reading a sick node

Network settings without a cable

A fixed IPv4 address, its netmask, gateway and DNS are set over the API — no reflash and no serial console. Compatibility profiles re-advertise live over mDNS with no reboot at all.

LimitThe address write REPLACES rather than patches: it starts from empty and fills only what you sent, so omitting DNS erases it. It takes effect on the next boot and says so, because re-addressing a live interface drops every socket on it — including the one carrying the response.

The limits list

There is no route table on this site, on purpose. A node registers more handlers than a marketing page keeps up with and the firmware’s own table is authoritative — a partial list reads as complete, which is worse than no list. The documentation publishes the parts that do not move: the auth model, the complete error vocabulary with retry advice, the limits, and the webhook contract.

Device support

New hardware without a firmware release.

A node that meets a device nobody wrote code for can be taught to drive it from a table, and it says out loud when what it was told disagrees with what it worked out itself.

Flowchart of how a USB vendor and product id becomes a working driver: the cloud overlay, the node's cached table and the compiled seed table resolved in a stated order, the hourly conditional poll, what the self-teach setting gates, and why a cloud row that overrides one the node worked out for itself reports the disagreement.
How a node learns a device nobody wrote code for. Three tiers resolved in a stated order rather than a version race — and when the cloud tier overrides a row the node worked out for itself, it reports the disagreement instead of quietly winning. An override that resolves silently is an override nobody can debug.

CI-first

Your integration passes before a printer exists.

The digital twin is a LAN service that mirrors the firmware byte for byte — same API, same :9100 behavior, same telemetry — with fault injection for the failure paths you can't schedule on real hardware.

$ pnpm sim                 # digital twin: HTTP :8080, :9100, mDNS
$ pnpm print               # POST a sample receipt — renders in the console
$ pnpm conformance         # protocol conformance suite vs the twin

sim> paper out             # now make it a bad day
sim> cover open
sim> offline

The conformance suite is the honest answer to "does passing against a simulator mean anything?" — one suite, validated against shared schemas, that runs identically against the twin and a physical node on your bench.

Protocol conformance is unconditional; physical-effect assertions skip with a printed reason when the hardware isn't attached — never a silent pass.

More on the simulator →

146 conformance specs · 2338 JS tests · 59 firmware suites under ASan/UBSan

Guides

The reference material we wished existed.

Deep technical guides on the protocols and failure modes of receipt printing — useful whether or not you ever run a node.

All guides →