🌐 Language: English · Tiếng Việt
Analog ghosts on a digital wire. Register, connect a printer, and fax your friends. If their printer is online it prints instantly; if not, it queues and prints the moment the printer comes back. No app to install — just a browser.
A terminal / CRT / hacker-vibe web app. Python backend (FastAPI + WebSocket), vanilla-JS frontend. A fax prints on the recipient's physical thermal printer via any of three paths — their browser (WebUSB), a printer wired into the server host (local bridge), or a headless agent on their own Raspberry Pi (printer node, authenticated with a device token).
flowchart LR
S["sender<br/>POST /api/fax"] --> F(["FAXXME server<br/>FastAPI"])
F -- "WebSocket push" --> B["recipient's browser<br/>WebUSB → printer"]
F -- "host local bridge" --> H["host printer<br/>/dev/usb/lp0"]
F -- "their Pi agent · device token" --> A["Pi node printer<br/>/dev/usb/lp0"]
F -. "offline" .-> Q[("SQLite queue")]
Q -. "flush on reconnect / hot-replug" .-> F
| The console | Recipient search | Printed-receipt view |
|---|---|---|
![]() |
![]() |
![]() |
| status bar · compose · inbox/outbox | fuzzy find, online-first | click a fax → paper slip |
- Accounts — register/login, pbkdf2 password hashing + hmac-signed session cookies (no native deps).
- Compose — searchable recipient picker, 200-char message, optional image, live char counter.
- Three print paths
- Browser WebUSB — server builds the ESC/POS bytes, the recipient's browser forwards them raw to the USB printer (auto re-binds on hot-replug, no click).
- Local bridge — a printer wired into the server host prints server-side, no browser needed.
- Printer node (agent) — a headless agent on the recipient's own Raspberry Pi, authenticated with a device token, prints faxes locally.
- Device tokens — per-account API token for the agent (sha256-hashed, shown once); regenerate to revoke instantly (the connected agent is kicked).
- Webhook integration — let any external site fax you (e.g. a blog's comment box) via a per-author secret key and
POST /api/fax/inbound, no account needed for the sender. See Webhook integration. - Live printer status — the PRINTER pill shows the best available path (
ONLINEbrowser USB ·NODE ✓agent ·WIREDbridge ·OFFLINE) and updates in real time; TEST prints a test page on whichever you have. - Unicode text — lines with Vietnamese, emoji, or anything the printer's code page can't show are auto-rendered with a bundled font as a crisp
GS v 0raster; pure-ASCII lines stay fast native ESC/POS text. - Image attachments — Floyd–Steinberg dithered to 1-bit halftone (
GS v 0raster), with a live client-side preview. - Offline queue — undelivered faxes wait in SQLite and flush when the recipient (browser/agent) reconnects, or when the host printer is hot-replugged (background watcher); the sender's outbox flips
queued → printedlive. - Printed-receipt modal — click any fax to see it as a paper slip (torn edges, dithered image).
- Housekeeping — clear inbox/outbox (only your side; the other party keeps their copy), auto-cap at 50 per side, can't fax yourself.
- Configurable auto-cut — full / feed-to-cutter / partial / none.
/healthz— liveness probe for Docker / systemd / uptime checks.
- Presence = a live WebSocket. With the console tab open you're online: faxes are pushed to you instantly and friends see your green dot.
- Send (
POST /api/fax). If the recipient is online, the server pushes the fax over their WebSocket; their browser writes the ESC/POS bytes to the USB printer and acks. If they're offline, the fax is queued in SQLite. - Delivery on return. Queued faxes flush when the recipient reconnects (browser or Pi
agent), or — for the host's wired printer — when the background watcher sees the device
reappear (polls every
FAXXME_PRINTER_POLLseconds; covers unplug/replug without a restart). - Printer node = another WebSocket client. The agent authenticates with a device token,
connects the same
/ws, and writes the pushed ESC/POS bytes to its local printer — so "others print to me" works with zero extra server logic (presence, queue, acks all reused). - Single source of truth. ESC/POS is built server-side (
faxxme/printer.py); the browser forwards raw bytes over WebUSB, and the local bridge / agent write the same bytes to/dev.
Deep-dive docs live in docs/:
- How it works — architecture, delivery model, watcher, imaging, tokens, admin.
- Printer compatibility — supported thermal printers, widths, auto-cut.
- Platform notes — WebUSB gotchas on Ubuntu / macOS / Windows.
- Webhook integration — let any site fax you (blog comments, apps): inbound API, samples, security, secret-key management.
- Printer node / agent — run FaxxMe on your own Pi (device token, no browser).
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
# listen on all interfaces; make the host's wired printer belong to callsign "pi"
FAXXME_LOCAL_USER=pi FAXXME_PRINTER_DEV=/dev/usb/lp0 ./run.sh
# or: FAXXME_LOCAL_USER=pi .venv/bin/python -m faxxme
# -> http://<host>:8000All configuration is via environment variables:
| var | default | meaning |
|---|---|---|
FAXXME_HOST / FAXXME_PORT |
0.0.0.0 / 8000 |
bind address |
FAXXME_LOG_LEVEL |
info |
uvicorn log level |
FAXXME_LOCAL_USER |
(unset) | callsign whose faxes print on THIS host's printer (enables the local bridge) |
FAXXME_PRINTER_DEV |
/dev/usb/lp0 |
printer device node for the local bridge |
FAXXME_PRINTER_POLL |
4 |
seconds between printer hot-replug checks |
FAXXME_BRIDGE_MAX_ATTEMPTS |
3 |
give up (and mark delivered) after this many failed writes to a flaky printer, so it can't reprint forever — see docs/printers.md (Power & USB) |
FAXXME_WRITE_DELAY / FAXXME_WRITE_CHUNK |
0 / 4096 |
pause (s) between / size (bytes) of chunks written to the printer device; raise the delay to pace a printer whose buffer can't keep up |
FAXXME_CUT |
full |
end-of-fax cut: full / feed (feed-to-cutter) / partial / none |
FAXXME_WIDTH |
32 |
text columns (58mm ≈ 32, 80mm ≈ 48) |
FAXXME_PRINT_DOTS |
384 |
image raster width in dots (58mm ≈ 384, 80mm ≈ 576) |
FAXXME_IMG_MAX_H |
1200 |
max printed image height (dots) |
FAXXME_MAX_UPLOAD |
6291456 |
max image upload size (bytes, 6 MB compressed) |
FAXXME_MAX_PIXELS |
24000000 |
max decoded image pixels (w×h); larger is rejected before decode (bomb guard) |
FAXXME_FAX_RATE_MAX / FAXXME_FAX_RATE_WINDOW |
20 / 60 |
per-sender rate limit: max faxes per N seconds (0 = off) |
FAXXME_WEBHOOK_RATE_MAX / FAXXME_WEBHOOK_RATE_WINDOW |
5 / 300 |
inbound webhook rate limit, enforced per author and per calling-site IP (0 = off) |
FAXXME_WEBHOOK_MSG_MAX |
500 |
max characters in an inbound webhook message |
FAXXME_ADMIN_PASSWORD_HASH |
(unset) | sha256 hash of the /admin password; unset = admin disabled |
FAXXME_FONT |
bundled Play (Google Fonts) | TTF used to render non-ASCII text (Vietnamese, emoji…) |
FAXXME_FONT_SIZE |
26 |
font size for rendered Unicode text |
FAXXME_FONT_THRESHOLD |
176 |
black/white cutoff for rendered text (higher = darker) |
FAXXME_FOOTER_FONT_SIZE |
22 |
font size for the small webhook attribution footer (name/post/url) |
FAXXME_DB / FAXXME_SECRET |
in repo | sqlite + session-secret paths |
| method | path | purpose |
|---|---|---|
| POST | /api/register · /api/login · /api/logout |
auth (form fields) |
| GET | /api/me |
current user + printer_online, local_bridge, node_online, has_token, webhook_secret |
| GET | /api/users |
other operators + online flags |
| POST | /api/fax |
send (multipart: to, body, optional image) |
| GET | /api/inbox · /api/outbox |
fax history (newest 50) |
| POST | /api/inbox/clear · /api/outbox/clear |
clear your side |
| GET | /api/fax/{id}/image |
the dithered PNG (sender/recipient only) |
| POST | /api/token/regenerate |
issue a device token (shown once); revokes + kicks the old one |
| POST | /api/webhook/regenerate · /api/webhook/revoke |
mint / revoke a webhook secret key (re-viewable in the panel) — see Webhook integration |
| POST | /api/fax/inbound |
public webhook — any site posts a message that prints as a fax (auth via secret key, not a session) |
| POST | /api/test-print |
print a test page on your node/bridge |
| POST | /api/admin/login · /api/admin/logout |
admin session (password → signed cookie; separate from user auth) |
| GET | /admin · /api/admin/* |
admin panel: paginated users + faxes, delete, revoke tokens, stats (admin cookie only) |
| WS | /ws |
presence + live delivery + status/node pushes; auth via session cookie (browser) or Authorization: Bearer <token> + X-Faxxme-User (agent) |
| GET | /healthz |
{status, printer_bridge} |
| GET | / |
the single-page CRT console |
Let any external site fax you — for example straight from a blog's comment box (e.g. lazyblog). The end sender doesn't need a FaxxMe account — the site authenticates on their behalf with your secret key. It's a plain webhook: anyone holding the secret can POST a message that prints on your printer.
📖 Full guide: docs/webhook.md — sample requests (PHP/Python/Node), security, secret-key management, administration, and troubleshooting.
How it fits together
flowchart LR
R["visitor<br/>comment box"] -->|"POST (same-origin)"| B["your site server<br/>(e.g. blog plugin)"]
B -->|"POST /api/fax/inbound<br/>Authorization: Bearer fxwh_…"| F(["FAXXME server"])
F --> P["your printer"]
The site calls FaxxMe server-side, not from the visitor's browser. That keeps the secret key hidden, needs no CORS, and lets the site add its own per-visitor checks (captcha, its own rate limit) before forwarding.
Set up (author): log in → :: WEBHOOK INTEGRATION → GENERATE SECRET KEY. The key (fxwh_…) shows masked — click the eye to reveal, copy to copy (it stays viewable in the panel). Hand it to whoever runs the site, to store server-side (e.g. in the site's .env). The ↻ icon rotates it (old key dies instantly); revoke turns the webhook off entirely.
Scope & safety: a secret key can only deliver a fax to the author who owns it — there's no recipient field to target anyone else. Inbound faxes are rate-limited per author and per calling-site IP (FAXXME_WEBHOOK_RATE_MAX/FAXXME_WEBHOOK_RATE_WINDOW), messages are capped at FAXXME_WEBHOOK_MSG_MAX, control characters are stripped before printing, and they print immediately (fire-and-forget) attributed to the reserved @webhook sender. The per-author (secret) limit is the primary guard; the per-IP limit is reliable only behind a trusted reverse proxy. Being spammed? Revoke the key.
POST /api/fax/inbound — Content-Type: application/x-www-form-urlencoded, header Authorization: Bearer <secret key>:
| field | required | notes |
|---|---|---|
body |
✅ | the message (≤ FAXXME_WEBHOOK_MSG_MAX chars) |
name |
– | sender's name (≤ 40) — printed as attribution |
post |
– | source title, e.g. a post title (≤ 120) |
url |
– | source URL (≤ 200) |
There's no IP field to spoof — FaxxMe takes the client IP from the connection (via the reverse-proxy headers). Behind a trusted proxy (Cloudflare/Caddy) that's reliable; a directly-reachable origin could spoof it, so treat per-IP as best-effort. That IP is your calling server anyway, so add your own per-visitor throttle. Full details in docs/webhook.md.
Returns { "ok": true, "fax_id": …, "delivered": bool }. Errors: 401 (missing/invalid secret), 400 (empty/too-long message), 429 (rate-limited).
Caller side (PHP snippet, e.g. for a blog plugin):
<?php
// Forward a comment to FaxxMe as a fax. Runs server-side; the secret never reaches the browser.
$faxxme = 'https://fax.hatrunghieu.com';
$secret = getenv('FAXXME_SECRET_KEY'); // fxwh_… , kept out of version control
$ch = curl_init("$faxxme/api/fax/inbound");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $secret"],
CURLOPT_POSTFIELDS => http_build_query([
'body' => $_POST['message'] ?? '',
'name' => $_POST['name'] ?? '',
'post' => $postTitle,
'url' => $postUrl,
]),
CURLOPT_TIMEOUT => 10,
]);
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); // 200 ok · 429 too fast · 401 bad secret
curl_close($ch);Curl equivalent for a quick test:
curl -X POST https://fax.hatrunghieu.com/api/fax/inbound \
-H "Authorization: Bearer fxwh_XXXX" \
--data-urlencode "body=great post!" \
--data-urlencode "name=A reader" \
--data-urlencode "post=My First Fax" \
--data-urlencode "url=https://blog.example/first"Browsers only expose navigator.usb on HTTPS or localhost, and only Chromium-based
browsers support it at all (no Safari/Firefox). Also, on macOS/Windows the OS claims
class-compliant USB printers, so they won't appear in the WebUSB picker. Options:
- Local bridge / printer node (simplest) — a printer wired into the server host, or the recipient's own Pi running the agent, prints server-side with no WebUSB at all. That's why the Pi's printer "just works".
- Tailscale HTTPS — real certs for the whole tailnet:
(undo:
sudo tailscale serve --bg http://localhost:8000 # → https://<host>.<tailnet>.ts.netsudo tailscale serve reset) - Chrome flag for LAN testing:
chrome://flags/#unsafely-treat-insecure-origin-as-secure.
On Linux clients the kernel usblp driver may hold the printer: sudo modprobe -r usblp
first (this disables the host's own local bridge though). Full per-OS guidance:
docs/platforms.md.
The local bridge writes to /dev/usb/lp* (owned root:lp). deploy/install.sh installs a
udev rule (/etc/udev/rules.d/99-faxxme-printer.rules, group lp, mode 0666) and adds the
run user to the lp group so the server can print without root — and so the device is writable
again automatically after a replug.
docker compose up -d --build # build + start on http://<host>:8000
docker compose logs -f
docker compose downDB + session secret persist in the faxxme-data volume. Browser/WebUSB printing works out of
the box; to print on the container host's wired printer, set FAXXME_LOCAL_USER and uncomment
the devices + group_add block in docker-compose.yml. USB hotplug is awkward in containers —
for a host-attached printer the systemd deploy is smoother.
Can't bind a printer through the browser (macOS/Windows) — or just want a dedicated,
always-on printer? Run the agent on a Raspberry Pi with the printer attached. It signs
in with your callsign + a device token (web UI: :: PRINTER NODE → GENERATE TOKEN,
regenerate to revoke) and prints every fax addressed to you — no browser needed.
sudo agent/install.sh
sudoedit agent/faxxme-agent.env # set FAXXME_SERVER, callsign, token
sudo systemctl restart faxxme-agentFull guide: agent/README.md.
The /admin panel is completely separate from user accounts — it's gated by a single
password whose sha256 hash you put in FAXXME_ADMIN_PASSWORD_HASH (leave it unset to disable
/admin entirely). No admin user, no extra DB table. Generate the hash and run:
# sha256 of your chosen admin password
python3 -c "import hashlib;print(hashlib.sha256(b'my-admin-pass').hexdigest())"
FAXXME_ADMIN_PASSWORD_HASH=<that hash> ./run.shThen open /admin, unlock with the password (its own signed session cookie), and you get a
terminal-styled control room to:
- see live stats (operators, online now, transmissions, queued/delivered, images);
- browse operators (paginated, 20/page) with sent/received counts, the last session (IP + User-Agent, Cloudflare/proxy-aware, with a last-seen kept live by a heartbeat) + online/node/token status, revoke a device token, or delete a user (a tombstone — the account is anonymized and can't log in, but its faxes are kept for the other party and the callsign is freed);
- browse/search all transmissions (paginated, 20/page), view any one as a printed paper slip (full text + image), and delete any fax (both sides).
Confirmations and the message viewer reuse the console's own terminal-styled modal. The API under
/api/admin/* checks the admin cookie server-side (401 without it), so the page itself is
harmless to serve.
sudo deploy/install.sh # venv + deps, printer udev rule, systemd unit
systemctl status faxxme
journalctl -u faxxme -f # logsSee deploy/README.md for stop/start/config/uninstall.
.venv/bin/python -m pytest tests/ -qCovers /healthz, auth + validation, offline queue → WebSocket flush → ack, immediate
local-bridge print, queue flush on printer reconnect, image dithering + raster + access
control, per-side clear, the 50-message cap, self-fax rejection, message-length limit,
anonymous-WebSocket rejection, device-token auth + revocation (incl. bad-token reject),
the node-online indicator, test-print routing to the agent, Unicode-body raster
rendering, and the per-sender rate limit.
faxxme/__main__.py `python -m faxxme` — the uvicorn daemon entrypoint
faxxme/app.py FastAPI app: auth, fax routing, presence, WS delivery, printer watcher, tokens
faxxme/db.py SQLite (stdlib) — users (+ device-token hash) + faxes (+ dithered image BLOB)
faxxme/auth.py pbkdf2 passwords + hmac session cookies + device tokens (no native deps)
faxxme/printer.py ESC/POS receipt builder + auto-cut + local /dev printer bridge
faxxme/imaging.py image → halftone raster + Unicode text → crisp raster (Pillow)
faxxme/fonts/ bundled Play font (renders Vietnamese/emoji)
static/ CRT terminal UI (index.html, style.css, app.js — WebUSB + WebSocket)
agent/ printer-node agent for a Raspberry Pi (faxxme_agent.py, systemd, install)
tests/test_api.py end-to-end tests
deploy/ systemd unit, udev rule, env, install/uninstall scripts
Dockerfile · docker-compose.yml


