Skip to content

Printer setup in your POS

This guide adds a Printers page to your POS settings: the shop connects the POS to the bridge, picks which printer gets receipts, kitchen tickets and invoices, and prints a test page. No code changes are needed when the shop adds or swaps printers.

Using React?

The SDK has this page ready-made: <PrinterSettings /> and a header <PrinterStatusBadge /> from pos-hardware-bridge/react. See React printer widget. This guide explains how it works, for other frameworks or your own UI.

It comes as two files you can copy into your app:

File What it is
printing.ts The printing module: saved settings, connect, pair, print, live status. No UI, works with any framework.
PrinterSettings.tsx A React settings page built on it. Plain HTML, so restyle it with your own components.
npm install pos-hardware-bridge

What the shop sees

Printers
┌─ 1. Connect to POS Hardware Bridge ───────────────────────────┐
│ (•) Printers are connected to this computer                    │
│ ( ) Printers are connected to another computer in the shop     │
│ Access token [••••••••••••••••]  [Connect]                     │
│ Connected to Front desk PC.                                    │
└────────────────────────────────────────────────────────────────┘
┌─ 2. Choose printers ───────────────────────────────────────────┐
│ Customer receipts  [Default printer        ▾]  [Test print]    │
│ Kitchen tickets    [Kitchen (not ready)    ▾]  [Test print]    │
│ A4 invoices        [Don't print            ▾]  [Test print]    │
└────────────────────────────────────────────────────────────────┘
┌─ 3. Options ───────────────────────────────────────────────────┐
│ [x] Print the receipt automatically after each sale            │
│ Copies [1]                                                     │
└────────────────────────────────────────────────────────────────┘
flowchart LR
  A["Open Settings → Printers"] --> B{"Printers on<br/>this computer?"}
  B -- Yes --> C["Paste the access token"]
  B -- No --> D["Type the bridge address<br/>and the 6-digit code"]
  C --> E["testConnection()"]
  D --> F["pairThisDevice()"] --> E
  E --> G["Choose a printer per role"] --> H["Test print"]

What gets saved

The settings belong to the device, not the shop account: every computer has its own token, and a till and a tablet may use different printers. The module saves them in localStorage; use your app's own storage instead if you have one (Electron store, secure storage on mobile). Never put the token in your code or your database.

interface PrintSettings {
  url: string;                                   // http://127.0.0.1:9191, or the shop address on a tablet
  token: string;                                 // access token or device token
  printers: Record<'receipt' | 'kitchen' | 'invoice', string | null>;
                                                 // API name per role: '' = default printer, null = don't print
  autoPrint: boolean;
  copies: number;                                // 1–5
}

Saving '' (the default printer) instead of a fixed name means the shop can swap printers in the bridge without touching the POS.

Connecting

There are two cases, and the page offers both:

  • The printers are on this computer. The shop opens the bridge, Connect your POS, copies the access token and pastes it in. The address is always http://127.0.0.1:9191.
  • The printers are on another computer (a tablet or second till). On the printer computer, they turn on Phones & tablets and click Get a code. The POS calls pairThisDevice(address, code, name) and saves the device token it gets back. See Phones & tablets.

testConnection() then checks three things in order, so the error says what is wrong:

  1. GET /health: is the bridge running at that address?
  2. GET /me: is the token accepted?
  3. GET /printers: which printers can this POS choose from?

Errors are BridgeErrors whose message is written for the shop, for example "POS Hardware Bridge is not running on this computer. Open it from the Start menu or the tray." Show it as it is.

Web POS on https://

A hosted web POS can reach http://127.0.0.1 from Chrome and Edge, but not a http://192.168.… address on another computer. For that case, use the print station instead of pairing.

Printing from the POS

Call printFor(role, receipt) wherever the POS prints. It reads the saved settings, sends the receipt to that role's printer and never throws, so a printer problem can't break a sale:

import { ReceiptBuilder } from 'pos-hardware-bridge';
import { loadSettings, printFor } from './printing';

async function onSaleCompleted(sale: Sale) {
  await saveSale(sale);                                // 1. the sale is safe first

  if (!loadSettings().autoPrint) return;
  const result = await printFor('receipt', buildReceipt(sale), { title: `Bill #${sale.number}` });

  if (!result.ok && !result.skipped) {
    showToast(result.message, {
      // canRetry is false when the bridge didn't answer: the receipt may
      // already have printed, so ask the cashier before printing again.
      action: { label: 'Reprint', confirm: !result.canRetry, run: () => printFor('receipt', buildReceipt(sale)) },
    });
  }

  await printFor('kitchen', buildKitchenTicket(sale)); // skipped if set to "Don't print"
}

function buildReceipt(sale: Sale) {
  const r = new ReceiptBuilder().center('MY SHOP', { bold: true, size: 'large' }).rule();
  for (const line of sale.lines) r.item({ name: line.name, qty: line.qty, total: line.total });
  return r.rule('double').total('TOTAL', sale.total);
}

result is one of:

Result Meaning Show
{ ok: true, printer } Printed Nothing, or a short "Printed"
{ ok: false, skipped: true } Not set up, or this role is Don't print Nothing
{ ok: false, message, canRetry } Didn't print message and a Reprint button

A4 invoices go through printInvoice(html) the same way. See Receipts, line by line for everything ReceiptBuilder can do.

Printer status in the POS header

watchPrinterStatus() gives a traffic light and one line of text, so the cashier sees an unplugged printer before a customer is waiting:

import { useEffect, useState } from 'react';
import { type PrinterStatusSummary, watchPrinterStatus } from './printing';

const COLORS = { ok: 'green', warning: 'orange', error: 'red', off: 'grey' };

export function PrinterStatusBadge() {
  const [status, setStatus] = useState<PrinterStatusSummary>({ light: 'off', text: 'Checking printers…' });
  useEffect(() => watchPrinterStatus(setStatus), []);
  return (
    <span title={status.text}>
      <span style={{ color: COLORS[status.light] }}>●</span> {status.text}
    </span>
  );
}

It listens to the bridge's live events and also checks every 10 seconds, so it recovers by itself when the bridge restarts.

The code

// printing.ts: one place for everything your POS knows about printing.
import { BridgeClient, BridgeError, ReceiptBuilder } from 'pos-hardware-bridge';
import type { PrinterInfo, ReceiptDocument } from 'pos-hardware-bridge';

/** What each kind of print is for. Add your own roles here. */
export type PrintRole = 'receipt' | 'kitchen' | 'invoice';

export interface PrintSettings {
  /** http://127.0.0.1:9191 on the printer computer, the shop address on a tablet. */
  url: string;
  /** Access token (printer computer) or device token (from pairing). */
  token: string;
  /** Printer API name per role. '' = the bridge's default; null = don't print. */
  printers: Record<PrintRole, string | null>;
  /** Print the customer receipt as soon as a sale is saved. */
  autoPrint: boolean;
  copies: number;
}

const STORAGE_KEY = 'pos.printing';

export const DEFAULT_SETTINGS: PrintSettings = {
  url: 'http://127.0.0.1:9191',
  token: '',
  printers: { receipt: '', kitchen: null, invoice: '' },
  autoPrint: true,
  copies: 1,
};

// ---- Saved settings (per device, never in your code or database) ----

export function loadSettings(): PrintSettings {
  try {
    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null');
    return saved
      ? { ...DEFAULT_SETTINGS, ...saved, printers: { ...DEFAULT_SETTINGS.printers, ...saved.printers } }
      : DEFAULT_SETTINGS;
  } catch {
    return DEFAULT_SETTINGS;
  }
}

export function saveSettings(settings: PrintSettings): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}

