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

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

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

FieldTypeDescription
contentarray required Ordered list of content blocks. See below.
printerstring optional Target printer name. Falls back to the default printer selected in the app.
paperWidthnumber 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).
cutboolean optional Cut the paper after printing. Default true.
referencestring 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:

TypePrintsReach 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 paperBlock that made it
MY STORE text · center · bold · large
Jl. Sudirman 123 text · center
----------------------divider
Nasi Goreng x2 50.000row
Es Teh x2 10.000row
----------------------divider
TOTAL 60.000row · 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

FieldTypeDescription
textstringrequiredThe text to print.
alignstringoptional"left" (default), "center", "right".
boldbooleanoptionalBold text. Default false.
underlinebooleanoptionalUnderlined text. Default false.
sizestringoptional"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.

FieldTypeDescription
leftstringrequiredLeft column text.
rightstringrequiredRight column text.
boldbooleanoptionalBold the row. Default false.
{ "type": "row", "left": "TOTAL", "right": "60.000", "bold": true }

divider — a horizontal line

FieldTypeDescription
charstringoptionalSingle character repeated across the line. Default "-".
{ "type": "divider" }   // or { "type": "divider", "char": "=" }

feed — blank lines

FieldTypeDescription
linesnumberoptionalNumber of blank lines to feed. Default 1.
{ "type": "feed", "lines": 3 }

qr — QR code

Generated natively by the printer.

FieldTypeDescription
datastringrequiredThe content to encode (URL, text, etc.).
sizenumberoptionalModule (dot) size, 1–16. Default 6.
alignstringoptional"left" (default), "center", "right".
{ "type": "qr", "data": "https://pay.me/inv/123", "size": 6, "align": "center" }

barcode — 1D barcode

FieldTypeDescription
datastringrequiredBarcode content (digits for EAN13; alphanumeric for CODE39/128).
formatstringrequired"EAN13", "CODE128", or "CODE39".
alignstringoptional"left" (default), "center", "right".
heightnumberoptionalBar height in dots. Default 60.
{ "type": "barcode", "data": "1234567890123", "format": "EAN13" }

image — raster image

FieldTypeDescription
base64stringrequiredBase64-encoded PNG or JPEG. No data-URL prefix needed.
alignstringoptional"left" (default), "center", "right".
ditherbooleanoptionalFloyd–Steinberg dithering for photos. Default true. Set false for crisp logos / line art.
{ "type": "image", "base64": "iVBORw0KGgo...", "align": "center" }

Responses

StatusMeaningBody
200Printed successfully{ "status": "printed", "jobId": "…" }
400Invalid JSON or unknown content type{ "error": "…", "detail": "…" }
503Printing 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.

CodeMeaningWhat resolves it
bluetooth_permission_missingThe Bluetooth permission was deniedGrant it in Cleanter
bluetooth_disabledBluetooth is switched offTurn Bluetooth on
printer_not_connectedNo printer is selected in CleanterSelect a paired printer
printer_not_pairedThe selected printer is no longer pairedRe-pair it in Android settings
printer_unreachableThe printer did not respond, including after a reconnectCheck 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.

POST /jobs

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.

GET /jobs/{id}
{
  "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 ?? ""}`);
  }
}