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.
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 path | Auth | What 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
| Field | Meaning |
|---|---|
name | Raw name from the printer, e.g. "Black Toner Cartridge". |
level | Raw value as reported. For most waste cartridges this is percent full. |
remaining | Normalised percent remaining. Use this one for bars and text. |
status | ok low critical |
color | black, cyan, magenta, yellow, none. |
supplytype | toner, drum, waste, maintenance. |
iswaste, isdrum | Convenience flags; a waste cartridge is usually worth showing differently. |
partnumbers | Order codes from the modelsupplies table. Empty until someone fills them in for that model. |
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://shopdb.example.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://shopdb.example.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://shopdb.example.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
- Show the supplies that need action as rows, and collapse the healthy ones into a single muted line. The endpoint returns every supply for a listed printer, so this is the client's call: a colour laser reports six, and rendering all six buries the empty one. Keep the healthy summary rather than dropping it - a cartridge at 12% is not flagged today but is the same walk.
- Chain the poll with
setTimeoutin afinally, notsetInterval: a slow or hung request cannot then stack up behind itself. - Keep the last good render when a poll fails. A display that blanks on one dropped request is worse than one showing five-minute-old numbers.
- Distinguish "nothing is low" from "nothing was checked". They both look like an empty list, and only one of them is good news.
- The board reloads itself if you serve it as a ShopDB display; otherwise add
a daily
location.reload()so a deployed fix reaches a screen nobody touches.