Python standard library only — no framework, no dependencies. HTTP handler + SQLite queue.
client apps printpapi server agent (remote machine)
┌──────────────┐ POST /jobs ┌────────────────┐ long-poll ┌───────────────┐
│ n8n, scripts │ ────────────▶ │ HTTP handler │ ◀───────────── │ registers its │
│ your backend │ │ SQLite queue │ GET /agent/ │ printers, │ ──▶ printer
│ │ ◀──────────── │ (WAL + reaper)│ ─ job+bytes ─▶ │ polls, prints │ (USB / :9100
└──────────────┘ GET /jobs/id └────────────────┘ ◀── result ─── │ raw or pdf │ / CUPS)
│ └───────────────┘
GET / dashboard
- An agent registers its printers, then long-polls
GET /agent/jobs. - A client submits a job (
POST /jobs); the server decodes the payload and queues it. - The agent receives the job, downloads the bytes, prints, reports the result.
- The agent connects outbound only — no inbound ports on the printer's machine.
Environment variables:
| Var | Default | Meaning |
|---|---|---|
PRINTAPI_TOKEN |
(required) | Bootstrap/admin token — treat like a password |
PRINT_DB |
printpapi.db |
SQLite database path |
PRINT_PORT |
3460 |
Listen port |
LOG_REQUESTS |
(off) | Set to log every HTTP request |
PRINTAPI_TOKEN=change-me PRINT_DB=data/printpapi.db python -m app.serverPull the prebuilt image (published to GHCR on every release, amd64 + arm64):
docker run -e PRINTAPI_TOKEN=change-me -p 3460:3460 \
-v $PWD/data:/app/data -e PRINT_DB=/app/data/printpapi.db \
ghcr.io/lukiprince/printpapiThe -v + PRINT_DB keep the SQLite DB (jobs and issued API keys) on the host, so
recreating the container doesn't wipe them. docker compose up -d does the same with a named
volume — see docker-compose.yml.
Build it yourself instead: docker build -t printpapi . then run the same command with
printpapi in place of the ghcr.io/... image.
Image is python:3.12-slim + cups-client. The server itself needs no Python packages.
GET / serves a static, secret-free single page: printers online/offline, job history,
one-click test print, API-key management, agent install instructions. You paste the token
once; it's kept in localStorage and sent as a bearer header — nothing sensitive lives in
the HTML. The test print sends a PDF only to PDF-capable printers and a ZPL label to
everything else.
The bootstrap PRINTAPI_TOKEN is root: it can do everything, including issuing scoped
per-client keys (one per integration). Issued keys can submit and read jobs, but can't manage
keys. Keys are stored SHA-256-hashed; revoking one cuts access immediately.
curl -s -X POST localhost:3460/apikeys \
-H 'Authorization: Bearer <PRINTAPI_TOKEN>' -H 'Content-Type: application/json' \
-d '{"label":"n8n"}'
# -> {"id":1,"label":"n8n","key":"<shown once — store it>"}SQLite in WAL mode with an atomic claim. A visibility-timeout reaper requeues jobs whose agent went silent and fails them after a bounded number of retries. Reaper errors are logged to stderr.
Enforced at every trust boundary: bearer auth on every endpoint, constant-time token compare,
SHA-256-hashed agent and client keys, http(s)-only URL fetches, 32 MB request-body cap,
no shell=True, agent temp-file cleanup, and job payloads scoped so an agent can only touch
its own jobs.
The server speaks plain HTTP — put your own reverse proxy / TLS in front for anything beyond the LAN. Vulnerability reports: see SECURITY.md.