export function isConfigured(settings = loadSettings()): boolean {
  return settings.token !== '';
}

function clientFor(settings: PrintSettings): BridgeClient {
  return new BridgeClient({ url: settings.url, token: settings.token });
}

// ---- Connecting ----

export interface Connection {
  bridgeName: string;
  version: string;
  /** 'local' = this is the printer computer; 'device' = a paired tablet or till. */
  kind: 'local' | 'device';
  printers: PrinterInfo[];
}

/**
 * Check the address and token work, and load the printer list.
 * Throws BridgeError with a message you can show as it is.
 */
export async function testConnection(url: string, token: string): Promise<Connection> {
  const bridge = new BridgeClient({ url, token, timeoutMs: 5000 });
  const health = await bridge.health(); // no token: is it running?
  const me = await bridge.me(); // token: is it accepted?
  const printers = await bridge.printers();
  return {
    bridgeName: health.bridge?.name ?? 'POS Hardware Bridge',
    version: health.bridge?.version ?? '',
    kind: me.kind,
    printers,
  };
}

/** A tablet or till: trade the 6-digit code for this device's own token. */
export async function pairThisDevice(url: string, code: string, deviceName: string) {
  const paired = await BridgeClient.pair({ url, code, name: deviceName });
  return { url: paired.url, token: paired.token };
}

