Your API call returned 201 Created. The response says "status": "queued". Your code moves on, marks the order as printed, and tells the customer their label is on its way.
Except the warehouse printer was out of labels.
This is the single most common bug in printing integrations: treating "the API accepted my job" as "the document is in someone's hands". Printing is physical, and physical things fail long after the HTTP response. Getting this right means understanding the lifecycle, then choosing how you learn about its end.
The job lifecycle
A cloud print job passes through four states before it ends:
queued -> dispatched -> printing -> completed
\
-> failed- queued — the API accepted the job and stored it. This is what your
POSTreturns. - dispatched — the job was pushed down to the agent running next to the printer.
- printing — the agent handed the bytes to the local print system.
- completed / failed — terminal. Only these two mean anything is settled.
The gap between queued and terminal is where reality lives: the agent's computer may be asleep, the printer may be offline, the PDF at content_url may 404, the ZPL may be malformed. Each of those produces a failed job with a specific reason, and none of them are visible from the response you already got.
Option 1: Polling (fine, until it isn't)
The obvious approach is to ask, repeatedly:
async function waitForJob(jobId, { timeoutMs = 60_000, intervalMs = 2000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`https://api.printbase.cloud/v1/print-jobs/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}` },
});
const job = await res.json();
if (job.status === 'completed' || job.status === 'failed') return job;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`job ${jobId} did not settle in time`);
}This is genuinely the right call in one situation: a user is standing there waiting, in a request you are already holding open — a kiosk, a POS terminal, a "print now" button. The loop lives for a few seconds and dies.
It goes wrong as an architecture. Printing a thousand labels means a thousand polling loops, most of them asking a question whose answer hasn't changed. You pay in requests, in latency (you learn up to intervalMs late), and in the awkwardness of keeping loops alive across deploys and process restarts. Worse, a naive loop that gives up after 60 seconds has no idea what happened to a printer someone plugged back in at minute three.
Option 2: Webhooks (what you actually want in a backend)
Invert it: register a URL, and get told. PrintBase webhooks POST a signed JSON event the moment a job reaches a terminal state.
Register the endpoint once:
curl https://api.printbase.cloud/v1/webhooks \
-H "Authorization: Bearer $PRINTBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Fulfillment service",
"url": "https://example.com/hooks/printbase",
"events": ["print_job.completed", "print_job.failed"]
}'The response carries a secret (whsec_...) — store it the way you store any other credential. Every delivery arrives with these headers:
X-PrintBase-Event: print_job.failed
X-PrintBase-Event-Id: evt_abc
X-PrintBase-Signature: t=1755852761,v1=9f2c...And a body that tells you which printer, on which computer, and why:
{
"id": "evt_abc",
"type": "print_job.failed",
"created_at": "2026-08-22T09:12:41Z",
"organization_id": "org_abc",
"data": {
"job_id": "job_abc",
"printer_id": "printer_123",
"printer_name": "Warehouse Zebra",
"computer_id": "cmp_456",
"computer_name": "wh-pc-02",
"status": "failed",
"reason_code": "PRINTER_OFFLINE",
"message": "printer is offline"
}
}printer_name and computer_name matter more than they look: when an alert fires at 2am, "Warehouse Zebra on wh-pc-02 is offline" is actionable and "job_abc failed" is not.
Verify the signature (on the raw body)
The signature is HMAC-SHA256("{timestamp}.{raw body}", secret). The trap everyone hits: verifying against re-serialized JSON. JSON.parse then JSON.stringify gives you different bytes — different key order, different whitespace — and the HMAC will never match. Keep the raw body.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(
(header ?? '').split(',').map((kv) => kv.split('=').map((s) => s.trim()))
);
if (!t || !v1) return false;
// reject a replayed old-but-valid signature
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/hooks/printbase', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(raw, req.get('X-PrintBase-Signature'), process.env.PRINTBASE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
res.sendStatus(200); // acknowledge inside the timeout...
queue.push(JSON.parse(raw)); // ...then do the slow work off the request
});That ordering is not stylistic. PrintBase gives a delivery 5 seconds, retries twice (after 5s and 30s), and then gives up. If your handler updates an order, writes to a queue, and sends an email before responding, you will eventually blow the timeout, get retried, and do all of it twice.
Which brings up the rule that saves you: handlers must be idempotent. Deduplicate on X-PrintBase-Event-Id before acting.
async function handleEvent(event) {
const fresh = await db.markEventSeen(event.id); // unique index on event id
if (!fresh) return; // already handled, drop it
...
}Act on the reason code, not the message
A failed job carries one of five reason_code values, and they call for genuinely different responses:
reason_code | What it means | What to do |
|---|---|---|
AGENT_OFFLINE | No agent connected at that location | Alert the site. Retrying now prints nothing |
PRINTER_OFFLINE | Agent is up, printer isn't | Alert, or fail over to another printer at the same site |
DOWNLOAD_FAILED | The agent couldn't fetch content_url | Check the URL's lifetime — signed URLs expiring mid-queue is the classic cause |
PRINT_ERROR | The local print system rejected it | Retry once; if it persists, it's a driver or hardware issue |
INVALID_CONTENT | The bytes weren't what you claimed | A bug in your generator. Never retry — it will fail identically |
The useful distinction is retryable versus not. PRINT_ERROR and a transient DOWNLOAD_FAILED deserve a retry with backoff. INVALID_CONTENT deserves an exception in your error tracker. AGENT_OFFLINE deserves a human.
const RETRYABLE = new Set(['PRINT_ERROR', 'DOWNLOAD_FAILED']);
if (event.type === 'print_job.failed') {
const { job_id, reason_code, printer_name, computer_name } = event.data;
if (RETRYABLE.has(reason_code)) {
await scheduleReprint(job_id, { attempt: 1 });
} else {
await alertOps(`${printer_name} (${computer_name}): ${reason_code}`);
}
}Webhooks are not a database
Here is the part most integrations skip. A webhook is a delivery attempt, and deliveries can fail permanently — your endpoint was down for the full five-second, 5s, 30s window; a deploy dropped the connection; someone rotated a secret. After the third attempt the event is gone, marked failed, and nothing will resend it.
So keep a safety net: a periodic sweep over jobs your database still believes are unsettled.
// every 10 minutes
const stale = await db.jobsAwaitingResult({ olderThanMin: 10 });
for (const { jobId } of stale) {
const res = await fetch(`https://api.printbase.cloud/v1/print-jobs/${jobId}`, {
headers: { Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}` },
});
const job = await res.json();
if (job.status === 'completed' || job.status === 'failed') {
await handleEvent({ id: `sweep_${jobId}`, type: `print_job.${job.status}`, data: job });
}
}Webhooks give you the fast path; the sweep gives you the guarantee. Because both funnel through the same idempotent handler, running them together costs nothing.
You can also inspect what happened to any delivery — status, attempts, HTTP code, next retry — via GET /v1/webhooks/{id}/deliveries, which is usually the fastest way to answer "did you even call us?".
Testing before anything is real
You do not need a printer to build this. POST /v1/webhooks/{id}/test sends a real, signed print_job.completed event with job_id: "job_test":
curl -X POST https://api.printbase.cloud/v1/webhooks/wh_abc/test \
-H "Authorization: Bearer $PRINTBASE_API_KEY"Point the endpoint at a tunnel (cloudflared tunnel --url http://localhost:3000 or ngrok) while developing, and you can get signature verification, deduplication, and your reason-code branches all working before a single label is printed.
The short version
- The
POSTresponse tells you a job was accepted. Nothing more. - Poll only when a human is waiting on an open request; use webhooks everywhere else.
- Verify signatures against the raw body, acknowledge inside 5 seconds, deduplicate by event id.
- Branch on
reason_code— retry what's retryable, alert on what needs hands. - Add a reconciliation sweep, because a webhook that never landed is silent by definition.
PrintBase sends these events on every terminal state, signed, with the printer and computer named. The free plan is 100 jobs a month for building the integration; webhooks turn on with any paid plan. The full reference is in the Webhooks API docs.
