LABERTY Privacy Terms Help Center

Laberty API

Everything Laberty can do from another program: list the printers on your network, print to them, add households, and email a batch of labels.

The point of the printing endpoints is that your other tools don't need their own printer stack. Laberty already runs a bridge on your network (see the print bridge); anything you write can hand it bytes and let it deal with drivers, sockets and retries.

  • Base URL: https://laberty.app
  • All paths below are relative to /api
  • Everything is JSON, Content-Type: application/json

Authenticating

Every request needs a bearer token:

Authorization: Bearer <token>

API keys (use these)

Make keys in Laberty under Admin → API keys, one per program. They look like lbk_ followed by 40 characters.

  • Shown once. Laberty stores only a SHA-256 hash, so a key cannot be shown again. If you lose one, revoke it and make another.
  • They never expire. A key that quietly lapses inside another app is an outage nobody sees coming. Access ends when you revoke the key, and that takes effect on the very next request.
  • Scoped. Each key is for one job:
ScopeForCan call
Print bridgeThe Docker bridge on your network/agent/* and /agent/v1/* only
PrintingYour own apps that printGET /printers, POST /print, GET /print/{id}, GET /agent/status (or /agent/v1/status)
Full accessAnything elseEvery endpoint except /device/*
ScreenA Laberty Screen by the printer (made on Admin → Devices)/device/* only. Nothing else can call /device/*

A scope limits a key. It never goes past the plan: bridge calls need any paid plan, and everything else needs Home (20 API actions a month) or Business (unlimited). See Limits.

The Print bridge page can make a bridge key for you and fills it into the Docker commands.

Older tokens

TokenScopeWhere it comes from
Workspace tokenOne workspace, everythingDomain console → Manage → Access
Deployment tokenThe primary workspaceThe SKILL_API_TOKEN secret

These keep working for anything already set up with them. New programs should use a key, because a key can be revoked without breaking anything else.

Responses

  • 401 Unauthorized: the token is missing, unknown or revoked.
  • 403 Forbidden: the key's scope does not cover that endpoint, or the workspace's plan does not include it. The error field says which. The deployment token is never gated this way.

A key is a password. Treat it like one: keep it out of client-side code and out of git.


The print bridge

your program ──HTTPS──> Laberty (cloud) <──HTTPS── bridge (your network) ──TCP/CUPS──> printer

Laberty cannot reach your printers, by design — nothing on the internet should. So printing is a queue: you add a job, the bridge picks it up within a few seconds, prints it, and reports the result back.

That means POST /print returning 201 means queued, not printed. Poll GET /print/{id} if you need to know it actually came out.

Where a job goes is decided by the printer's record in Laberty — you type its IP address (or print-queue name) once, under Printers & stock, and every job for that printer carries that address. One bridge serves every printer on the network.

The bridge's own endpoints are frozen

Everything under /agent/* is also served at /agent/v1/* — the same handlers, mounted twice. /agent/v1/* is a frozen contract: inside v1 no field is ever removed, renamed, or changed in type, and new behaviour arrives only as new optional fields. That is what lets a customer install the bridge container once and leave it running for years while Laberty changes underneath it.

The full specification, including what the bridge must do to hold up its end, is docs/BRIDGE-PROTOCOL-V1.md. You only need it if you are writing your own bridge; to print from your own program, use POST /print below and let the bridge deal with printers.

The bridge also tells Laberty what it is — its version, what protocols it can drive, and whether it is running in a container. Laberty then never hands a bridge a job it cannot do: such a job waits in the queue with a reason instead of failing. A bridge that reports nothing is assumed to be able to do everything, so older bridges behave exactly as they always have.


Printing

GET /printers

Lists the workspace's printers: what type each is, what it accepts, and what Laberty believes is loaded in it.

curl https://laberty.app/api/printers \
  -H "Authorization: Bearer $LABERTY_API_KEY"
{
  "bridge": { "online": true, "lastSeenAt": "2026-09-13 14:02:11" },
  "printers": [
    {
      "id": 1,
      "name": "Kitchen QL-820NWB",
      "isDefault": true,
      "type": "label",
      "model": "Brother QL-820NWB",
      "brand": "Brother",
      "protocol": "agent-brother-ql",
      "accepts": ["pdf"],
      "host": "192.168.1.50",
      "port": 9100,
      "cupsQueue": null,
      "maxWidthIn": 2.4,
      "loadedStock": {
        "id": 3, "code": "2205", "name": "Continuous 2.4in roll (2205)",
        "kind": "continuous", "widthIn": 2.4, "heightIn": null, "dkPart": "DK-2205",
        "confirmed": true, "confirmedVia": "switch"
      }
    },
    {
      "id": 2,
      "name": "Front counter receipt",
      "isDefault": false,
      "type": "receipt",
      "model": "Epson TM-T88VII",
      "brand": "Epson",
      "protocol": "agent-escpos",
      "accepts": ["escpos", "raw"],
      "host": "192.168.1.50",
      "port": 9100,
      "cupsQueue": null,
      "maxWidthIn": 3.15,
      "loadedStock": null
    }
  ]
}
  • type is label, receipt or tape. Send label jobs to label printers and receipt jobs to receipt printers.
  • accepts lists the raw kind values that printer understands:
protocolacceptsHow it prints
agent-brother-qlpdfBrother raster to the printer's IP. The bridge checks the loaded roll first
agent-cupspdfThrough the bridge machine's print system (needs a driver): DYMO, Rollo USB, anything with a CUPS queue
agent-zplzpl, pdf, rawRaw bytes to the printer's IP, port 9100 by default. A pdf is drawn in the printer's dots by the bridge and sent as a ZPL graphic, so any PDF label prints on a Zebra
agent-escposescpos, rawSame, for receipt printers (escpos is an older spelling of the same thing)
agent-ipppdfIPP to the printer's IP, port 631 by default, with no driver: inkjets, lasers, AirPrint label printers. Only printers that accept PDF over IPP; see unsupported_format below
share-sheetpdfNot through the bridge: printed from a phone or tablet
  • dpi is the printer's density when Laberty knows it (300 for a ZD421-300, 203 for most thermal printers, null for an inkjet). A ZPL layout is measured in dots, so it matters there.
  • loadedStock.confirmed is true when Laberty knows for sure that this roll is in the printer. That is a physical act at the printer, on a Laberty device: a roll switch or selector, or a confirm button. Admin's Swap roll and POST /printers/{id}/loaded-stock with "confirmed": true confirm it only when the workspace allows them (Admin → Printers & stock → "Who can confirm a roll"); otherwise they record the roll as "not sure". Then any job that needs a different stock is refused before it is queued (see When the roll is confirmed). confirmedVia is device, switch, admin or api (screen on older rows), or null when not confirmed. Not confirmed ("not sure") means jobs are sent and the printer's own errors are reported, as always.
  • loadedStock.sheet appears when a sheet of labels is loaded (Avery and similar): columns, rows, perSheet, labelWidthIn, labelHeightIn, topIn, leftIn, hPitchIn, vPitchIn and page (inches, measured from the top-left of a Letter page).

POST /print

Queues one job. Say what to print in one of three ways:

FieldForNotes
labelLabel printers{ "lines": ["Jane Smith", "12 Oak St"], "align": "center", "bold": false }. Laberty renders plain, legible text at the exact size of the labels (see stock). Up to 20 lines, shrunk to fit. On a ZPL printer this is native ZPL at the printer's dpi (kind: "zpl"); on sheet stock it is placed on the sheet, with optional position and count (see Sheets of labels)
receiptReceipt printers{ "lines": [...], "cut": true, "openDrawer": false }. See below
kind + payloadB64 or textAnythingYour own bytes. kind is pdf, zpl, escpos or raw. text works for the text protocols

And optionally:

FieldNotes
printerIdDefaults to the workspace's default printer
stockThe labels this job is for. A stock id, its code ("2205", "5160"), its DK part number ("DK-2205") or its product number. If the printer has anything else loaded, the job is refused. See below
copies1–50, default 1
detailA short description, shown in the queue and the activity log

Header Idempotency-Key: send a unique value per real-world print (an order number, say). If your request times out and you retry with the same key, you get the first job back (200, "duplicate": true) instead of a second label.

# Three lines on the 2.4" roll -- refused if something else is loaded
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: food-1042" \
  -d '{
        "printerId": 1,
        "stock": "2205",
        "label": { "lines": ["Chili", "Made Sep 13", "Use by Sep 20"] }
      }'
{
  "jobId": 88,
  "status": "queued",
  "kind": "pdf",
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "stock": { "id": 3, "code": "2205", "name": "Continuous 2.4in roll (2205)", "kind": "continuous", "widthIn": 2.4, "heightIn": null, "dkPart": "DK-2205" },
  "statusUrl": "/api/print/88"
}

Receipts. Each item in lines is one of:

ItemPrints
"plain text"A left-aligned line, wrapped to the paper
{ "text": "TOTAL", "align": "center", "bold": true, "size": "big" }align: left, center, right. size: normal, tall, wide, big
{ "key": "Coffee", "value": "$3.00" }Coffee..............$3.00 across the full width
{ "rule": true }A full-width line of dashes (or "rule": "=")
{ "feed": 2 }Blank lines
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "printerId": 2,
        "receipt": {
          "lines": [
            { "text": "LIBANY", "align": "center", "size": "big" },
            { "rule": true },
            { "key": "Coffee", "value": "$3.00" },
            { "key": "TOTAL", "value": "$3.00" },
            "Thank you"
          ],
          "openDrawer": true
        }
      }'
# Your own bytes: ZPL as text, or a PDF you generated
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "printerId": 3, "kind": "zpl", "text": "^XA^FO20,20^A0N,30,30^FDORDER 1042^FS^XZ" }'

Labels on a Zebra

A label sent to an agent-zpl printer is written as ZPL, not as a picture: 4in labels at 300 dpi are ^PW1200, each line is centred by the printer, and non-ASCII text is sent as UTF-8 (^CI28). It is sized to the loaded stock (stock, or what is recorded as loaded); with nothing recorded, it is as wide as the print head allows (up to 4in) and as long as the text needs. The job's kind is zpl.

A pdf job for a ZPL printer works too: the bridge draws it at the printer's dpi and sends it as a ^GFA graphic, turning it a quarter turn if it is wider than the print head. That needs a bridge image built from the current agent folder.

Sheets of labels (Avery)

When the labels are a sheet (stock.kind is sheet), a label is placed on the sheet at the template's exact position and the job is a Letter-size PDF. Print it at 100%: the bridge asks IPP printers for that (print-scaling: none) when they support it.

FieldNotes
label.position (or label.startPosition, or top-level position / startPosition)Which label on the sheet to start at, 1 to perSheet, counted left to right then top to bottom. Default 1. Use it to finish a half-used sheet
label.countHow many copies of this label to place (1–500). They carry on onto further sheets. Default 1. (copies still prints the whole job that many times)
# A sheet of return-address labels, starting after the 12 already used
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "printerId": 4, "stock": "5167", "label": { "lines": ["Ada Lovelace", "1428 Rosemont Ave"], "count": 68, "position": 13 } }'

A position outside the sheet is a 400 with code: "bad_position". Laberty knows Avery 5160/8160, 5161, 5162, 5163, 5164, 5167, 5195, 22805 and 8126 by number; an admin adds them under Printers & stock → Add from catalog, which also offers a printable alignment sheet.

When the wrong labels are loaded

Pass stock and Laberty compares it with what is recorded as loaded before anything is rendered or queued:

409
{
  "code": "stock_mismatch",
  "error": "Kitchen QL-820NWB has Continuous 2.4in roll (2205) loaded, but this job is for Large Address (208). Nothing was printed. Swap the labels, update what is loaded, and send the job again.",
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "loaded": { "id": 3, "code": "2205", "...": "..." },
  "requested": { "id": 2, "code": "208", "...": "..." }
}

The workspace's admins are emailed at the same time ("Load Large Address (208) in Kitchen QL-820NWB"), at most once per printer and label every half hour. Show your user the error text, or build your own message from loaded and requested.

Laberty's record can be out of date: someone swaps a roll by hand, or prints from the Brother app. So before it sends anything, the bridge asks the printer what is physically loaded, three ways, stopping at the first real answer:

  1. IPP Get-Printer-Attributes on port 631 — media-ready and media-col-ready (the exact size, in hundredths of a millimetre).
  2. SNMP on UDP 161 — the standard Printer MIB's input tray.
  3. Brother's ESC i S status request on port 9100, last, because some QL firmware ignores it over the network.

If the printer answers and it disagrees, the job is never sent: it is reported stock_mismatch and waits (see GET /print/{id}), the printer is left untouched rather than pushed into an error state, and the admins get the same kind of email. If the printer answers and agrees, that answer is also what Laberty records as loaded, confirmed — the printer outranks anything a person or an app said.

Each question has a 2.5-second limit and the answer is cached for 30 seconds, so a batch of labels asks once. A printer that answers none of the three is printed to exactly as before, so for such a printer keep Laberty's loaded stock accurate (Printers & stock, or POST /printers/{id}/loaded-stock) — that record is then the only protection, along with the question Laberty asks whoever presses Print when it is not sure.

When the roll is confirmed

If the printer's loadedStock.confirmed is true, Laberty refuses any job that needs a different stock, whether or not you pass stock. Without stock, a pdf whose page is exactly one of the workspace's die-cut label sizes needs that stock. A label is sized for the loaded roll, so it always fits. Raw zpl, escpos and raw need nothing in particular.

409
{
  "code": "stock_mismatch",
  "confirmed": true,
  "error": "Kitchen QL-820NWB has Continuous 2.4in roll (2205) confirmed loaded, but this job needs Large Address (208). Nothing was printed. ...",
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "loaded": { "id": 3, "name": "Continuous 2.4in roll (2205)", "via": "switch" },
  "needed": { "id": 2, "name": "Large Address (208)" }
}

With stock passed, the body is the one above plus confirmed, needed and loaded.via. When the roll isn't confirmed, confirmed is false.

A job queued earlier is checked again when the bridge picks it up. If a different roll has been confirmed since, the job is never sent. It fails with errorCode: "stock_mismatch" and an error starting not sent:, and the admins are emailed. The bridge moves on to the next job. (Occasion labels, and labels someone released from Ready to print, wait as held instead.)

If the printer itself then reports stock_mismatch on a confirmed roll, Laberty clears the confirmation and emails the admins.

Errors

StatuscodeMeaning
400unknown_stockNo label stock matches stock. GET /label-stock lists them
400bad_positionA sheet position that is not a whole number from 1 to the labels per sheet
400(none)Missing or invalid body, payload, base64 or lines, or a workspace with no printers
404(none)Unknown printerId
409stock_mismatchDifferent labels are loaded (above). confirmed: true when that roll is confirmed, even if you didn't pass stock
409stock_unknownNothing is recorded as loaded in that printer. Set it, then retry
413(none)Payload over about 1 MB. A label is a few KB
422wrong_kindThe job does not suit the printer: a receipt to a label printer, a PDF to a receipt printer. accepts is included
429(none)Over 60 jobs a minute (Retry-After header)

POST /printers/{id}/loaded-stock

Tells Laberty which labels are now in a printer, e.g. after your app asks someone to swap the roll. Body: { "stock": "208" } (id, code or DK part), plus "confirmed": true if your integration truly knows it. A confirmed roll refuses every job for another stock (409, above). Without confirmed, the roll is recorded as "not sure", even if it was confirmed before. A removed stock is never confirmed. Full access keys only, because it changes what every later job is checked against.

By default only a Laberty device at the printer can confirm a roll. Unless an admin has ticked Apps confirm it through the API (Admin → Printers & stock), "confirmed": true still records the stock, as not sure, and the answer says "confirmed": false with a confirmNote saying why.

{ "printer": { "id": 1, "name": "Kitchen QL-820NWB" }, "loadedStock": { "id": 2, "code": "208", "...": "...", "confirmed": true, "confirmedVia": "api" }, "confirmed": true }
{ "printer": { "id": 1, "name": "Kitchen QL-820NWB" }, "loadedStock": { "id": 2, "code": "208", "...": "...", "confirmed": false, "confirmedVia": null }, "confirmed": false,
  "confirmNote": "Recorded as not sure: this workspace confirms rolls only from a Laberty device at the printer. An admin can allow the API to confirm rolls under Admin → Printers & stock." }

GET /print/{id}

{
  "jobId": 88,
  "status": "failed",
  "kind": "pdf",
  "copies": 1,
  "printerId": 1,
  "detail": "API label",
  "source": "Libany",
  "error": "Wrong labels in \"Kitchen QL-820NWB\": it has 29 x 90mm die-cut labels, but this job needs the 62mm continuous roll. ...",
  "errorCode": "stock_mismatch",
  "queuedAt": "2026-09-13 14:02:11",
  "claimedAt": "2026-09-13 14:02:14",
  "completedAt": "2026-09-13 14:02:15"
}

status is one of queued, claimed, done, failed. source is the name of the API key that queued the job. When the job has failed, error says why in plain language, and errorCode is set when the cause is one your program can act on:

errorCodeWhat happenedWhat to tell the user
stock_mismatchThe printer reported different labels from the job's, or (with error starting not sent:) a different roll was confirmed loaded, so Laberty never sent it. When the printer itself refused it, the job's status becomes held with an error starting Waiting for — it prints when the right roll goes in, and nothing was sent to the printerLoad the right labels, confirm them, and print again
no_mediaThe printer is emptyLoad labels
printer_errorCover open, cutter jam, and so on (the text says which)Fix it at the printer
unreachableThe bridge could not reach the printerIs it on, and on the bridge's network?
no_addressThe printer has no IP address in LabertyAn admin needs to add it
unsupported_formatAn IPP printer does not accept PDF (it only takes PWG raster or Apple URF, as many AirPrint-only printers do)Print from a phone or tablet with the share sheet, or set the printer up as a CUPS queue on the bridge machine
nullAnything elseShow error

For the first three, nothing was printed and the admins were emailed. Every coded failure emails the admins, at most once per printer and problem every half hour.

On an IPP printer, no_media and printer_error come from what the printer reports (printer-state-reasons) when it is stopped: out of paper, a jam, a door open. Low ink or toner is only logged. A printer that is not stopped is sent the job even if it mentions a problem, since an empty second tray does not stop it printing. The bridge reports done once the printer accepts the job; IPP does not say when the paper comes out.

A job stuck on claimed means the bridge took it and then died. Once it has been claimed for 15 minutes, it goes back to queued the next time any bridge for the workspace asks for work (and a daily sweep catches the rest), so it prints when the bridge is back. Sending it again as well prints it twice.

GET /print

Recent jobs, newest first, without their payloads. ?status=failed&printerId=1&limit=25 (limit up to 100).

{ "jobs": [ { "jobId": 88, "status": "failed", "errorCode": "stock_mismatch", "source": "Libany", "...": "..." } ] }

GET /agent/status

Check the bridge is alive before queueing, so your own UI can say "printer offline" instead of silently piling up jobs.

{
  "queued": 0,
  "claimed": 0,
  "done": 214,
  "failed": 2,
  "lastAgentSeenAt": "2026-09-07 18:02:14",
  "agentLikelyOnline": true
}

agentLikelyOnline is true when the bridge claimed something in the last five minutes. It is a hint, not a guarantee — an idle bridge that has had no work for an hour reports false while being perfectly healthy.

GET /agent/v1/config

What the bridge should be doing, answered by Laberty rather than baked into the installed container: poll interval, timeouts, retry and backoff, log level, heartbeat, and feature switches. It means the installed bridge holds only three things — the Laberty URL, its key, and a cache of this — so its behaviour can be changed without anyone touching the machine it runs on.

{
  "configVersion": 1,
  "pollSeconds": 5,
  "requestTimeoutSeconds": 30,
  "jobTimeoutSeconds": 180,
  "heartbeatSeconds": 60,
  "configRefreshSeconds": 300,
  "retry": { "initialSeconds": 5, "maxSeconds": 300, "factor": 2, "jitter": 0.2 },
  "logLevel": "info",
  "features": {
    "probeMedia": true,
    "probeOrder": ["ipp", "snmp", "escp"],
    "snmp": true,
    "reportPrinters": true,
    "maxProbesPerPoll": 4
  },
  "minBridgeVersion": "1.0.0"
}
  • Every value is clamped, in Laberty and again in the bridge, so no setting can tell a bridge to poll every 0 seconds or wait forever on a socket. The worst a bad config can do is choose a different sane number.
  • The bridge caches the last good copy on disk and falls back to its built-in defaults — the same numbers — if this is unreachable or the answer is unusable. A bridge that has never once reached this endpoint prints perfectly well.
  • minBridgeVersion is informational and never enforced. Refusing an old bridge is exactly the breakage the contract exists to prevent.

POST /agent/printers/{id}/media

Print bridge keys only (a Full access key is refused with 403), and only for a printer in that key's own workspace (anything else is 404). This is how the bridge tells Laberty what a printer says is in it, with no job involved — at startup, and every few minutes afterwards.

{
  "media": {
    "ok": true,
    "source": "ipp",
    "mediaName": "om_62x100mm_62x100mm",
    "widthMm": 62, "lengthMm": 100, "dieCut": true,
    "printerState": "idle",
    "stateReasons": [],
    "raw": "media-ready: om_62x100mm_62x100mm\nmedia-col-ready: {...}"
  }
}

source is ipp, snmp or escp. Send {"media": null, "error": "it did not answer"} when the printer said nothing: Laberty records that it was asked, and changes nothing else.

{
  "ok": true,
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "reported": "62 x 100mm die-cut",
  "matched": { "id": 2, "name": "Shipping (1202)" },
  "confirmed": true
}
  • A report that matches one of the workspace's label sizes (by DK part number, by measurement to within a hundredth of an inch, or by the Brother label both work out to) becomes that printer's loaded stock and is marked confirmed, with confirmedVia: "printer". That always counts, whatever "who can confirm a roll" allows: it is the printer.
  • A report that matches nothing is kept and shown on the printer's card (matched: null), and the recorded roll is left exactly as it was.
  • 503 means this Laberty has not had migration 030 applied yet.

The same object may be sent as loadedMedia on POST /agent/jobs/{id}/done, on a success as well as on a failure:

{ "ok": false, "code": "stock_mismatch", "error": "Wrong labels in …",
  "loadedMedia": { "source": "escp", "widthMm": 29, "lengthMm": 90, "dieCut": true, "…": "…" } }

GET /agent/jobs answers with mediaProbes alongside the job when Laberty wants printers asked — each entry is { printerId, target, asked }, where target is the same shape a job carries. The bridge asks them after it has printed, so a question never delays a label.


Laberty Screens (/device)

What a Laberty Screen calls: a touchscreen, rotary knob, e-ink sign or two-button box next to the printer (firmware in hardware/laberty-screen/), or /screen in a browser. Only a Screen key can call these, and a Screen key can call nothing else. Screens are gated like the print bridge (any paid plan), and no /device call counts against the monthly API actions.

A switched-off screen (Admin → Devices) can still read its state, but every POST returns 403 { "code": "screen_off" }.

GET /device/state

Everything the screen draws, and its heartbeat (Admin → Devices shows it online). Names and label titles only: never a street address or a payload. About 3–6 KB. Add ?view=compact for the small devices: well under 2 KB, with no clock or ages.

Every answer has stateVersion, also sent as the ETag header. Send it back as If-None-Match and you get 304 with no body when nothing the screen shows has changed. The clock and "3 min ago" ages don't change the version, so an e-ink sign only refreshes when something real does.

{
  "v": 1,
  "workspace": "Family",
  "now": "2026-09-13T18:04:00.000Z", "localTime": "1:04 PM", "localDate": "Sun, Sep 13",
  "bridge": { "online": true, "lastSeen": "just now" },
  "printers": [{
    "id": 1, "name": "Kitchen QL-820NWB", "kind": "label", "default": true,
    "status": "ready", "statusText": "Ready",
    "loadedStock": { "id": 2, "name": "Large Address (208)", "confirmed": true, "via": "switch" },
    "rollNeeded": { "count": 0, "needs": [] },
    "swapStocks": [{ "id": 3, "name": "Continuous 2.4in roll (2205)" }]
  }],
  "jobs": [{ "id": 812, "title": "Address labels for Rose (5)", "status": "failed", "errorCode": "no_media", "printer": "Kitchen QL-820NWB", "age": "4 min ago" }],
  "held": [{ "id": 815, "title": "Occasion: Birthday — Ada Lovelace (Sept 30)", "stockName": "Large Address (208)", "loadedMatches": true }],
  "heldCount": 1,
  "heldText": "1 label ready to print — load Large Address (208)",
  "occasions": [{ "id": 4, "title": "Ada & Grace Lovelace — Anniversary", "date": "Sept 30", "when": "in 17 days", "daysAway": 17, "labelDate": "Sept 23", "label": "label Sept 23" }],
  "pendingRoll": { "printerId": 1, "stockId": 3, "name": "Continuous 2.4in roll (2205)", "expiresInSeconds": 94 },
  "screen": { "id": 1, "name": "Kitchen screen", "kind": "touch", "confirmButton": false, "enabled": true, "sleepAfterSeconds": 120, "quietStart": "21:30", "quietEnd": "06:30", "quietNow": false, "pollSeconds": 15, "wake": true },
  "stateVersion": "9f2c41d07a3b55e1"
}
  • jobs: the latest 8, never held ones. status is queued | claimed | done | failed | cancelled.
  • held: up to 6 labels waiting for someone to press Print; heldCount is all of them.
  • printers[].status: ready | bridge_offline | needs_setup | phone_only, or the last failure's error code (no_media, unreachable, …). statusText says it in words.
  • printers[].loadedStock.confirmed: the roll is confirmed, so jobs for any other roll are refused. via is device | switch | admin | api (screen on older rows), or null when not confirmed.
  • pendingRoll: a roll this device picked with selectRoll that nobody has confirmed yet, or null. It is forgotten after 2 minutes (expiresInSeconds, which doesn't change stateVersion), and used up by confirmRoll.
  • screen.confirmButton: a control on this device is bound to confirmRoll. Without one, a touch UI's own Confirm records the roll as not sure.
  • printers[].rollNeeded: rolls that jobs are waiting for. count is the held jobs, plus jobs that failed in the last 24 hours, that the confirmed-roll check refused (stock_mismatch) and whose stock isn't in the printer now. needs lists those stocks as { id, name }.
  • occasions: the next 5. label says when the label goes out (label Sept 23, label today, label sent).
  • screen.quietNow is worked out on the server in the account's time zone. screen.wake is true when something is waiting (held labels, or a print failed in the last hour) outside quiet hours. A screen should wake once per new thing.

Compact (?view=compact) has workspace, localDate, bridge.online, the default printer (with loadedStock, rollConfirmed, needsRoll ({ count, name } or null) and up to 3 swapStocks), heldCount, heldText, up to 5 held, failedCount, lastJob, up to 3 occasions, pendingRoll, screen and stateVersion, with titles shortened to 32 characters.

screen.profileVersion goes up every time the device is saved in Admin → Devices (0 = never saved). When it changes, fetch GET /device/config. A device doesn't have to be flashed again when only what its controls DO changed: see layoutVersion under POST /device/commands.

GET /device/config

The device's saved profile, as the firmware needs it: which board, the display settings, every input and output with its pins and settings, and what each button, switch, knob or sensor is bound to. Built in Admin → Devices (the Device Builder). A device that was never given a profile gets the default for its kind (a touchscreen, a knob, an e-ink sign or two buttons), with profileVersion 0.

Nothing only the server should know is in it: no workspace or user details, no revision history, and never a webhook's URL (Laberty calls webhooks itself).

{
  "v": 1,
  "profileVersion": 3,
  "name": "Hall box",
  "kind": "buttons",
  "board": { "id": "esp32-devkitc", "chip": "esp32", "display": null },
  "display": { "use": false, "layout": "status", "rotation": 0 },
  "inputs": [
    { "id": "go", "type": "button", "label": "Print", "pins": { "pin": 25 }, "config": { "activeLow": true, "pullup": true, "debounceMs": 50, "longPressMs": 1000 } }
  ],
  "outputs": [
    { "id": "lamp", "type": "led", "label": "Labels waiting", "pins": { "pin": 27 }, "config": { "activeLow": false, "brightness": 100 },
      "states": { "readyLabelsWaiting": { "pattern": "slowBlink" } } }
  ],
  "bindings": [
    { "id": "go.press", "control": "go", "event": "press", "trigger": "press", "command": "releaseReadyLabels", "args": { "which": "all" } },
    { "id": "go.longPress", "control": "go", "event": "longPress", "trigger": "longPress", "command": "relayPulse", "args": { "controlId": "power", "durationMs": 3000 },
      "longPress": true, "disabled": true, "reason": "plan" }
  ],
  "pollSeconds": 15,
  "configVersion": "5b1e0c7a2f9d4e61"
}
  • bindings[].disabled is true when the device may not run that binding right now. reason is plan (an advanced command on a plan without them) or off (the command is switched off on this Laberty). Show it, don't send it.
  • longPress: true marks a command that only ever runs from a long press (or a switch).
  • configVersion is also the ETag. Send it back as If-None-Match for a 304 with no body.
  • At most one fetch every 5 seconds per device: sooner is 429 { "code": "rate_limited" } with Retry-After: 5. A 304 counts as a fetch.

The state also has bridge.paused (someone paused the print bridge from a device or Admin) and outbox: things Laberty is asking this device to do.

POST /device/commands

Runs one control's binding. The device says which control and gesture; Laberty looks the command and its settings up in the device's current saved profile, so a device key can never run anything that isn't bound on that device, and nothing in the body can change a command's arguments.

{ "requestId": "1a2b3c4d-17", "bindingId": "go.press", "gesture": "press", "holdMs": 0, "layoutVersion": 2, "profileVersion": 3 }
  • requestId: 1-64 of A-Z a-z 0-9 _ -, unique per press (the firmware uses its boot id plus a counter). The same requestId again within 24 hours gets the first answer back with "replayed": true, and nothing runs twice.
  • bindingId: bindings[].id from GET /device/config (control.event).
  • gesture: the binding's trigger (press, longPress, doublePress, switchOn, switchOff, turn, position, threshold). holdMs: how long it was held.
  • layoutVersion (optional): the profile version the firmware's controls and pins were built from. An ESPHome export sends the version it was exported from.
  • profileVersion (optional): the version the device last heard about (from /device/state or an earlier answer). Used as the build version when there is no layoutVersion.
  • value: the reading, for an analog / 4-20 mA above / below binding. position: optional, a selector's position id.

Changes apply live. When the build version is older than the current profile, the press still runs, with the current binding's command and settings, as long as that control is wired the same (same type, pins, built-in part and selector positions) and that event is still bound the same kind of act. Otherwise it's 409 profile_changed, with reason:

  • layout or control_gone ("reflash": true): the control was rewired, is new, or was removed. Nothing can change wiring remotely: export or flash the firmware again.
  • binding_gone: nothing is bound to that event any more.
  • trigger: the event is a different kind of act now, or the gesture sent isn't what it is.

Every answer, success or refusal, carries the current profileVersion, so firmware notices a change without waiting for its next poll. (The older per-job routes answer with it too.)

The touch UI may instead send { "requestId", "command", "params" } for these commands only: releaseJob, skipJob, retryJob, cancelJob, swapRollTo, selectRoll, confirmRoll, releaseReadyLabels, skipReadyLabels, retryLastFailedJob, reprintLastJob, printPreset, printTestPage, swapRollNext, swapRollPrevious, pauseBridge, resumeBridge (plus gesture and holdMs for the long-press ones). A command sent by name is a tap: a roll it records is never confirmed (the answer carries confirmNote). The knob, e-ink sign and button box firmware send their per-label actions this way too; from those devices (screen.kind not touch), skipJob and a forced releaseJob ("force": true, "print anyway") need gesture: "longPress" with holdMs of at least 1000.

The per-job routes (POST /device/jobs/:id/release|skip|retry|cancel, POST /device/printers/:id/loaded-stock) keep working for firmware flashed before; new firmware doesn't use them.

200
{ "ok": true, "requestId": "1a2b3c4d-17", "command": "reprintLastJob",
  "result": { "jobId": 912, "jobStatus": "queued", "reprintOf": 905 },
  "display": { "text": "Reprinting", "ttlMs": 4000 },
  "led": { "output": "lamp", "mode": "flashTwice", "ms": 800 },
  "profileVersion": 3 }

A webhook answers 202 (Laberty sends it right after answering). Every refusal has ok: false, code, error, display and led too:

StatuscodeWhen
400invalid_requestBad JSON, requestId, bindingId, gesture, or a reading without value
403screen_offThe device is switched off in Admin → Devices
403plan_requiredAdvanced commands (relay, threshold, webhook) need Business; checked on every press
403command_not_allowedSwitched off on this Laberty, or not on the touch UI's list
404unknown_bindingNo such binding in the saved profile
404not_foundNothing waiting, no such saved label / webhook / relay, or nothing failed
409profile_changed{ profileVersion, reason, reflash }: this control was rewired (reflash: true: flash again), or its event is no longer bound the same way (fetch the config)
409long_press_required{ minHoldMs }: skip, cancel, pause, not-sure-which-roll, relay and webhook need a long press (or a switch / selector position where allowed); so do a skip or "print anyway" sent by name from a knob, e-ink sign or button box
409wrong_status / duplicate / nothing_to_reprint / not_in_swap_list / stock_mismatch / no_rollduplicate carries jobId (the same label within 10 seconds). no_roll: confirmRoll with nothing picked and nothing recorded as loaded
400not_in_swap_list / stock_removedA roll command named a stock that isn't on that printer's list, or was removed
410payload_goneThat label's data was cleared (after 7 days)
422threshold_not_crossedThe reading isn't past the level, or the threshold hasn't re-armed
429rate_limited{ retryAfterSeconds, scope } and Retry-After. scope: burst (5 in 2 s), device_minute (30), device_hour (300), disruptive (6 a minute), print (the per-minute print limit), webhook_minute (6), webhook_day (200 per account)

What each command does:

  • releaseReadyLabels / skipReadyLabels: the oldest held label, or all of them.
  • retryLastFailedJob: the latest failed job that still has its label data.
  • reprintLastJob: a new copy of the latest printed job from a person, program or occasion (never a device's own reprint).
  • printPreset: a saved label (Admin → Devices → Saved labels): text re-rendered with {date} and {time}, or a kept copy.
  • printTestPage (printerId?): the same test page as Admin's printer card (a test label, receipt, alignment sheet or page, in the printer's own language), on that printer or the default one. Queued as a device job: the 10-second duplicate guard, the print rate limit and the confirmed-roll check apply. The answer has jobId and printer.
  • swapRollNext / swapRollPrevious: the next or previous roll on the printer's swap list (removed rolls skipped), not confirmed.
  • setLoadedRoll / swapRollTo: one particular roll. From a switch or selector position it is confirmed (via: "switch"), from a bound button via: "device"; sent by name (a tap) it is not sure.
  • selectRoll (stockId?, printerId?): picks a roll on this device without changing the printer. It shows up as pendingRoll in the state for 2 minutes. With no stockId, each press or turn steps round the printer's swap list (turnCcw goes back). The answer has pendingRoll and says "Selected: …".
  • confirmRoll (printerId?): the physical confirm button. Records the picked roll as loaded and confirmed (via: "device") and clears the pick; with nothing picked it confirms the roll already recorded as loaded. A removed roll is 400 stock_removed, one no longer on the swap list 400 not_in_swap_list, and no roll at all 409 no_roll. Sent by name from a touch UI it records the roll as not sure, with confirmNote, and never undoes a confirmation of the same roll.
  • "Not sure" (clearRollConfirmation) is always allowed.
  • pauseBridge / resumeBridge / toggleBridge: while paused the bridge gets { "job": null, "paused": true } from GET /agent/jobs and Print on the iPad opens its print dialog. resumeAfterMinutes (up to 480) resumes it by itself.
  • relayPulse: the device pulses its own relay after the 2xx (led.ms is the length, at most the relay's maxPulseMs, 2000 by default).
  • webhook: Laberty POSTs { event, device, command, value, at } to the webhook, signed X-Laberty-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">, 5 second timeout, no redirects.
  • analogThreshold: fires its command once when the reading crosses the level; a reading back past the level by the hysteresis re-arms it (the device reports both).

Every press is kept for 90 days (Admin → Devices → a device → Presses), with web addresses and secrets left out.

POST /device/outbox/{id}/ack

The device did what outbox in the state asked (today: { "action": "pulse", "output": "power", "ms": 1000 } from Admin's relay Test button). Answers { "ok": true, "id": 7 }, or 404 for an id that isn't this device's. Outbox items not acked within 10 minutes are dropped.

GET /agent/jobs while paused

The print bridge's poll answers { "job": null, "paused": true } while the bridge is paused; queued jobs wait.

POST /device/jobs/{id}/release

A held job goes to the queue, and the bridge prints it on its next poll. If the printer has other labels loaded than the job was made for:

409
{ "code": "stock_mismatch", "error": "Load Large Address (208) in Kitchen QL-820NWB first, or print anyway.", "stockName": "Large Address (208)", "printerName": "Kitchen QL-820NWB" }

Send { "force": true } to print anyway.

When the printer's roll is confirmed as something else, force is ignored:

409
{ "code": "roll_confirmed_mismatch", "confirmed": true, "error": "Needs Large Address (208): Kitchen QL-820NWB has Continuous 2.4in roll (2205) confirmed loaded. Change the roll and press Swap roll first.", "stockName": "Large Address (208)", "printerName": "Kitchen QL-820NWB", "loadedStockName": "Continuous 2.4in roll (2205)" }

POST /device/jobs/{id}/skip

A held job is cancelled and never printed.

POST /device/jobs/{id}/retry

A failed job goes back on the queue. Refused with 409 duplicate (and the other jobId) while the same job is already queued or printing, 410 payload_gone once its label data has been cleared (after 7 days), and 429 under the per-minute print limit.

POST /device/jobs/{id}/cancel

A queued job is cancelled before the bridge picks it up. A job the bridge already has returns 409.

POST /device/printers/{id}/loaded-stock

Body { "stockId": 3 }. Records a roll swap from a tap on the screen, as not sure, with a confirmNote: a tap never confirms a roll. To confirm it for sure, pick it with selectRoll and press a button bound to confirmRoll (or use a switch bound to setLoadedRoll). The stock must be on that printer's list of stocks (Printers & stock), or it's 400 not_in_swap_list. A removed stock is 400 stock_removed. The same swap again answers "unchanged": true and changes nothing.

Every action answers { "ok": true, "jobId": 815, "jobStatus": "queued" } (or the printer and loadedStock for a swap), or an error with a code: not_found (404, including another workspace's ids), wrong_status (409), and the ones above. Every action is written to the activity log as the screen, for example Screen "Kitchen screen".


Households and labels

GET /households

Every household with its people, addresses and active label variants.

A household can have several addresses (a lake house, a winter home). address is the household's primary address, exactly as it always was; addresses lists every address, primary first. Use an address's id as address_id when you print. A household with one address has a one-entry list.

{
  "households": [
    {
      "id": 12,
      "displayName": "Ada & Grace Lovelace",
      "address": { "line1": "1428 Rosemont Ave", "line2": null, "city": "Charleston", "state": "SC", "zip": "29403" },
      "addresses": [
        { "id": 31, "nickname": "Town", "primary": true, "line1": "1428 Rosemont Ave", "line2": null, "city": "Charleston", "state": "SC", "zip": "29403", "notes": null },
        { "id": 32, "nickname": "Lake house", "primary": false, "line1": "12 Lake Rd", "line2": null, "city": "Ely", "state": "MN", "zip": "55731", "notes": null }
      ],
      "people": [{ "id": 20, "title": "Ms.", "first_name": "Ada", "last_name": "Lovelace", "is_child": false }],
      "variants": [{ "id": 41, "display_label": "Couple", "name_text": "Mr. & Mrs.\nAda & Grace Lovelace", "variant_type": "couple" }]
    }
  ]
}

POST /households

address_line1, city, state and zip are required. people is optional; when given, the standard label variants (individual, couple, family) are generated automatically.

curl -X POST https://laberty.app/api/households \
  -H "Authorization: Bearer $LABERTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "address_line1": "1428 Rosemont Ave",
        "city": "Charleston", "state": "SC", "zip": "29403",
        "people": [{ "title": "Ms.", "first_name": "Ada", "last_name": "Lovelace" }]
      }'
{ "householdId": 12, "peopleCreated": 1, "variantsGenerated": 2 }

To give the household more than one address, add an addresses array. Each entry takes nickname, address_line1 (or line1), address_line2, city, state, zip and notes. The top-level address stays the primary. Or leave the top-level fields out and mark one entry "primary": true (else the first is the primary). Up to 10 addresses in all. The response then also says addressesCreated.

{
  "address_line1": "1428 Rosemont Ave", "city": "Charleston", "state": "SC", "zip": "29403",
  "addresses": [{ "nickname": "Lake house", "address_line1": "12 Lake Rd", "city": "Ely", "state": "MN", "zip": "55731" }],
  "people": [{ "first_name": "Ada", "last_name": "Lovelace" }]
}

GET /label-stock

The workspace's active label sizes, for choosing one to print on.

POST /print/email

Renders a batch of address labels as a PDF and emails it to the member account — the remote-printing path for someone who prints from a tablet.

curl -X POST https://laberty.app/api/print/email \
  -H "Authorization: Bearer $LABERTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "items": [{ "householdId": 12, "variantId": 41 }] }'
{ "sentTo": "member@example.com", "labelCount": 1 }

Each item may carry address_id (or addressId), one of the household's address ids from GET /households. Without it the household's primary address prints, as always. An address_id that isn't that household's is refused with 400 and "code": "bad_address", and nothing is sent.

Needs a member account with an email address on it (Users page), and stockId is optional — it uses the default stock otherwise. On sheet stock, startPosition (default 1) says where on the first sheet the labels begin.


Limits, and what they cost

Home includes 20 API actions a month. Business is unlimited. An action is a call that does something: POST /print, POST /households, POST /print/email, POST /printers/{id}/loaded-stock. Reads (GET) never count, so polling a job's status is free, and print-bridge calls (/agent/*) and Laberty Screen calls (/device/*) never count. A refused or failed call doesn't use one up. The month is the account's own calendar month (Central time by default). Once the allowance is used, a call that would count gets:

429
{ "code": "api_quota", "error": "Home includes 20 API actions a month, and this workspace has used 20. ...", "limit": 20, "used": 20, "resetsAt": "2026-10-01T05:00:00.000Z" }

On Business, printing through the API is not metered and not billed per job. Queueing a job costs a database write and a share of a poll the bridge was making anyway — fractions of a cent — so a meter would cost more to build, explain and argue about than it could ever recover.

What exists instead are guards against the runaway case:

GuardValueWhat happens
Rate limit60 print jobs / minute per workspace429 with Retry-After: 60. This is what stops a loop in your code, not a ration
Fair use10,000 jobs / month per workspaceNothing is blocked. The deployment owner gets one notification, so an unusual integration gets noticed
Payload size~1 MB decoded413. A label is a few KB
Copies50 per jobClamped, not rejected
Printers1 / 2 / unlimited on Free / Home / BusinessEnforced when adding one, with a hard ceiling of 50 as a typo-catcher
API actionsnone / 20 a month / unlimited on Free / Home / Business429 with code: "api_quota" and resetsAt. Reads and bridge calls don't count
API keysnone / 3 / unlimited on Free / Home / BusinessChecked when a key is made. Revoked keys don't count. A hard ceiling of 200 live keys catches a runaway script

GET /agent/status returns these live, including your month-to-date count, so your own UI can show them:

"limits": { "jobsPerMinute": 60, "fairUseJobsPerMonth": 10000, "jobsThisMonth": 214, "apiActionsPerMonth": 20, "apiActionsThisMonth": 12, "metered": false }

Retention. A finished job's payload is blanked after 7 days and the row is deleted after 30. Queued and claimed jobs are never touched however old — a stuck job is something to look at, not something to silently delete. So poll GET /print/{id} while you care, and keep your own record if you need one beyond a month.

Notes worth knowing

Everything is workspace-scoped. A token only ever sees its own workspace. Passing an id belonging to another workspace returns "not found", never someone else's row.

Polling. Don't poll GET /print/{id} in a tight loop — once a second is plenty, and the bridge only checks for work every five seconds anyway. Reads are not rate limited; only POST /print is.

Stability. These paths and field names are what the print bridge and the Claude skill already use, so they won't change shape without a version bump. Additive fields may appear.

The bridge's own endpoints go further: /agent/v1/* is a frozen contract with golden tests that fail the build if any field is removed, renamed or retyped. See docs/BRIDGE-PROTOCOL-V1.md.

Errors are always { "error": "a sentence you can show a user" } with a matching HTTP status.

Download the API reference (Markdown) · Contact support