Cloud & Remote WebSocket Printing
A common hurdle in modern cloud Point of Sale (POS) and online ordering systems is how a cloud server pushes print jobs to physical printers inside retail stores and restaurants.
Retail store countertop printers sit behind ISP NAT routers with dynamic private IP addresses (192.168.1.x). Inbound connections from the public Internet are blocked by default, and setting up dynamic DNS or port forwarding is dangerous, fragile, and hard to maintain across multiple locations.
The Outbound WebSocket Gateway Architecture
Section titled βThe Outbound WebSocket Gateway Architectureβpapermint solves this problem with the Outbound WebSocket Gateway Pattern. Instead of your cloud server trying to reach inside the store, the in-store daemon (papermintd) maintains a persistent outbound encrypted WebSocket connection to your cloud backend:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Cloud Server (VPS / Cloudflare / Hono / Node.js / Next.js) ββ β’ Customer places order online / Barista dashboard ββ β’ Generates receipt ticket or label JSON payload ββ β’ Dispatches PRINT_JOB to shop WebSocket channel βββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ β Persistent Outbound WSS βΌ (Bypasses NAT & Firewalls!)ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ In-Store Gateway (`papermintd` on Raspberry Pi / PC / Mac) ββ β’ Connects to wss://... on boot with bearer authentication ββ β’ Listens for PRINT_JOB events ββ β’ Formats wire bytes in ~10Β΅s via `papermint` ββ β’ Streams to physical USB, TCP, or Serial printer ββ β’ Replies with instant ACK and paper sensor telemetry βββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ β Raw Wire Bytes βΌ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Countertop Receipt / Kitchen Printer (Port 9100 / USB) β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ1. Running the In-Store Gateway (papermintd)
Section titled β1. Running the In-Store Gateway (papermintd)βThe papermintd sidecar daemon supports outbound gateway mode out of the box with zero extra software:
# Connect to your cloud server with shop authentication:papermintd \ --gateway "wss://pos-api.yourbrand.com/ws/printer/shop_042" \ --token "sk_live_9b4e78a2c1f" \ --shop-id "shop_042" \ --printer "tcp://192.168.1.200:9100"Supported --printer Formats
Section titled βSupported --printer Formatsβ- Network / LAN / WiFi:
tcp://192.168.1.200:9100or192.168.1.200:9100 - USB:
usb:04b8:0202(VID:PID in hex, e.g. Epson TM-T88VI) - Serial:
serial:/dev/ttyUSB0:19200 - Mock:
mock(for virtual testing in CI/CD)
Environment Variable Alternative
Section titled βEnvironment Variable AlternativeβYou can configure papermintd using environment variables or a .env file:
GATEWAY_URL="wss://pos-api.yourbrand.com/ws/printer/shop_042"GATEWAY_TOKEN="sk_live_9b4e78a2c1f"SHOP_ID="shop_042"DEFAULT_PRINTER="tcp://192.168.1.200:9100"PORT=8080[!TIP] While running in gateway mode,
papermintdsimultaneously serves its local HTTP REST API onhttp://127.0.0.1:8080. Local cash registers and tablets can still query/healthor send local receipts over LAN at the same time.
2. Cloud Server Implementation (Node.js / Hono / Bun)
Section titled β2. Cloud Server Implementation (Node.js / Hono / Bun)βHere is a minimal cloud WebSocket server that registers in-store printers and pushes jobs when web orders arrive:
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 3000 });const storeSockets = new Map<string, WebSocket>();
wss.on("connection", (ws, req) => { let authenticatedShop: string | null = null;
ws.on("message", (raw) => { const msg = JSON.parse(raw.toString());
// 1. In-store daemon registers upon connecting if (msg.type === "register") { if (isValidToken(msg.token)) { authenticatedShop = msg.shop_id; storeSockets.set(msg.shop_id, ws); console.log(`β
Shop ${msg.shop_id} connected (v${msg.version})`); } else { ws.close(4001, "Unauthorized"); } }
// 2. In-store daemon confirms print completion if (msg.type === "ack") { console.log(` Job ${msg.job_id} acknowledged (success: ${msg.success})`); } });
ws.on("close", () => { if (authenticatedShop) storeSockets.delete(authenticatedShop); });});
// Function to push a receipt to a specific shop from Next.js / API route:export function dispatchCloudReceipt(shopId: string, order: any) { const ws = storeSockets.get(shopId); if (!ws || ws.readyState !== WebSocket.OPEN) { throw new Error(`Shop ${shopId} is currently offline`); }
const job = { type: "print_job", job_id: `ord_${order.id}`, dialect: "escpos", paper_width: "80mm", ticket: { title: "MINT SPECIALTY COFFEE", subtitle: `Order #${order.number}`, items: order.items.map((i: any) => ({ qty: `${i.qty}x`, name: i.name, price: `$${i.price.toFixed(2)}`, total: `$${(i.qty * i.price).toFixed(2)}`, })), totals: [ { label: "SUBTOTAL", value: `$${order.subtotal.toFixed(2)}` }, { label: "TAX (8%)", value: `$${order.tax.toFixed(2)}` }, { label: "TOTAL", value: `$${order.total.toFixed(2)}` }, ], qr: `https://brand.com/receipt/${order.id}`, cut_mode: "full", beep: 2, }, };
ws.send(JSON.stringify(job));}3. Wire Protocol Specification
Section titled β3. Wire Protocol SpecificationβCloud $\to$ Gateway (print_job)
Section titled βCloud $\to$ Gateway (print_job)β{ "type": "print_job", "job_id": "job_01h8x9p3", "dialect": "escpos", "paper_width": "80mm", "ticket": { "title": "STORE TAKEOUT", "items": [ { "qty": "2x", "name": "Latte", "total": "$9.00" } ], "cut_mode": "full" }}Gateway $\to$ Cloud (ack)
Section titled βGateway $\to$ Cloud (ack)β{ "type": "ack", "job_id": "job_01h8x9p3", "success": true, "bytes_sent": 412, "error": null}Heartbeat & Telemetry
Section titled βHeartbeat & Telemetryβ- Ping / Pong: Send
{"type": "ping", "timestamp": 1726400000}. Gateway immediately replies with{"type": "pong"}. - Status Query: Send
{"type": "status_query"}. Gateway replies with real-time sensor status (is_online,is_ready,paper: "Adequate",cover: "Closed").
4. Production Systemd Service (Raspberry Pi / Linux)
Section titled β4. Production Systemd Service (Raspberry Pi / Linux)βTo run papermintd as an unkillable system service in retail stores:
Create /etc/systemd/system/papermintd.service:
[Unit]Description=papermint Thermal Print Gateway SidecarAfter=network.target
[Service]Type=simpleUser=piEnvironment=GATEWAY_URL=wss://pos-api.yourbrand.com/ws/printer/shop_042Environment=GATEWAY_TOKEN=sk_live_9b4e78a2c1fEnvironment=SHOP_ID=shop_042Environment=DEFAULT_PRINTER=tcp://192.168.1.200:9100ExecStart=/usr/local/bin/papermintdRestart=alwaysRestartSec=3
[Install]WantedBy=multi-user.targetEnable and start the service:
sudo systemctl daemon-reloadsudo systemctl enable --now papermintd