// ---- Printing ----

export type PrintOutcome =
  | { ok: true; printer: string }
  | { ok: false; skipped: true }
  | { ok: false; skipped?: false; message: string; canRetry: boolean };

/**
 * Print to the printer chosen for this role. Never throws: a printer problem
 * must not break a sale. Show `message` and a Reprint button when !ok.
 */
export async function printFor(
  role: PrintRole,
  receipt: ReceiptBuilder | ReceiptDocument,
  options: { title?: string; copies?: number } = {},
): Promise<PrintOutcome> {
  const settings = loadSettings();
  const printer = settings.printers[role];
  if (!isConfigured(settings) || printer === null) return { ok: false, skipped: true };
  try {
    const result = await clientFor(settings).printReceipt(receipt, {
      printer: printer || undefined,
      copies: options.copies ?? settings.copies,
      title: options.title,
    });
    return { ok: true, printer: result.printer };
  } catch (error) {
    if (error instanceof BridgeError) {
      // 0 = no answer: it may or may not have printed, so ask before reprinting.
      return { ok: false, message: error.message, canRetry: error.status !== 0 };
    }
    return { ok: false, message: 'Printing failed.', canRetry: true };
  }
}

/** A4 invoice from HTML, to the printer chosen for 'invoice'. */
export async function printInvoice(html: string, title?: string): Promise<PrintOutcome> {
  const settings = loadSettings();
  const printer = settings.printers.invoice;
  if (!isConfigured(settings) || printer === null) return { ok: false, skipped: true };
  try {
    const result = await clientFor(settings).printDocument({ html }, { printer: printer || undefined, title });
    return { ok: true, printer: result.printer };
  } catch (error) {
    const message = error instanceof BridgeError ? error.message : 'Printing failed.';
    return { ok: false, message, canRetry: !(error instanceof BridgeError && error.status === 0) };
  }
}

export async function printTestPage(role: PrintRole): Promise<PrintOutcome> {
  const settings = loadSettings();
  const printer = settings.printers[role];
  if (printer === null) return { ok: false, skipped: true };
  try {
    const result = await clientFor(settings).printTestPage(printer || undefined);
    return { ok: true, printer: result.printer };
  } catch (error) {
    return { ok: false, message: error instanceof Error ? error.message : 'Printing failed.', canRetry: true };
  }
}

// ---- Live status for the POS header ----

export type PrinterLight = 'ok' | 'warning' | 'error' | 'off';

export interface PrinterStatusSummary {
  light: PrinterLight;
  /** One line for the cashier, e.g. "Front counter: out of paper". */
  text: string;
}

function summarize(status: { printers: Array<{ level: PrinterLight; title: string; name: string }> }): PrinterStatusSummary {
  const worst =
    status.printers.find((p) => p.level === 'error') ??
    status.printers.find((p) => p.level === 'warning') ??
    status.printers.find((p) => p.level === 'off');
  if (status.printers.length === 0) return { light: 'warning', text: 'No printers set up in the bridge' };
  return worst ? { light: worst.level, text: `${worst.name}: ${worst.title}` } : { light: 'ok', text: 'Printers ready' };
}

/**
 * Calls `onChange` now and whenever a printer's status changes. Also checks
 * every 10 s, so it recovers when the bridge restarts. Returns a function
 * that stops watching.
 */
