The print bridge
How rendered ESC/POS receipts reach a printer on the customer's own network: the claim-then-acknowledge loop, the claim timeout, and the two silent failure modes to watch for.
7 min read
On this page
Your server never talks to a printer. It hands the device a rendered receipt and a LAN address, and the device opens the socket from inside the customer's own network — which is what makes printing work through NAT with no VPN, no port forwarding and no static IP at the site. Render ESC/POS server-side and a receipt layout change never needs a firmware update.
The loop
GET /restaurant/print-jobs HTTP/1.1
x-device-id: AA:BB:CC:DD:EE:FF
──────────────────────────────────────────────
204 No Content → nothing to print. Poll again in 3 seconds.
200 OK
{
"order_id": 344,
"printer_host": "192.168.18.87",
"printer_port": 9100,
"escpos_b64": "G0AbYQEuLi4="
}Base64-decode escpos_b64, open a TCP socket to printer_host and printer_port, write the bytes, close. Then acknowledge.
POST /restaurant/orders/344/printed HTTP/1.1
x-device-id: AA:BB:CC:DD:EE:FF
──────────────────────────────────────────────
{ "order_id": 344, "printed_at": "2026-09-10T10:22:31Z", "already": false }Claim mechanics
- Claiming is an atomic compare-and-swap, so several devices polling the same queue in the same millisecond cannot both win a ticket.
- A claimed-but-unacknowledged ticket is released to other devices after 90 seconds — long enough for a print and a few polls, short enough that a device that died mid-job does not strand the kitchen.
- The same device may re-claim its own unacknowledged ticket immediately. If you rebooted mid-job nobody else can print it anyway, so retrying at once beats making the kitchen wait out the timeout.
- The acknowledgement is idempotent — the first one wins. A device that prints, acknowledges, loses the response and acknowledges again does not corrupt the recorded print time.
A complete print bridge
import base64, socket, time, requests
HOST = "https://api.yourcompany.com"
MAC = "AA:BB:CC:DD:EE:FF"
def print_once():
r = requests.get(f"{HOST}/restaurant/print-jobs",
headers={"x-device-id": MAC}, timeout=10)
if r.status_code == 204:
return False # empty queue — the normal case
r.raise_for_status()
job = r.json()
payload = base64.b64decode(job["escpos_b64"])
with socket.create_connection(
(job["printer_host"], job["printer_port"]), timeout=5) as sock:
sock.sendall(payload)
# Acknowledge, always. Skipping this reprints this ticket forever and
# blocks every newer order behind it.
ack = requests.post(f"{HOST}/restaurant/orders/{job['order_id']}/printed",
headers={"x-device-id": MAC}, timeout=10)
if ack.status_code == 403:
raise RuntimeError("wrong tenant: x-device-id resolves to another property")
ack.raise_for_status()
return True
while True:
try:
if not print_once():
time.sleep(3) # the claim timeout assumes ~3 s polling
except OSError as exc:
# Printer unreachable. Do NOT acknowledge — let the 90 s claim timeout
# return the ticket to the queue so another device can take it.
print("printer unreachable:", exc)
time.sleep(3)Two silent failure modes to design against
Printer not configured
If the property has no printer_host, the backend releases the claim and answers 204 — identical to an empty queue. Orders pile up and nothing prints, with no error anywhere. This has happened in production: four orders sat unprinted while a correctly configured device polled every few seconds.
curl -s "https://api.yourcompany.com/restaurant/print-health?property_id=4" \
-H "Authorization: Bearer $TOKEN"
# → { "property_id": 4, "unprinted": 0,
# "oldest_age_seconds": null, "stale": false }Wrong tenant
Acknowledging an order that belongs to another property returns 403. If you see those, your x-device-id is resolving to a device on the wrong property — a provisioning fault, not a transient one.
Something wrong or missing on this page? Tell us.

