Printer supplies API

Reference for the toner and supply endpoints in ShopDB, and a working single-page dashboard you can lift straight out of this file.

Where the numbers come from. ShopDB does not poll printers itself. Levels are read from Zabbix by IP address, so every endpoint below returns empty results unless zabbix_enabled, zabbix_url and zabbix_token are set under Settings > Integrations. That failure is deliberately soft: you get 200 with an empty list, never an error, so a wall dashboard does not blow up when Zabbix is down.

Endpoints

Method and pathAuthWhat it returns
GET /api/printers/lowsupplies optional Every printer with at least one supply below threshold, plus a summary. The toner report. Cached 5 minutes.
GET /api/printers/<printerid>/supplies optional Live levels for one printer, read from Zabbix on request. Not cached.
POST /api/printers/supplies/refresh required + printers.create Clears the cache so the next read is fresh. Backs the report's Refresh button.
GET /api/printers/supplies/meta optional Allowed supply types, colors and capacity tiers.
GET /api/printers/models/<modelnumberid>/supplies optional Cartridge part numbers for a model.
GET /api/printers/lookup?ip= or ?fqdn= optional Resolve an IP or hostname to a printer id.

"optional" means the endpoint accepts a token but does not need one: a logged-out page can read it. Only the refresh endpoint requires a token, and the account behind it needs the printers.create permission.

Response shape

Every ShopDB response is wrapped. Your data is under data:

{
  "status": "success",
  "data": { ... },
  "meta": { "requestid": "6d9d0bb6", "timestamp": "2026-08-11T13:47:41.250953Z" }
}

GET /api/printers/lowsupplies

{
  "printers": [
    {
      "printerid": 42,
      "printername": "Printer-10-129-22-15",
      "assetnumber": "PRN-0042",
      "ipaddress": "10.129.22.15",
      "vendor": "Xerox",
      "model": "AltaLink C8145",
      "location": "Building 1 - Cell 4",
      "supplies": [
        {
          "name": "Cyan Toner Cartridge",
          "level": 8,
          "remaining": 8.0,
          "status": "low",
          "color": "cyan",
          "supplytype": "toner",
          "iswaste": false,
          "isdrum": false,
          "partnumbers": ["006R01737"]
        }
      ]
    }
  ],
  "summary": { "total_checked": 37, "low": 4, "critical": 1 }
}

Only printers with something below threshold appear in printers. total_checked counts every printer Zabbix answered for, so "37 checked, 5 listed" means 32 are healthy. A printer is counted once in the summary, as critical if any of its supplies is critical, otherwise as low.

GET /api/printers/<printerid>/supplies

{
  "ipaddress": "10.129.22.15",
  "pingstatus": "1",
  "supplies": [ /* same supply objects as above */ ]
}

pingstatus is Zabbix's reachability value, "-1" when Zabbix is unconfigured or unreachable. This endpoint hits Zabbix on every call, so poll it for one printer on a detail view, not for forty on a dashboard - use lowsupplies for that.

The supply object

FieldMeaning
nameRaw name from the printer, e.g. "Black Toner Cartridge".
levelRaw value as reported. For most waste cartridges this is percent full.
remainingNormalised percent remaining. Use this one for bars and text.
statusok low critical
colorblack, cyan, magenta, yellow, none.
supplytypetoner, drum, waste, maintenance.
iswaste, isdrumConvenience flags; a waste cartridge is usually worth showing differently.
partnumbersOrder codes from the modelsupplies table. Empty until someone fills them in for that model.
Do not compute status from level yourself. Thresholds are remaining <= 5 critical, <= 10 low. The catch is direction: a full waste cartridge is bad, so for waste ShopDB converts to 100 - level - except on Xerox, which already reports waste as capacity remaining. That vendor rule is why remaining and status exist. Read them, do not re-derive them.

Before you build

Base path

Instances are served under a subpath, so the API is at https://tsgwp00525.wjs.geaerospace.net/shopdb/api/... - production on this server - or /ops/api/... for the dev instance beside it, not at the domain root. Make the base a variable; do not hardcode /api.

Same origin, or CORS

The simplest deployment is to drop your HTML file into the instance's web root so it is served from the same origin - then fetch just works. A page served from anywhere else is a cross-origin request, and the browser will block it unless that origin is in the server's CORS_ORIGINS allowlist (an env var; production refuses to start with a wildcard).

Caching and polling

lowsupplies is cached for 5 minutes server-side, so polling every 30 seconds gets you the same payload nine times out of ten and buys nothing. Poll every 2 to 5 minutes. If you need a manual Refresh button, call the refresh endpoint first - that one needs a token.

Minimal example

const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb';   // no trailing /api

async function lowSupplies() {
  const response = await fetch(`${BASE}/api/printers/lowsupplies`);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const body = await response.json();
  return body.data;                                  // { printers, summary }
}

lowSupplies().then(data => {
  console.log(`${data.summary.critical} critical, ${data.summary.low} low`);
  for (const printer of data.printers) {
    for (const supply of printer.supplies) {
      if (supply.status === 'ok') continue;
      console.log(`${printer.printername}: ${supply.name} ${supply.remaining}%`);
    }
  }
});

Polling, with the failure cases handled

const BASE = 'https://tsgwp00525.wjs.geaerospace.net/shopdb';
const POLL_MS = 3 * 60 * 1000;      // server caches 5 min; faster buys nothing

async function tick() {
  try {
    const response = await fetch(`${BASE}/api/printers/lowsupplies`, {
      headers: { 'Accept': 'application/json' }
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const { data } = await response.json();

    // Zabbix off or unreachable: 200 with nothing in it. Say so, rather than
    // rendering "0 low" as if every printer were healthy.
    if (data.summary.total_checked === 0) {
      render.unavailable();
    } else {
      render.board(data);
    }
  } catch (err) {
    // A wall display must survive a blip: keep the last good render.
    console.error('supply poll failed', err);
    render.stale(err);
  } finally {
    setTimeout(tick, POLL_MS);      // chained, so a slow response cannot pile up
  }
}
tick();

Refreshing on demand (needs a token)

Create a Personal Access Token under Settings > API Tokens for an account with printers.create. Treat it as a credential: a token embedded in a page anyone can open is readable by anyone who opens it, so use this on an operator page behind login, not on a lobby display.

async function refreshAndReload(token) {
  await fetch(`${BASE}/api/printers/supplies/refresh`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` }
  });
  return lowSupplies();            // next read pulls fresh values from Zabbix
}

One printer, live

// Skips the cache and asks Zabbix now. Fine for a detail page, wrong for a loop.
async function printerSupplies(printerid) {
  const response = await fetch(`${BASE}/api/printers/${printerid}/supplies`);
  const { data } = await response.json();
  if (data.pingstatus === '-1') return { offline: true, supplies: [] };
  return data;
}

// Have an IP but not an id? Resolve it first.
async function printerIdByIp(ip) {
  const response = await fetch(`${BASE}/api/printers/lookup?ip=${encodeURIComponent(ip)}`);
  const { data } = await response.json();
  return data?.printerid ?? null;
}

Live dashboard

The section below is the real thing, running in this page. Point it at an instance and it will render. Served from a different origin than the API, it will fail on CORS - that is the browser doing its job, and the fix is to host the file on the instance itself.

Enter a base URL and press Load.

Notes for a wall display