Skip to content

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.


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) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The papermintd sidecar daemon supports outbound gateway mode out of the box with zero extra software:

Terminal window
# 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"
  • Network / LAN / WiFi: tcp://192.168.1.200:9100 or 192.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)

You can configure papermintd using environment variables or a .env file:

Terminal window
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, papermintd simultaneously serves its local HTTP REST API on http://127.0.0.1:8080. Local cash registers and tablets can still query /health or send local receipts over LAN at the same time.


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));
}

{
"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"
}
}
{
"type": "ack",
"job_id": "job_01h8x9p3",
"success": true,
"bytes_sent": 412,
"error": null
}
  • 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").

To run papermintd as an unkillable system service in retail stores:

Create /etc/systemd/system/papermintd.service:

[Unit]
Description=papermint Thermal Print Gateway Sidecar
After=network.target
[Service]
Type=simple
User=pi
Environment=GATEWAY_URL=wss://pos-api.yourbrand.com/ws/printer/shop_042
Environment=GATEWAY_TOKEN=sk_live_9b4e78a2c1f
Environment=SHOP_ID=shop_042
Environment=DEFAULT_PRINTER=tcp://192.168.1.200:9100
ExecStart=/usr/local/bin/papermintd
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

Enable and start the service:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now papermintd