export function watchPrinterStatus(onChange: (summary: PrinterStatusSummary) => void): () => void {
  const settings = loadSettings();
  if (!isConfigured(settings)) {
    onChange({ light: 'off', text: 'Printing not set up' });
    return () => {};
  }
  const bridge = clientFor(settings);
  let stopped = false;
  let stopListening = () => {};
  let retry: ReturnType<typeof setTimeout> | undefined;

  const connect = async () => {
    try {
      onChange(summarize(await bridge.status()));
      stopListening = bridge.onStatus((payload) => onChange(summarize(payload as any)));
    } catch (error) {
      onChange({ light: 'error', text: error instanceof BridgeError ? error.message : 'Bridge not reachable' });
    }
  };
  // Poll every 10 s as a safety net: covers a dropped socket or a bridge restart.
  const poll = async () => {
    if (stopped) return;
    try {
      onChange(summarize(await bridge.status()));
    } catch (error) {
      onChange({ light: 'error', text: error instanceof BridgeError ? error.message : 'Bridge not reachable' });
    }
    retry = setTimeout(poll, 10_000);
  };

  void connect().then(() => (retry = setTimeout(poll, 10_000)));
  return () => {
    stopped = true;
    clearTimeout(retry);
    stopListening();
  };
}
// PrinterSettings.tsx: the "Printers" page in your POS settings.
import { useEffect, useState } from 'react';
import type { PrinterInfo } from 'pos-hardware-bridge';
import {
  type Connection,
  type PrintRole,
  type PrintSettings,
  loadSettings,
  pairThisDevice,
  printTestPage,
  saveSettings,
  testConnection,
} from './printing';

const ROLES: Array<{ role: PrintRole; label: string; kind: PrinterInfo['kind'] }> = [
  { role: 'receipt', label: 'Customer receipts', kind: 'receipt' },
  { role: 'kitchen', label: 'Kitchen tickets', kind: 'receipt' },
  { role: 'invoice', label: 'A4 invoices', kind: 'document' },
];

