Print receipts from a web app to a Bluetooth printer.
Your web app can't reach the shop's Bluetooth receipt printer on its own. A free Android app on the same tablet can. Here's the setup and the code.
The shop has a Bluetooth receipt printer, paired to the tablet at the till. But the cashier app runs in a browser, and Chrome can't see the printer. Web Bluetooth only talks to a newer kind of printer that almost nobody in Indonesia has. Safari on iPad doesn't speak Bluetooth at all.
The usual fixes: a paid bridge app with a watermark, a printer with a network cable the shop doesn't have, or "just print from the cashier's laptop". We hit this on our clients' retail projects so often that we built Cleanter, a free Android app that sits between the browser and the printer.
Cleanter runs on the tablet itself.
Your web app sends the receipt to Cleanter on the same Android tablet, and Cleanter prints it over Bluetooth. No cloud, no driver, no account.
Setup takes three steps, once per tablet.
- Pair the printer in Android's Bluetooth settings, the way you'd pair headphones.
- Install Cleanter from Google Play and open it.
- Pick the printer from the list and tap Start Server. After that, Cleanter starts itself on every boot.
That's it. Shop staff never open the app again.
One request prints the receipt.
A receipt is a list of blocks: text, a two-column row, a divider, a QR code, a barcode, an image or a paper feed. Your app sends that list as JSON:
await fetch("http://localhost:9100/print", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: [
{ type: "text", text: "KOPI SUDIRMAN", align: "center", bold: true, size: "large" },
{ type: "text", text: "Jl. Sudirman 123, Medan", 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://example.com/inv/123" },
{ type: "feed", lines: 3 },
],
}),
});
That's the entire integration. Cleanter turns the blocks into the printer's own commands, fits the paper width (80 mm or 58 mm) and cuts the paper.
Nasi Goreng x250.000Es Teh x210.000
TOTAL60.000
A green dot shows the printer is ready.
When the cashier opens a shift, ask the bridge if it's running and connected:
const res = await fetch("http://localhost:9100/health", {
signal: AbortSignal.timeout(1500),
});
const status = await res.json();
Show the answer as a green or grey dot on the cashier screen. Staff learn to trust it within a day. On an https page, run this check from a tap, never on page load, and allow more than 1.5 seconds the first time: the cashier may be answering Chrome's prompt.
GET /healthThree things trip people up.
Your app is on https. From Chrome 150, an https page needs the cashier's permission to reach localhost. Make the first request from a tap, such as the Print button: Chrome asks once, and the print goes through when the cashier taps Allow. On Android the prompt says the site wants to access other apps and services on this device. A request on page load is refused without asking, and the refusal sticks. Cleanter handles the rest of the handshake.
Power cuts. The tablet reboots, Bluetooth comes back and the printer reconnects on the first job. Nobody has to open anything.
Pages you don't control. Cleanter also shows up as a printer in Chrome's own print dialog. A supplier's invoice page prints at 80 mm, with no code at all.
Our clients' web-based tills print through Cleanter, and more than 2,000 people now use it. It turned "which printer should we buy?" into "keep the one you have". If your cashier app needs to print and you're not sure how, ask us. It's usually a five-minute answer.
