Integration guide: printing from another appHome & Business
A worked walkthrough for connecting a point-of-sale, inventory or shipping app to Laberty, with examples in curl, JavaScript and Python.
This guide connects another app to Laberty so it can print: order labels, shelf labels, receipts. Your app says "print these lines on that printer". Laberty renders the label at the right size, checks the right labels are loaded, and hands the job to the print bridge on your network. Your app needs no printer drivers and no container of its own.
You'll need:
- A Home plan (20 API actions a month, enough to try it) or Business (unlimited).
- A print bridge running on the same network as the printers.
- Your printers added on Admin → Printers & stock, each with its IP address and In it now set.
Every endpoint and field is documented in the API reference. This guide is the path through it.
The one thing to understand: printing is a queue
POST /api/print answering 201 means the job is queued, not printed. The bridge picks it up within about five seconds, prints it, and reports back. To know whether it really came out, check GET /api/print/{id}.
So design your app for three outcomes: sent, printed, and failed, with a reason.
Step 1: Make a Printing key
- In Laberty, go to Admin → API keys.
- Name the key after your app, for example "Front counter POS".
- Choose Printing and press Make key.
- Copy the key. It's shown once.
Store it in your server's configuration, for example an environment variable named LABERTY_API_KEY. Never put it in browser JavaScript or in an app people download. If your app runs only in a browser, put a small server in between that holds the key. More in API keys explained.
Step 2: List the printers
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", "accepts": ["pdf"],
"loadedStock": { "id": 3, "code": "2205", "name": "Continuous 2.4in roll (2205)", "kind": "continuous", "widthIn": 2.4, "heightIn": null, "dkPart": "DK-2205" }
},
{
"id": 2, "name": "Front counter receipt", "isDefault": false,
"type": "receipt", "accepts": ["escpos", "raw"], "loadedStock": null
}
]
}
The fields that matter:
id: what you send asprinterId.type:label,receiptortape.accepts: the raw job kinds this printer understands.loadedStock: what Laberty believes is in the printer.codeanddkPartare what you send asstock.bridge.online: whether the bridge picked up a job in the last five minutes. An idle bridge reportsfalsewhile being perfectly healthy, so treat it as a hint.
Step 3: Pick the right printer
Choose by type, and let the person setting up your app confirm the choice once. Store the printer id in your app's settings.
const { printers } = await (await fetch('https://laberty.app/api/printers', {
headers: { Authorization: `Bearer ${process.env.LABERTY_API_KEY}` },
})).json()
const labelPrinter = printers.find((p) => p.type === 'label' && p.isDefault) || printers.find((p) => p.type === 'label')
const receiptPrinter = printers.find((p) => p.type === 'receipt')
if (!receiptPrinter) {
// Say it plainly, rather than failing later
console.log('No receipt printer is set up in Laberty yet.')
}
If a printer you stored disappears (someone removed it), POST /api/print answers 404. Fetch the list again and ask the user to choose.
Step 4: Send a label
Send label with lines of text. Laberty renders plain, legible text at the exact size of the labels: as long as the text needs on a continuous roll, or exactly the label's size on die-cut stock. Up to 20 lines, shrunk to fit.
Always send stock: the labels this job was designed for, as a code ("2205"), a DK part ("DK-2205") or a stock id. If different labels are recorded as loaded, the job is refused before anything is queued. And always send an Idempotency-Key (see step 8).
curl -X POST https://laberty.app/api/print \
-H "Authorization: Bearer $LABERTY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-label" \
-d '{
"printerId": 1,
"stock": "2205",
"label": { "lines": ["Jane Smith", "Order 1042", "2 x Sourdough"], "align": "center" },
"copies": 1,
"detail": "Order 1042"
}'
const res = await fetch('https://laberty.app/api/print', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LABERTY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `order-${order.id}-label`,
},
body: JSON.stringify({
printerId: labelPrinter.id,
stock: '2205',
label: { lines: [order.customerName, `Order ${order.id}`, order.summary] },
detail: `Order ${order.id}`,
}),
})
const data = await res.json()
// 201 = queued, 200 = a retry of a job already queued (data.duplicate === true)
import os
import requests
res = requests.post(
"https://laberty.app/api/print",
headers={
"Authorization": f"Bearer {os.environ['LABERTY_API_KEY']}",
"Idempotency-Key": "order-1042-label",
},
json={
"printerId": 1,
"stock": "2205",
"label": {"lines": ["Jane Smith", "Order 1042", "2 x Sourdough"]},
"detail": "Order 1042",
},
timeout=15,
)
data = res.json()
A queued job answers:
{ "jobId": 88, "status": "queued", "kind": "pdf", "printer": { "id": 1, "name": "Kitchen QL-820NWB" }, "stock": { "code": "2205" }, "statusUrl": "/api/print/88" }
Step 5: Send a receipt
Send receipt to a printer whose type is receipt. Each item in lines is one of:
| Item | Prints |
|---|---|
"plain text" | A left-aligned line, wrapped to the paper |
{ "text": "TOTAL", "align": "center", "bold": true, "size": "big" } | align is left, center or right. size is normal, tall, wide or big |
{ "key": "Coffee", "value": "$3.00" } | Coffee..............$3.00 across the paper |
{ "rule": true } | A line of dashes across the paper |
{ "feed": 2 } | Blank lines |
The paper is cut at the end; send "cut": false to feed a little paper instead. Add "openDrawer": true to open a cash drawer plugged into the printer. Receipts don't need stock.
await fetch('https://laberty.app/api/print', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LABERTY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `order-${order.id}-receipt`,
},
body: JSON.stringify({
printerId: receiptPrinter.id,
receipt: {
lines: [
{ text: 'MAPLE STREET BAKERY', align: 'center', size: 'big' },
{ rule: true },
...order.items.map((item) => ({ key: item.name, value: item.price })),
{ rule: true },
{ key: 'TOTAL', value: order.total },
'Thank you!',
],
cut: true,
openDrawer: order.paidInCash,
},
}),
})
Step 6: Handle refusals
A refusal comes back straight away, with an HTTP status and JSON like { "error": "…", "code": "…" }. Nothing was queued. The error text is written for people, so you can show it as it is.
| Status | code | What happened | What your app should do |
|---|---|---|---|
409 | stock_mismatch | Different labels are recorded as loaded | Show error. It names the labels to load. Your admins were emailed |
409 | stock_unknown | Nothing is recorded as loaded in that printer | Ask an admin to set loaded stock on Printers & stock |
422 | wrong_kind | A label sent to a receipt printer, or the reverse | A bug in your printer choice. The response lists what the printer accepts |
400 | unknown_stock | No label size matches your stock | Check the code on Printers & stock |
404 | The printer no longer exists | Fetch printers again | |
429 | Over 60 jobs a minute | Wait Retry-After seconds, then retry with the same Idempotency-Key | |
401 / 403 | Key revoked, wrong kind of key, or plan | Tell an admin. Retrying won't help |
if (res.status === 201 || res.status === 200) {
return { queued: true, jobId: data.jobId }
}
if (res.status === 409) {
// stock_mismatch or stock_unknown: a person needs to act at the printer
return { queued: false, message: data.error, needsPerson: true }
}
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') || 60)
return { queued: false, retryAfterSeconds: wait }
}
return { queued: false, message: data.error || `Laberty answered ${res.status}` }
The full list, with a sentence for each, is in Error codes and what to tell your users.
Step 7: Find out whether it printed
Check the job until it's done or failed. Once every second or two is plenty: the bridge only checks for work every five seconds. Give up after a minute or so and tell the user the printer hasn't picked it up.
async function waitForPrint(jobId, timeoutMs = 60000) {
const started = Date.now()
while (Date.now() - started < timeoutMs) {
const res = await fetch(`https://laberty.app/api/print/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.LABERTY_API_KEY}` },
})
const job = await res.json()
if (job.status === 'done') return { ok: true }
if (job.status === 'failed') return { ok: false, code: job.errorCode, message: job.error }
await new Promise((resolve) => setTimeout(resolve, 1500))
}
return { ok: false, code: 'timeout', message: 'The printer has not picked this up yet. Is the print bridge running?' }
}
import time
def wait_for_print(job_id, timeout=60):
started = time.time()
while time.time() - started < timeout:
job = requests.get(
f"https://laberty.app/api/print/{job_id}",
headers={"Authorization": f"Bearer {os.environ['LABERTY_API_KEY']}"},
timeout=15,
).json()
if job["status"] == "done":
return True, None
if job["status"] == "failed":
return False, job.get("errorCode") or job.get("error")
time.sleep(1.5)
return False, "timeout"
status is one of queued, claimed (the bridge has it), done or failed. When a job fails, error says why in plain language, and errorCode is set when your app can act on it:
errorCode | Tell the user |
|---|---|
stock_mismatch | The wrong labels are in the printer. Load the right ones and print again |
no_media | The printer is out of labels |
printer_error | The printer needs attention, for example its cover is open |
unreachable | Laberty can't reach the printer. Is it on? |
no_address | The printer has no IP address in Laberty yet. Ask an admin |
null | Show the error text |
For the first three, nothing was printed and your admins were emailed.
Step 8: Retry without printing twice
Networks drop requests. If your app retries a print, the customer must not get two labels. Send an Idempotency-Key header that's unique per real-world print, such as the order number plus the kind of label.
- A retry with the same key returns the first job (
200,"duplicate": true) instead of queueing another. - A genuine reprint needs a new key, such as
order-1042-label-reprint-1. - Keys can be up to 100 characters.
Step 9: When staff swap a roll
If your app tells staff to change labels, it should tell Laberty too, so the next job isn't refused:
curl -X POST https://laberty.app/api/printers/1/loaded-stock \
-H "Authorization: Bearer $LABERTY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "stock": "208" }'
This needs a Full access key, because it changes what every later job is checked against. With a Printing key, ask staff to press Swap roll on the printer's card on Printers & stock instead.
The roll is recorded as not sure: jobs are sent, and the printer reports a wrong roll. Only a Laberty device at the printer confirms a roll for sure. If your app truly knows what's loaded, an admin can tick Apps confirm it through the API on Printers & stock, and then "confirmed": true confirms it. Without that, the answer says "confirmed": false with a confirmNote explaining why.
Before you go live
- The key lives on your server, not in the browser or a downloadable app.
- Printers are chosen by
type, and theidis stored in settings. - Every label job sends
stock. - Every job sends an
Idempotency-Key. 409,422and429each show a clear message.- Your app checks each job's status and shows
errorCodeproblems in plain words. - You've tried it with the wrong loaded stock recorded, and seen the
409.
API printing isn't metered or billed per job. See Limits for the guards that do exist.
Still stuck?
Contact support and tell us what you tried. We reply by email, usually within 1–2 business days.