Turn an HTTP POST into a printed receipt on a USB thermal printer.
A small, self-hosted Node.js service for anyone with a thermal receipt printer and a Raspberry Pi (or any Linux box). Point a script, a browser form, an IFTTT applet, or a home-automation webhook at it — whatever text you POST comes out of the printer, neatly normalized and padded so the cutter never clips your last line. No SDK, no cloud account, no print drivers.
$ curl -X POST http://localhost:4180/print --data "Hello, receipt!"
{"status":"printed","bytes":26}┌──────────────────────────┐
│ Hello, receipt! │ ← your text
│ │
│ │ ← auto-padding so the cut
│ │ never clips the last line
│ │
│ ✁ - - - - - - - - - - - -│ ← paper cut
└──────────────────────────┘
Developed against an Epson TM-T20II connected over USB to a Raspberry Pi,
but any ESC/POS-compatible printer exposed as a character device (e.g.
/dev/usb/lp0) should work.
GET /— a web form to type and print text.POST /print— print the request body. The body is the text; no JSON envelope required.GET /healthz— health check.
The service fixes up incoming text before printing: it normalizes line endings, trims trailing whitespace, and appends a configurable number of blank feed lines at the bottom so there's clearance before the paper cut. Clients just send their text — they no longer need to pad it themselves.
npm install
npm run build
npm start
# open http://localhost:4180/Print from the command line:
curl -X POST http://localhost:4180/print --data "Hello, receipt!"DRY_RUN=1 npm start # renders to stdout instead of a deviceThe client CLI is an HTTP client for a running service — handy for scripting
and for smoke-testing a deployment:
# POST text to /print (defaults to http://localhost:4180)
npm run client -- "Hello, receipt"
# Target another host
npm run client -- --url http://pi.local:4180 "Hello"
# Read from stdin
echo "piped text" | npm run client
# Smoke-test every route (health, web form, print, empty-body) and report
npm run client -- --checkThe target URL also reads from $RECEIPT_URL.
The render CLI produces the exact bytes the service would send, so you can
inspect receipts, save fixtures, or pipe straight to a device:
# Raw ESC/POS bytes to stdout
npm run render -- "Hello, receipt"
# Annotated view (control codes shown as <ESC>@, <GS>VA<0>, …)
npm run render -- --inspect "Hello"
# Read from stdin
echo "piped text" | npm run render
# Write a gallery of sample receipts to ./out (.escpos + annotated .txt)
npm run render -- --samples
# Save to a file, then send it to a real printer
npm run render -- --out receipt.escpos "Hi"
cat receipt.escpos > /dev/usb/lp0All configuration is via environment variables (see .env.example):
| Variable | Default | Description |
|---|---|---|
PORT |
4180 |
HTTP listen port |
PRINTER_DEVICE |
/dev/usb/lp0 |
Printer character device |
DRY_RUN |
0 |
1 renders to stdout instead of a device |
BOTTOM_PADDING_LINES |
4 |
Blank feed lines added before the cut |
MAX_BODY_SIZE |
64kb |
Max body size for a single print |
LOG_LEVEL |
info |
pino log level |
docker compose up --builddocker-compose.yml passes the USB printer device through to the container.
Remove the devices: block and set DRY_RUN=1 to run without a printer.
Build (npm run build) on the host, then install the unit in
deploy/receiptprinter.service:
sudo cp deploy/receiptprinter.service /etc/systemd/system/
sudo systemctl enable --now receiptprinternpm run dev # watch-mode server
npm test # vitest
npm run typecheck # tsc --noEmit
npm run lint # biomeThermal printers speak ESC/POS. The
service renders each receipt as: initialize (ESC @), the normalized text, the
bottom padding lines, then a feed-and-cut (GS V A). All of that lives in
src/escpos.ts as pure, unit-tested functions, so the rendering is identical
whether it comes from the HTTP service or the render CLI.
ISC © Dan Driscoll