Why you'd want this
If you've integrated with a carrier like PostNL, DHL or DPD, you've probably received shipping labels as ZPL: plain text meant for a Zebra thermal printer. That's great when a Zebra printer sits next to you. It's annoying everywhere else.
Some situations where you need ZPL as a PDF or image instead:
- You want to preview a label in your admin panel before printing it.
- Your warehouse has a regular office printer, not a thermal one.
- You need to archive labels or email them to a customer.
- You're debugging a label layout and don't want to waste label stock on every tweak.
In this tutorial we'll convert ZPL to PDF and PNG with a single HTTP request, using Caretly, a ZPL rendering API I built. We'll do it with cURL, Node.js and Python, then look at batching and a few production tips.
ZPL in 30 seconds
ZPL (Zebra Programming Language) describes a label as a list of commands. Each command starts with a caret (^). Here's a small shipping label we'll use throughout this post. Save it as label.zpl:
^XA
^CI28
^FO40,40^A0N,24,24^FDFrom: Demo Shop B.V.^FS
^FO40,150^A0N,48,48^FDJ. Jansen^FS
^FO40,206^A0N,36,36^FDVoorbeeldstraat 12^FS
^FO40,250^A0N,36,36^FD3511 AB Utrecht NL^FS
^FO40,340^BY3^BCN,150,Y,N,N
^FD3SDEMO012345678^FS
^XZ
The commands you see here:
-
^XAand^XZmark the start and end of a label. -
^CI28switches to UTF-8, so characters like é and ü print correctly. -
^FO40,40sets the field origin: 40 dots from the left, 40 dots from the top. -
^A0N,24,24picks font 0, normal orientation, 24 dots high and wide. -
^FD...^FSholds the field data, the actual text. -
^BY3^BCN,150,Y,N,Ndraws a Code 128 barcode, 150 dots tall, with the human-readable text below it.
Positions are in dots, not pixels or millimetres. A typical 203 dpi Zebra printer has 8 dots per millimetre, which matters when you choose an output resolution later.
Quick preview: the browser playground
If you just want to see what a label looks like, you don't need any code. Paste the ZPL above into the Caretly playground and the label renders as you type. Clicking a field on the rendered label jumps to the line of ZPL that drew it, which is handy when you're hunting for the command that puts a barcode 10 dots too far to the right.
The playground is free and needs no account. Its output carries a PREVIEW watermark, so it's for checking layouts, not for printing.
Rendering ZPL with the API
First, create a test key on the API keys page of the Caretly console. Test keys are free and stamp TEST on every label, so you can build your whole integration without paying anything. Export the key as an environment variable:
export ZPL_API_KEY="zpl_test_..."
The API is one endpoint. You POST the raw ZPL as the request body and get a PDF back.
cURL
curl -X POST "https://caretly.dev/render?format=pdf" \
-H "Authorization: Bearer $ZPL_API_KEY" \
-H "Content-Type: text/plain" \
--data-binary @label.zpl \
-o label.pdf
Use --data-binary, not -d. Plain -d strips newlines, which can break multi-line ZPL.
Node.js
This uses the built-in fetch in Node 18 and later, so there's nothing to install.
import { readFile, writeFile } from "node:fs/promises";
const zpl = await readFile("label.zpl");
const res = await fetch("https://caretly.dev/render?format=pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ZPL_API_KEY}`,
"Content-Type": "text/plain",
},
body: zpl,
});
if (!res.ok) {
throw new Error(`Render failed: ${res.status} ${await res.text()}`);
}
await writeFile("label.pdf", Buffer.from(await res.arrayBuffer()));
console.log("Pages:", res.headers.get("X-Page-Count"));
Python
import os
import requests
with open("label.zpl", "rb") as f:
zpl = f.read()
res = requests.post(
"https://caretly.dev/render",
params={"format": "pdf"},
headers={
"Authorization": f"Bearer {os.environ['ZPL_API_KEY']}",
"Content-Type": "text/plain",
},
data=zpl,
)
res.raise_for_status()
with open("label.pdf", "wb") as f:
f.write(res.content)
print("Pages:", res.headers["X-Page-Count"])
If something goes wrong, the error body is plain text that says what to fix. A 401 means the key is missing, invalid, expired or revoked.
Open label.pdf and you'll see the label, barcode included, ready for any regular printer.
PNG instead of PDF
For previews in a web page, an image is often easier to show than a PDF. Set format=png and choose the resolution with dpmm, in dots per millimetre:
curl -X POST "https://caretly.dev/render?format=png&dpmm=12" \
-H "Authorization: Bearer $ZPL_API_KEY" \
--data-binary @label.zpl \
-o label.png
Pick the dpmm that matches the printer the ZPL was written for: 6 (152 dpi), 8 (203 dpi, the default), 12 (300 dpi) or 24 (600 dpi). ZPL positions are in dots, so the wrong value makes the label come out too big or too small.
A PNG holds one page. If your ZPL contains several labels, pick one with page, starting at 1.
Going to production
Many labels at once
A ZPL file can hold many labels, one ^XA ... ^XZ block after another, and ^PQ adds copies. Send the whole file and you get back one PDF with a page per label. The X-Page-Count header tells you how many pages you received.
That's how you'd print a day's worth of parcels in one go. In a test with 500 real carrier labels, including bitmap logos, the server rendered them into a single PDF in about 0.4 seconds.
If your labels come as separate files, upload them as multipart/form-data with one file part each:
curl -X POST "https://caretly.dev/render?format=pdf" \
-H "Authorization: Bearer $ZPL_API_KEY" \
-F file=@first.zpl \
-F file=@second.zpl \
-F file=@third.zpl \
-o labels.pdf
Pages follow the upload order, and the X-File-Page-Counts header lists the pages per file, for example 2, 1, 1. Each file gets a fresh printer state, so settings like ^PW or stored graphics in one file don't leak into the next.
Labels with embedded bitmaps get large but compress well. Add Content-Encoding: gzip and send the body gzipped:
gzip -c labels.zpl | curl -X POST "https://caretly.dev/render?format=pdf" \
-H "Authorization: Bearer $ZPL_API_KEY" \
-H "Content-Encoding: gzip" \
--data-binary @- \
-o labels.pdf
One request can carry up to 100 MiB (measured after decompression) and 1,000 pages. Split bigger batches over several requests.
Safe retries with Idempotency-Key
Networks fail. If your request times out, you don't know whether the label was rendered and billed. Send an Idempotency-Key header, any string up to 255 characters. A retry with the same key, the same labels and the same query parameters is billed only once:
const res = await fetch("https://caretly.dev/render?format=pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ZPL_API_KEY}`,
"Content-Type": "text/plain",
"Idempotency-Key": `order-${order.id}`,
},
body: zpl,
});
Your order or shipment number makes a natural key, because it stays the same on every retry of that job.
The API is also rate limited per key, at about 50 requests per second with bursts of 200. That's mainly there to stop runaway loops. If you hit it, you get a 429 with a Retry-After header saying how many seconds to wait.
Keeping an eye on usage
Every response includes X-Labels-Billed: each page, ^PQ copies included, for a live key, 1 for a PNG, and 0 for a test key. Log it next to your order ID and you can reconcile your bill against your own records. There's also a usage API that returns monthly, daily and per-key totals as JSON, if you'd rather pull usage into a dashboard.
Going live
When everything works with the test key, swap in a live key. Live labels cost €0.002 each, with no subscription. Labels are rendered in the EU and not stored, which keeps the GDPR conversation short when your labels contain customer addresses.
Wrapping up
Turning ZPL into a PDF or PNG takes one POST request: raw ZPL in, printable file out, no printer or driver involved. From there you can show label previews in your app, print on ordinary printers, or archive labels as PDFs.
Useful links:
- Playground, to try your own ZPL in the browser
- Docs, for the full API reference and supported ZPL commands
- Free test key, to start integrating
I built Caretly because I kept needing this myself, so I'd love to hear how you handle labels. If you run into a ZPL command that doesn't render the way your printer does, leave a comment below.
Top comments (1)
Previewing without burning label stock is the use case every warehouse integration eventually hits — layout debugging against a real thermal printer is slow, expensive, and somehow always happens the day the label roll is nearly out. The archiving point deserves equal billing: when a carrier dispute turns on what a label actually said, a rendered PDF kept with the shipment record settles it in a way the raw ZPL, readable by almost nobody outside the integration team, never will. The dots-versus-resolution trap is also real, since a label that looks right at one DPI and shifts at another is a scaling bug, not a data bug, and teams lose hours checking the wrong layer.