HTTP API Reference
Cleanter exposes an HTTP API on http://localhost:9100:
/health to check readiness, /print to print
and wait, and /jobs to queue with retries. All requests
and responses are application/json.
GET /health
Returns the app version and current printer status. Use it on page load to detect whether Cleanter is installed and a printer is ready.
Response 200
{
"status": "ok",
"version": "1.1.0",
"printer": {
"connected": true,
"name": "RPP02N",
"address": "00:11:22:33:44:55"
}
}
When no printer is connected, printer.connected is
false, name is omitted, and
printer.problem carries the reason — one of the
error codes below, or null
if nothing is blocking printing. Polling this lets you warn the
cashier before they ring up a sale:
{
"status": "ok",
"version": "1.2.0",
"printer": {
"connected": false,
"address": "00:11:22:33:44:55",
"problem": "bluetooth_disabled"
}
}
POST /print
The request body is a JSON object. The only required field is
content — an ordered array of
content blocks printed top to bottom.
Request fields
| Field | Type | Description | |
|---|---|---|---|
content | array | required | Ordered list of content blocks. See below. |
printer | string | optional | Target printer name. Falls back to the default printer selected in the app. |
paperWidth | number | optional | 80 or 58 mm — sets line width to 48 or 32 characters. When omitted, the width configured in the app's Settings is used (80 unless changed). |
cut | boolean | optional | Cut the paper after printing. Default true. |
reference | string | optional | Your own label for the job — an order number, a table, a customer name. Shown in Cleanter's job list so a failed receipt can be identified and reprinted from the tablet. Never printed; truncated at 64 characters. Available from v1.5.0. |
Content blocks
content is an ordered array of blocks.
Each block prints one piece of the receipt, top to bottom, in the
order you list them. Every block is an object with a
"type" field — you pick the type per line, based on what
that line is.
There are seven types. Most receipts only need the first four:
| Type | Prints | Reach for it when… |
|---|---|---|
text |
A line of text — align, bold, underline, size | store name, headings, address, any freeform line |
row |
Two columns: left edge + right edge on one line | line items, totals — any “label … value” pair |
divider |
A full-width horizontal rule | separating sections of the receipt |
feed |
Blank vertical space | spacing, or pushing paper out before the cut |
qr |
A QR code | payment links, e-receipt URLs, feedback forms |
barcode |
A 1D barcode | SKUs, product codes, order / tracking numbers |
image |
A raster image from base64 | a logo or any custom graphic |
Anatomy of a receipt
To make it concrete — here's how an everyday receipt maps to blocks, line by line. This whole thing is built from just four types:
| What prints on paper | Block that made it |
|---|---|
| MY STORE | text · center · bold · large |
| Jl. Sudirman 123 | text · center |
| ---------------------- | divider |
| Nasi Goreng x2 50.000 | row |
| Es Teh x2 10.000 | row |
| ---------------------- | divider |
| TOTAL 60.000 | row · bold |
feed |
As JSON, that receipt is simply those blocks in order — see the full example below, or build one live in the interactive builder. The rest of this section is the exact fields each block accepts.
text — a line of text
| Field | Type | Description | |
|---|---|---|---|
text | string | required | The text to print. |
align | string | optional | "left" (default), "center", "right". |
bold | boolean | optional | Bold text. Default false. |
underline | boolean | optional | Underlined text. Default false. |
size | string | optional | "small", "normal" (default), "large". |
{ "type": "text", "text": "STORE NAME", "align": "center", "bold": true, "size": "large" }
row — two columns
Prints left flush-left and right flush-right
on one line, padded to the paper width. Ideal for line items and totals.
| Field | Type | Description | |
|---|---|---|---|
left | string | required | Left column text. |
right | string | required | Right column text. |
bold | boolean | optional | Bold the row. Default false. |
{ "type": "row", "left": "TOTAL", "right": "60.000", "bold": true }
divider — a horizontal line
| Field | Type | Description | |
|---|---|---|---|
char | string | optional | Single character repeated across the line. Default "-". |
{ "type": "divider" } // or { "type": "divider", "char": "=" }
feed — blank lines
| Field | Type | Description | |
|---|---|---|---|
lines | number | optional | Number of blank lines to feed. Default 1. |
{ "type": "feed", "lines": 3 }
qr — QR code
Generated natively by the printer.
| Field | Type | Description | |
|---|---|---|---|
data | string | required | The content to encode (URL, text, etc.). |
size | number | optional | Module (dot) size, 1–16. Default 6. |
align | string | optional | "left" (default), "center", "right". |
{ "type": "qr", "data": "https://pay.me/inv/123", "size": 6, "align": "center" }
barcode — 1D barcode
| Field | Type | Description | |
|---|---|---|---|
data | string | required | Barcode content (digits for EAN13; alphanumeric for CODE39/128). |
format | string | required | "EAN13", "CODE128", or "CODE39". |
align | string | optional | "left" (default), "center", "right". |
height | number | optional | Bar height in dots. Default 60. |
{ "type": "barcode", "data": "1234567890123", "format": "EAN13" }
image — raster image
| Field | Type | Description | |
|---|---|---|---|
base64 | string | required | Base64-encoded PNG or JPEG. No data-URL prefix needed. |
align | string | optional | "left" (default), "center", "right". |
dither | boolean | optional | Floyd–Steinberg dithering for photos. Default true. Set false for crisp logos / line art. |
{ "type": "image", "base64": "iVBORw0KGgo...", "align": "center" }
Responses
| Status | Meaning | Body |
|---|---|---|
200 | Printed successfully | { "status": "printed", "jobId": "…" } |
400 | Invalid JSON or unknown content type | { "error": "…", "detail": "…" } |
503 | Printing is blocked — see the error codes below | { "error": "printer_unreachable", "detail": "…", "fix": "…" } |
Error codes
Every 503 names the specific reason and carries a
fix sentence written for the person standing at the
counter. You can show fix verbatim instead of writing
your own copy for each case.
| Code | Meaning | What resolves it |
|---|---|---|
bluetooth_permission_missing | The Bluetooth permission was denied | Grant it in Cleanter |
bluetooth_disabled | Bluetooth is switched off | Turn Bluetooth on |
printer_not_connected | No printer is selected in Cleanter | Select a paired printer |
printer_not_paired | The selected printer is no longer paired | Re-pair it in Android settings |
printer_unreachable | The printer did not respond, including after a reconnect | Check power, range, and paper |
printer_not_connected and
printer_unreachable predate the others and keep their
original meaning, so existing integrations continue to work. The
fix field is additive — older versions of Cleanter simply
omit it.
Queued printing
POST /print waits for the printer and answers with the
outcome. It makes exactly one attempt and is never retried in the
background — if the cashier was told it failed, they have already
pressed print again, and a background retry would hand the customer a
second receipt.
When you would rather the bridge keep trying — the printer is off,
out of paper, or out of range — send the same body to
POST /jobs instead. Available from v1.3.0.
Response 202
{
"status": "queued",
"jobId": "3f0c1a52-…",
"statusUrl": "/jobs/3f0c1a52-…"
}
The job is retried with a backoff running to roughly 19 minutes, and
is given up on 30 minutes after submission. It survives the app being
killed and the tablet rebooting, and drains the moment the printer
answers again. Receipts print in the order you submitted them.
413 if the rendered receipt exceeds 4 MB.
{
"jobId": "3f0c1a52-…",
"status": "queued",
"attempts": 2,
"maxAttempts": 10,
"nextAttemptAt": 1753500061000,
"error": "printer_unreachable",
"detail": "The printer did not respond…",
"fix": "Check the printer is on, in range, and has paper…",
"canRetry": true
}
status is one of queued,
printing, printed, failed.
Timestamps are epoch milliseconds. error uses the same
error codes as a 503, so you
can render a queued failure with the copy you already have.
404 for an unknown id.
GET /jobs lists recent jobs, newest first
(?limit= up to 50, ?status=failed), and
POST /jobs/{id}/retry re-queues a failed one:
409 job_already_active if it is already going,
410 payload_expired once the stored receipt
has passed its 24-hour retention.
Jobs from POST /print also appear in
GET /jobs, so the jobId you already get
back is now something you can look up. Send a
reference with every job: it is what makes a failed
receipt identifiable in Cleanter's own job list, where the
operator can reprint it without your app's help.
Full example
A complete receipt, sent with curl:
curl -X POST http://localhost:9100/print \
-H "Content-Type: application/json" \
-d '{
"cut": true,
"content": [
{ "type": "text", "text": "MY STORE", "align": "center", "bold": true, "size": "large" },
{ "type": "text", "text": "Jl. Sudirman 123", "align": "center" },
{ "type": "divider" },
{ "type": "row", "left": "Nasi Goreng x2", "right": "50.000" },
{ "type": "row", "left": "Es Teh x2", "right": "10.000" },
{ "type": "divider" },
{ "type": "row", "left": "TOTAL", "right": "60.000", "bold": true },
{ "type": "qr", "data": "https://pay.me/inv/123", "size": 6, "align": "center" },
{ "type": "feed", "lines": 3 }
]
}'
TypeScript helper
Drop this into a Next.js / React project:
const BRIDGE_URL = "http://localhost:9100";
// Returns true if Cleanter is running on this device.
export async function isBridgeAvailable(): Promise<boolean> {
try {
const res = await fetch(`${BRIDGE_URL}/health`, {
signal: AbortSignal.timeout(1500),
});
return res.ok;
} catch {
return false;
}
}
export async function printReceipt(content: unknown[]): Promise<void> {
const res = await fetch(`${BRIDGE_URL}/print`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cut: true, content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`Print failed: ${res.status} ${err.detail ?? ""}`);
}
}