export function PrinterSettings() {
  const [settings, setSettings] = useState<PrintSettings>(loadSettings);
  const [connection, setConnection] = useState<Connection | null>(null);
  const [message, setMessage] = useState('');
  const [busy, setBusy] = useState(false);
  const [mode, setMode] = useState<'this-computer' | 'pair'>(
    settings.url.includes('127.0.0.1') || settings.url.includes('localhost') ? 'this-computer' : 'pair',
  );
  const [code, setCode] = useState('');
  const [deviceName, setDeviceName] = useState('');

  const update = (next: PrintSettings) => {
    setSettings(next);
    saveSettings(next);
  };

  const connect = async (url: string, token: string) => {
    setBusy(true);
    setMessage('');
    try {
      const result = await testConnection(url, token);
      setConnection(result);
      update({ ...settings, url, token });
      setMessage(`Connected to ${result.bridgeName}.`);
    } catch (error) {
      setConnection(null);
      setMessage(error instanceof Error ? error.message : String(error));
    } finally {
      setBusy(false);
    }
  };

  const pair = async () => {
    setBusy(true);
    setMessage('');
    try {
      const { url, token } = await pairThisDevice(settings.url, code, deviceName || 'POS device');
      setCode('');
      await connect(url, token);
    } catch (error) {
      setMessage(error instanceof Error ? error.message : String(error));
      setBusy(false);
    }
  };

  // Already set up: load the printer list when the page opens.
  useEffect(() => {
    if (settings.token) void connect(settings.url, settings.token);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <section>
      <h2>Printers</h2>

      {/* Step 1: connect */}
      <fieldset disabled={busy}>
        <legend>1. Connect to POS Hardware Bridge</legend>
        <label>
          <input type="radio" checked={mode === 'this-computer'} onChange={() => setMode('this-computer')} />
          Printers are connected to this computer
        </label>
        <label>
          <input
            type="radio"
            checked={mode === 'pair'}
            onChange={() => {
              setMode('pair');
              setSettings({ ...settings, url: '' }); // the shop address, typed below
            }}
          />
          Printers are connected to another computer in the shop
        </label>

        {mode === 'this-computer' ? (
          <>
            <p>In the bridge, open <b>Connect your POS</b> and copy the access token.</p>
            <input
              type="password"
              placeholder="Access token"
              value={settings.token}
              onChange={(e) => setSettings({ ...settings, url: 'http://127.0.0.1:9191', token: e.target.value.trim() })}
            />
            <button onClick={() => connect('http://127.0.0.1:9191', settings.token)}>Connect</button>
          </>
        ) : (
          <>
            <p>
              On the printer computer, open <b>Connect your POS → Phones &amp; tablets</b>, turn on network
              access and click <b>Get a code</b>.
            </p>
            <input
              placeholder="Bridge address, e.g. http://192.168.1.20:9191"
              value={settings.url}
              onChange={(e) => setSettings({ ...settings, url: e.target.value.trim() })}
            />
            <input placeholder="6-digit code" inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} />
            <input placeholder="Name for this device, e.g. Till 2" value={deviceName} onChange={(e) => setDeviceName(e.target.value)} />
            <button onClick={pair} disabled={!code}>Pair</button>
          </>
        )}
        {message && <p role="status">{message}</p>}
      </fieldset>

      {/* Step 2: choose printers */}
      {connection && (
        <fieldset>
          <legend>2. Choose printers</legend>
          {ROLES.map(({ role, label, kind }) => (
            <div key={role}>
              <label>
                {label}{' '}
                <select
                  value={settings.printers[role] ?? 'none'}
                  onChange={(e) =>
                    update({
                      ...settings,
                      printers: { ...settings.printers, [role]: e.target.value === 'none' ? null : e.target.value },
                    })
                  }
                >
                  <option value="">Default printer</option>
                  {connection.printers
                    .filter((p) => p.kind === kind)
                    .map((p) => (
                      <option key={p.id} value={p.id}>
                        {p.name} {p.ready ? '' : '(not ready)'}
                      </option>
                    ))}
                  <option value="none">Don't print</option>
                </select>
              </label>
              <button
                disabled={settings.printers[role] === null}
                onClick={async () => {
                  const result = await printTestPage(role);
                  setMessage(result.ok ? `Test page sent to ${result.printer}.` : 'message' in result ? result.message : '');
                }}
              >
                Test print
              </button>
            </div>
          ))}
          {connection.printers.length === 0 && <p>No printers yet: add one in the bridge, then reopen this page.</p>}
        </fieldset>
      )}

      {/* Step 3: options */}
      {connection && (
        <fieldset>
          <legend>3. Options</legend>
          <label>
            <input
              type="checkbox"
              checked={settings.autoPrint}
              onChange={(e) => update({ ...settings, autoPrint: e.target.checked })}
            />
            Print the receipt automatically after each sale
          </label>
          <label>
            Copies{' '}
            <input
              type="number"
              min={1}
              max={5}
              value={settings.copies}
              onChange={(e) => update({ ...settings, copies: Math.min(5, Math.max(1, Number(e.target.value) || 1)) })}
            />
          </label>
        </fieldset>
      )}
    </section>
  );
}

Other frameworks

printing.ts has no UI code. For Vue, Svelte, Angular or plain JavaScript, keep the module and build the same three steps with your own components:

  1. Connect: a token field (this computer) or address + code + device name (another computer) → testConnection() / pairThisDevice().
  2. Choose printers: one dropdown per role, filled from connection.printers (receipt printers for receipts and kitchen, A4 printers for invoices), plus Default printer and Don't print. Save with saveSettings().
  3. Options and a Test print button per role → printTestPage(role).

Not using JavaScript? The module is a thin layer over the HTTP API: the same calls work from any language.

Check before you ship

  • [ ] Wrong token → the page says the token wasn't accepted.
  • [ ] Bridge closed → the page says it isn't running.
  • [ ] Printer unplugged → the header badge turns red within a few seconds, and a sale still completes with a Reprint option.
  • [ ] Don't print on kitchen tickets → no request is sent.
  • [ ] Printers renamed or swapped in the bridge → Default printer still works.
  • [ ] A tablet pairs with a code, and printing still works after the app restarts.