API reference
LabelCraft - barcode labels for Shopify that print at exactly the size you set.
The LabelCraft API prints labels and reads templates from outside the Shopify admin: a warehouse system, a receiving script, a scheduled job. It is the app's own print engine reached with a key instead of a session, so a label printed through the API is the label the app would have printed for the same template and the same products, warnings included. It answers with the PDF, or sends it to a printer of the shop's PrintNode account.
It is part of the Retail Pro plan. Keys are created in LabelCraft, under Settings, API access.
Authentication
Send the key as a bearer token on every request. Keys start with lc_live_ and are shown once, when created. A shop holds at most 5 active keys; revoking one takes effect on the next request.
A request without a valid key answers 401. A valid key on a shop below the Retail Pro plan answers 402.
curl https://labelcraft.tech/api/v1/templates \
-H "Authorization: Bearer lc_live_0123456789abcdef0123456789abcdef01234567"Limits
- 60 requests per minute per key. Above that, 429 with a Retry-After header, in seconds.
- 2000 labels per print request, summed over the quantities. Above that, 400 too_many_labels; split the job.
- 500 items (variant and quantity pairs) per print request. Above that, 400 invalid_request with the count in details[].
- A body of at most 1048576 bytes, enough for a template carrying a brand font and a logo. Above that, 413 payload_too_large.
- 200 non-archived templates per shop through POST /templates. Above that, 409 template_limit_reached; archive one or PATCH an existing one.
- The rate window lives in the memory of the single machine that serves the app, so it resets on a deploy. Nothing is queued: a refused request is not printed.
- A print request is not idempotent. If your call times out after the server answered, retrying prints the batch again and records a second job; with printerId, a second batch on paper.
GET /api/v1/templates
Lists the built-in paper sizes (by slug) and the shop's own templates (by id), with the label size in millimetres and the resolution the template was designed at. Archived templates are not listed and cannot be printed.
A thermal roll with two or more labels side by side, such as zebra-2-375x1-2across, also answers rollAcross: true. Its kind stays sheet and its label size is one label of the row; the row itself is in layout.sheet of GET /templates/:id. Every other template answers without the key.
curl https://labelcraft.tech/api/v1/templates \
-H "Authorization: Bearer YOUR_KEY"{
"templates": [
{
"id": "cmf1x9q2h0001",
"name": "Shelf 62 x 29",
"kind": "roll",
"source": "custom",
"labelWidthMm": 62,
"labelHeightMm": 29,
"dpi": 300,
"version": 3
},
{
"id": "dymo-30252",
"name": "Dymo 30252 Address (3½ × 1⅛ in)",
"kind": "roll",
"source": "preset",
"labelWidthMm": 88.89999999999999,
"labelHeightMm": 28.575,
"dpi": 203,
"version": 1
}
]
}POST /api/v1/print
Renders one PDF for a template and a list of variants with quantities, records the job in the shop's print history (source API, with the key's name), and returns the bytes as base64 beside the warnings.
Variants are read live from the shop at print time: title, SKU, barcode, price, weight, metafields. You send ids and quantities, nothing else.
With printerId, the PDF goes to that printer of the shop's PrintNode account instead, the way the app's Send to printer sends it: the printer is checked online right before the job goes, the labels go as one job, and the answer names the printer instead of carrying the PDF. The API also checks the printer before anything is counted, so a printer that is off costs nothing. Only a job PrintNode accepted is recorded and counted; a print refused before that answers an error and counts nothing.
curl https://labelcraft.tech/api/v1/print \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "dymo-30252",
"items": [
{ "variantId": 45678901234567, "qty": 2 },
{ "variantId": "gid://shopify/ProductVariant/45678901234568", "qty": 1 }
]
}'- template (string, required): a paper size slug or one of the shop's template ids, as listed by GET /templates.
- items (array, required): one entry per line, { variantId, qty }. variantId is a numeric id, a numeric string or a full gid; qty is a positive integer. The same variant may appear more than once.
- startPosition (integer, optional): for sheet templates, the index of the first label to use on the first sheet, from 0. Ignored on rolls, including a roll with labels across (layout.rollAcross).
- locale (en, fr, de or es, optional): the format of a printed date element and of prices on the label. Warning codes are stable and their messages are English; the app translates them by code on its own screens. Default en.
- printerId (integer, optional): a printer of the shop's PrintNode account, as listed by GET /printers; the same digits as a string work too. The labels then go to that printer, and the answer has no pdfBase64. Leave it out to get the PDF, as before. null or any other value is refused with invalid_request, never read as no printer. The shop's printer calibration (Settings) applies as it does to every PDF of the shop: LabelCraft keeps one calibration per shop, not one per printer.
{
"jobId": "cmf1xa77c0003",
"filename": "labels-dymo-30252.pdf",
"pageCount": 3,
"labelCount": 3,
"warnings": [
{
"code": "textOverflow",
"labelIndex": 1,
"element": "productTitle",
"message": "Text overflows its box: \"Organic cotton crew neck, heavyweight, oversized fit\""
}
],
"pdfBase64": "JVBERi0xLjcK..."
}- jobId: the print history row. It reprints from the app like any other job.
- pageCount and labelCount: what the PDF holds. labelCount is the sum of the quantities.
- warnings: never empty when something did not fit or a field came back empty. Same codes and sentences as the app. A print with warnings still returns the PDF; decide on your side whether to send it to the printer. With printerId, it is sent with its warnings, as the app sends it, unless a product photo or the variants' options could not be read for this print: then nothing is sent (held_from_printer).
- warnings[].params: the structured inputs of each sentence. A list of names travels as one JSON array in a string: params.options and params.has of variantOptionMissing read, for example, '["Größe","Rahmengröße"]'.
- pdfBase64: the PDF, base64. Decode it and hand it to your printer or your print server as is; it is already at the template's exact size.
- With printerId, in place of pdfBase64: delivered (true), printerId, printerName (as PrintNode names it now) and printNodeJobId (PrintNode's id for the job, null when PrintNode gave none). jobId is null when the job printed and LabelCraft could not confirm its history row; the labels are counted all the same.
GET /api/v1/printers
Lists the printers of the PrintNode account connected to the shop in LabelCraft (Settings, Send labels straight to a printer), so a print can go to one of them: printerId on POST /print. online says whether LabelCraft would send to it right now (the printer online in PrintNode and its computer connected); selectedInApp marks the printer the app's own Send to printer uses.
A shop without PrintNode connected answers 409 printnode_not_connected. A key PrintNode refuses, or PrintNode out of reach, answers 502 printnode_error with stage, kind and retryable. The PrintNode key is only ever sent to PrintNode, never back in an answer.
curl https://labelcraft.tech/api/v1/printers \
-H "Authorization: Bearer YOUR_KEY"{
"printers": [
{
"id": 73065,
"name": "Zebra ZD421",
"state": "online",
"computerName": "Front desk PC",
"computerState": "connected",
"online": true,
"selectedInApp": true
},
{
"id": 73071,
"name": "Rollo X1040",
"state": "offline",
"computerName": "Warehouse PC",
"computerState": "connected",
"online": false,
"selectedInApp": false
},
{
"id": 73066,
"name": "Zebra GK420d",
"state": "online",
"computerName": "Warehouse PC",
"computerState": "connected",
"online": true,
"selectedInApp": false
}
]
}GET /api/v1/templates/:id
One template with its full layout: a built-in paper size by slug or one of the shop's non-archived templates by id. The layout is the exact object the editor stores, so a system can read a paper size, change a few fields and POST the result under its own name.
curl https://labelcraft.tech/api/v1/templates/dymo-30252 \
-H "Authorization: Bearer YOUR_KEY"POST /api/v1/templates
Creates a template from { name, layout } and answers 201 with the same shape as GET /templates/:id. The layout goes through the editor's own rules: unknown fields are dropped, fields that only make sense on some element types are removed from the others, a logo or a custom font must decode, and an element outside the label is refused with 422 and the box that would fit.
A template created here appears in the app's template list and prints from the app and from POST /print alike.
curl https://labelcraft.tech/api/v1/templates \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Shelf 62 x 29",
"layout": {
"kind": "roll", "labelWidthMm": 62, "labelHeightMm": 29, "dpi": 300,
"elements": [
{ "type": "productTitle", "xMm": 2, "yMm": 2, "widthMm": 58, "heightMm": 8, "fontSize": 9 },
{ "type": "barcode", "xMm": 2, "yMm": 11, "widthMm": 58, "heightMm": 16, "barcodeFormat": "ean13" }
]
}
}'- name (string, required): 1 to 80 characters.
- layout.kind (roll or sheet), layout.labelWidthMm and layout.labelHeightMm (mm, above 0), layout.dpi (integer, default 203).
- layout.sheet (required on a sheet): pageWidthMm, pageHeightMm, marginTopMm, marginLeftMm, gutterXMm, gutterYMm, columns, rows.
- layout.rollAcross (optional, true): a thermal roll with two or more labels side by side, such as the zebra-2-375x1-2across paper size. Send it with layout.kind sheet and a layout.sheet that is one row of the roll: pageWidthMm the roll's width, pageHeightMm the row's height (the label's height on die-cut stock), rows 1, columns 2 or more. Each page of the PDF that POST /print returns is one row of the roll, and startPosition is ignored, as on any roll; in the app, a Zebra (ZPL) file prints each row as one format as wide as the roll, unless layout.printBandMm leaves part of the label out (below). Refused with invalid_request on a roll layout, with any value other than true, with rows other than 1, with fewer than 2 columns, when the left margin, the labels and the gaps are wider than pageWidthMm, or when the top margin and the label are taller than pageHeightMm.
- layout.printBandMm (optional, with layout.rollAcross only): { fromMm, toMm }, the part of every label of the row that the print head reaches, in mm from the label's left edge. A head narrower than the roll leaves the outer edges of the roll unprinted: on zebra-2-375x1-2across, laid out for a 56 mm head, it is { fromMm: 2.1625, toMm: 28 }. Each linear barcode then keeps its bars and its outer digits inside it whenever they fit there without going under a scanning minimum (bar width, 2 printer dots a bar, digit and bar height), the rest of its box counting as quiet zone; otherwise it is placed in its box as without the band. A barcode drawn past it anyway says so in warnings (barcodeOutsidePrintBand), and so does any other element whose ink runs past it, on each label (elementOutsidePrintBand): the lines of a text, or its whole box under a background colour, a line's stroke, an image's rectangle. In the app, a row whose band leaves part of the label out gets no Zebra (ZPL) file for now: print the PDF; a band of the whole label limits nothing. Refused with invalid_request without layout.rollAcross, or unless 0 <= fromMm < toMm <= layout.labelWidthMm.
- layout.panels (optional, on a roll only): { cutsMm: [...] }, where a slit, a fold or a perforation running along the roll cuts one label into panels, 1 to 8 positions in mm from the label's left edge, in increasing order, each panel at least 1 mm wide. A barcode then counts only the white of its own panel as its quiet zone, and an element whose box runs over a cut comes back as an elementAcrossPanelCut warning.
- layout.calibration (optional): offsetXMm and offsetYMm, within 5 mm of 0.
- layout.elements (1 to 60): each with type, xMm, yMm, widthMm, heightMm, and the optional fields of that type (fontSize, bold, align, overflow, value, color, barcodeFormat, qrSource, includeText, serial, imageData and imageSource for a logo, moneyFormat for price and compareAtPrice, strikethrough for compareAtPrice, optionNames for variantTitle, rotation for a text, a logo or a photo). imageSource "product" draws each label's product photo from Shopify in that logo's box instead of imageData, on PDF prints only; a label whose photo cannot be had prints without it and says why in warnings. moneyFormat takes Shopify's money format grammar, for example "{{amount_with_comma_separator}} €", and is kept on price and compareAtPrice only and dropped from other types; leave it out and the label prints the shop's own format. strikethrough true draws a line through a compareAtPrice on PDF prints, only on a label whose compare-at price is above its price; it is dropped from other types, false is stored as absent, and a ZPL print shows the amount without the line. A format the label cannot print (an unknown placeholder, no placeholder at all, more than 80 characters once HTML tags are removed, or no decimals on a currency that has cents) is refused with invalid_request, one detail line per element at fault. optionNames turns a variantTitle element into the value of ONE option of the variant, named as in Shopify and matched without regard to case: ["Größe", "Rahmengröße", "Size"] prints the first of those options the variant has, so "56" rather than "Red / 56". It holds 1 to 5 names of 1 to 255 characters without control characters, is kept on variantTitle only and dropped from other types; a list that is empty once each name is trimmed is refused with invalid_request. A variant without any of the names prints nothing there and says why in warnings (code variantOptionMissing, or variantOptionLoadFailed when the options could not be read). rotation turns a text element, a logo or a photo clockwise inside its box, which keeps its position and size: 0, 90, 180 or 270, and 0 is stored as absent. A barcode, a QR code or a line does not turn: a rotation other than 0 on one is refused with invalid_request, and so is any other value, one detail line per element at fault. GET a built-in paper size to see a complete example of every type you need.
PATCH /api/v1/templates/:id
Changes the name, the layout or both. Every change bumps the version and stores an immutable snapshot, so a job printed before the change reprints with the same layout. Built-in paper sizes answer 409 template_read_only: GET the paper size, POST it under your name, then edit that one.
curl -X PATCH https://labelcraft.tech/api/v1/templates/cmf1x9q2h0001 \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Shelf 62 x 29 (v2)" }'DELETE /api/v1/templates/:id
Archives the template: it leaves the list and can no longer print, and answers 204. Nothing is erased, so past jobs keep reprinting from their snapshots. Built-in paper sizes answer 409.
curl -X DELETE https://labelcraft.tech/api/v1/templates/cmf1x9q2h0001 \
-H "Authorization: Bearer YOUR_KEY"POST /api/v1/barcodes/audit
Runs the app's barcode check over a list of values, the same check behind the Barcode check page and the pre-print warnings. Nothing is read from the shop and nothing is written: send the codes your system holds and get, for each, whether it is a valid retail GTIN, missing, invalid (with the corrected value when only the check digit is wrong), a restricted prefix (in-store, coupon, ISBN) or an internal Code 128 value. An 8-digit value the app reads as a UPC-E (starting with 0, outside the 0000 block GS1 leaves empty) gets no corrected value: it may be the UPC-E of a UPC-A, which upcA names.
curl https://labelcraft.tech/api/v1/barcodes/audit \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "values": ["4006381333931", "4006381333932", "SKU-0042"] }'- values (array of strings, required): 1 to 500 values of at most 64 characters.
{
"results": [
{ "value": "4006381333931", "status": "valid", "format": "GTIN-13", "message": "..." },
{ "value": "4006381333932", "status": "invalid", "suggestion": "4006381333931", "message": "..." },
{ "value": "SKU-0042", "status": "internal", "message": "..." }
]
}- status: valid, missing, invalid, restricted or internal. suggestion appears only when a corrected value exists. prefix names the GS1 range when it is known. upcA appears when the value may be a UPC-E: the 12-digit UPC-A it stands for.
Errors
Every error is a JSON object { error: { code, message } } with the extra fields named below. The message says what to change; the code is stable.
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | no bearer token, unknown, malformed or revoked key, or the shop uninstalled the app |
| 402 | plan_required | the key's shop is below the Retail Pro plan; carries plan and pricingUrl |
| 429 | rate_limited | more than 60 requests in the trailing minute; carries retryAfterSeconds and a Retry-After header |
| 413 | payload_too_large | the body is above 1048576 bytes; carries maxBytes |
| 400 | invalid_request | the body is not valid; details[] names each field |
| 400 | too_many_labels | the quantities sum above 2000; carries max and requested |
| 404 | template_not_found | no paper size slug and no non-archived template of this shop matches |
| 409 | template_read_only | PATCH or DELETE on a built-in paper size; create your own from it |
| 409 | template_conflict | another change landed on the template while yours was saving; GET it again and retry. Nothing saved |
| 409 | template_limit_reached | POST /templates past 200 non-archived templates; carries max and count |
| 500 | write_failed | a template write failed after validation; carries a reference to quote to support. Nothing saved |
| 422 | layout_not_printable | an element sits outside the label; carries faults[] with the element, the overflow per edge and the box that would fit. Nothing saved |
| 422 | variants_not_found | one or more variant ids returned nothing on this shop; carries missing[]. Nothing printed, nothing counted |
| 409 | printnode_not_connected | GET /printers, or a print with printerId, on a shop with no PrintNode account connected in LabelCraft. Nothing printed, nothing counted |
| 404 | printer_not_found | PrintNode lists no printer with that printerId on the account connected to the shop; carries printerId. Nothing printed, nothing counted |
| 409 | printer_offline | the printer is not online in PrintNode, or its computer is not connected; carries printer. Nothing printed, nothing counted |
| 502 | printnode_error | PrintNode refused the key or the job, or could not be reached; carries stage (printers for the list, job for the job), kind, retryable, and printNodeCode when PrintNode gave one. Nothing counted. stage job with kind network or server means PrintNode never confirmed the job, which may still print: retryable is then false, so check the job list in PrintNode before you retry |
| 422 | held_from_printer | a print with printerId whose product photo or variants' options could not be read, so labels would have printed without them; carries reason and warnings[]. Nothing printed, nothing counted |
| 405 | method_not_allowed | wrong HTTP method; the Allow header names the right one |
| 503 | shop_unavailable | the shop's offline access is gone, or Shopify refused the token (uninstalled, frozen or locked store); carries detail. Open LabelCraft once in the Shopify admin of that shop, then retry |
| 500 | render_failed | something failed after validation; carries a reference to quote to support. The job was not recorded and nothing was counted |
What the API shares with the app
The print endpoint calls the same functions the in-app print calls: the same variant read, the same money formatting, the same metafield resolution, the same brand kit rules for the plan, the same font and barcode floors before and after the merchant's own printer calibration, and the same render engine. A test in the code base fails if one of those calls is replaced by a copy or dropped.
It sends to PrintNode only when printerId names a printer, and then through the functions of the app's Send to printer: the same online check before sending, the same rule that holds a print back, the same job.
What the API does not do: it never asks for a review, never appends the calibration ruler, and never offers the in-app metafield fixes.
Coming next
Generating barcodes for products that have none (writing to the catalogue) is planned as a further slice. It is not available yet, and this page only documents what the code does today.
Questions, or a system you want to connect: support@labelcraft.tech.