Rendered simply. Nothing else.
Z2PL renders Zebra Programming Language into files your application can store, print or show. It is an HTTP API with no SDK to install and no client library to keep current — post the ZPL, get the file back.
Output is vector-first: text, barcodes, rectangles and lines are
drawn as real primitives rather than rasterised, so a PDF or SVG
stays sharp at any size. Only genuinely raster content —
^GF, ~DG and ^XG images —
becomes pixels.
Conversion is deterministic. The same ZPL with the same parameters returns the same bytes every time, which makes responses safe to cache and safe to diff in tests.
Every failure at every layer returns one error envelope, so a single handler covers the whole API. Start with the Quickstart, or jump to All endpoints if you would rather read the surface first.
Quickstart
Get a working PDF in two calls, with no SDK to install.
The API speaks plain HTTP. Sign up for a key, post ZPL, receive a file. Everything else on this page is detail you may never need.
# 1. Sign up. The key is returned once — store it now.
curl -X POST $API/accounts \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","tier":"free"}'
# 2. Convert.
curl -X POST $API/convert \
-H "Authorization: Bearer $KEY" \
-H 'Accept-Encoding: gzip' \
--data-binary '^XA^FO50,50^A0N,40,40^FDHello^FS^XZ' \
--output label.pdf
See also: Authentication·Output formats·Plans
Authentication
Send your API key as a bearer token on every request.
Three endpoints are open: GET /health,
GET /plans and POST /accounts.
Everything else requires a key.
Authorization: Bearer zpk_xxxxxxxxxxxxxxxx
Keys are stored only as a hash, so nobody can show you an existing key — not even us. A lost key is replaced, never recovered.
A missing, malformed or revoked key returns 401 with
code unauthorized.
See also: API keys·Rotating a leaked key
Your first conversion
Post ZPL as the raw request body and read the file back.
The body is your ZPL, sent verbatim — not JSON, not form-encoded.
Each label is one complete ^XA … ^XZ frame; send as
many as your plan allows in one request.
curl -X POST $API/convert \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: text/plain' \
--data-binary @labels.zpl \
--output labels.pdf
Python
import requests
zpl = "^XA^FO50,50^A0N,40,40^FDHello^FS^XZ"
r = requests.post(
f"{API}/convert",
headers={"Authorization": f"Bearer {KEY}"},
data=zpl.encode(),
)
r.raise_for_status()
open("label.pdf", "wb").write(r.content)
Node
const res = await fetch(`${API}/convert`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}` },
body: "^XA^FO50,50^A0N,40,40^FDHello^FS^XZ",
});
if (!res.ok) throw new Error((await res.json()).error.code);
await writeFile("label.pdf", Buffer.from(await res.arrayBuffer()));
Go
req, _ := http.NewRequest("POST", api+"/convert", strings.NewReader(zpl))
req.Header.Set("Authorization", "Bearer "+key)
res, err := http.DefaultClient.Do(req)
See also: Output formats·Handling errors
Output formats
Four formats. Every one returns exactly one file.
Pick by answering two questions: do you want one label or all of them, and do you want print-ready output or an image?
| format | You get | Use it when | Content-Type |
|---|---|---|---|
pdf |
1 PDF file — 1 label, 1 page | The default. One label, ready to print. | application/pdf |
multi-label-pdf |
1 PDF file — many labels, one page each | A batch in one document — print a run, or email a whole shipment's labels as a single attachment. | application/pdf |
svg |
1 SVG file — 1 label | Show it on a web page, or edit it in a vector tool. Scales to any size without blurring. | image/svg+xml |
png |
1 PNG file — 1 label | A picture of the label — a preview, a thumbnail, or anywhere an ordinary image is expected. | image/png |
Only multi-label-pdf returns more than one
label. The other three render a single label — the first one
in your ZPL, unless
?label= names another.
So sending a three-label document as pdf gives you
label 1, not all three.
Every response is exactly one file, never a ZIP, so you can write
the body straight to disk under the extension its
Content-Type names.
# One label, print-ready — ?format= omitted means pdf
curl -X POST $API/convert \
-H "Authorization: Bearer $KEY" \
--data-binary @labels.zpl --output label.pdf
# Every label, one page each, in one document
curl -X POST "$API/convert?format=multi-label-pdf" \
-H "Authorization: Bearer $KEY" \
--data-binary @labels.zpl --output labels.pdf
# The third label as an image
curl -X POST "$API/convert?format=png&label=3" \
-H "Authorization: Bearer $KEY" \
--data-binary @labels.zpl --output label-3.png
Omitting ?format= gives you pdf. An
unknown value returns 400 with code
invalid_request_parameter, naming what you sent and
what is accepted.
See also: Resolution·Choosing a label
Resolution
Set raster density with ?dpi=, on PNG only.
Accepted values are 152, 203, 300 and 600. The default is
203, the density of a standard thermal printer.
Vector formats refuse the parameter rather than ignoring it.
?format=pdf&dpi=300 returns 400, not
a PDF — a caller asking for a 600-DPI PDF has misunderstood
something, and silence would let that misunderstanding reach their
billing expectations.
Resolution above your plan's ceiling returns 403 with
code output_dpi_not_available, reporting both the
value you asked for and the one your plan allows.
POST /convert?format=png&dpi=300
See also: Plans·Error codes
Choosing a label
Pick which label a single-label format renders with
?label=.
pdf, svg and png each
render one label. Send a document holding several and you get the
first one — ?label= chooses a different one.
Labels are counted from 1.
POST /convert?format=png&label=3
Two cases return 400 rather than guessing: a label
past the end of the document (the error names how many the
document actually holds), and ?label= sent with
multi-label-pdf, which renders every label by
definition and so cannot narrow to one.
See also: Output formats·Supported ZPL
Response headers
Read what the server did, and how close you are to your ceiling.
| Header | Meaning |
|---|---|
x-label-count |
Labels billed for this request |
x-output-format |
The format actually produced |
x-output-dpi |
Resolution used (PNG only) |
X-RateLimit-Limit-Second |
Your plan's per-second ceiling |
X-RateLimit-Limit-Day |
Your plan's daily ceiling |
X-RateLimit-Remaining-Second |
Requests left in this second; never negative |
X-RateLimit-Remaining-Day |
Requests left today |
X-RateLimit-Reset-Second |
Fractional seconds until the per-second counter resets |
Retry-After |
Seconds to wait, on a 429 |
The rate headers come back on every response, not only
refusals — back off before you are told to.
x-output-format is echoed because content type cannot
distinguish a combined PDF from a per-label one once unpacked.
See also: Rate limits·Retry and backoff
Sign up
Create an account and receive its first API key in one call.
POST /accounts
{ "email": "you@example.com", "tier": "free" }
Response:
{
"id": "8f14e45f-ceea-467a-9f3c-2b1d0e4a77c1",
"email": "you@example.com",
"tier": "free",
"stripe_customer_id": null,
"created_at": "2026-09-14T08:21:33Z",
"api_key": "zpk_live_9c1f…"
}
An omitted or unknown tier creates a Free account. A
tier that exists but is not open for signup returns
403 tier_not_available rather than
quietly downgrading you.
| Status | Code | Cause |
|---|---|---|
400 |
invalid_email |
Not a valid address |
400 |
invalid_request_body |
Body is not valid JSON |
409 |
email_already_registered |
Use your existing key |
403 |
tier_not_available |
That plan is not open |
API keys
Issue, list and revoke keys without naming your account.
The key in your header already identifies you, so these routes carry no account id. Use one key per environment — revoking staging then never takes production down.
| Method | Path | Returns |
|---|---|---|
POST |
/me/keys |
{"api_key": "zpk_…"}, shown once |
GET |
/me/keys |
Every key, newest first |
DELETE |
/me/keys/{id} |
204, effective immediately |
The listing never contains key material — only ids and dates:
[
{ "id": "b3d1…", "created_at": "2026-09-14T08:21:33Z", "revoked_at": null },
{ "id": "a02c…", "created_at": "2026-08-02T11:05:12Z", "revoked_at": "2026-09-01T09:14:00Z" }
]
Revoked keys stay in the list on purpose: someone investigating a leak needs to see that a key was revoked, not watch it vanish.
See also: Rotating a leaked key·Authentication
Usage and limits
Check what you have spent against every ceiling your plan imposes.
GET /me/usage
{
"tier": "free",
"per_second": { "used": 0, "limit": 5 },
"per_day": { "used": 142, "limit": 5000 },
"labels_per_month": { "used": 3801, "limit": 50000 }
}
This endpoint does not count against your quota. A client that has just been throttled can still ask why — a limit you cannot inspect while blocked is not an actionable limit.
GET /me returns the account itself: id, email, tier
and creation date.
See also: Rate limits·Plans
Plans
Compare the three plans open for signup today.
| Plan | Price | Req/s | Req/day | Labels/month |
|---|---|---|---|---|
| Free | $0 | 5 | 5,000 | 50,000 |
| Personal Normal | $15 | 5 | 5,000 | 350,000 |
| Personal Plus | $39 | 5 | 10,000 | 1,000,000 |
Per-request ceilings differ too: Free accepts 0.5 MiB and 30 labels per call, the two Personal plans 1 MiB and 50 labels. Free and Personal top out at 300 DPI; Business gets 600.
GET /plans is the authority and needs no key — the
table above can go stale, that endpoint cannot.
See also: Billing·Resolution
Billing
Upgrade, change a card or cancel through Stripe-hosted pages.
| Method | Path | Returns |
|---|---|---|
POST |
/billing/checkout |
{"url": "https://checkout.stripe.com/…"} |
POST |
/billing/portal |
{"url": "https://billing.stripe.com/…"} |
GET |
/billing/events |
Payment history |
A checkout link is not an upgrade. The plan changes when
Stripe confirms payment, which arrives by webhook — poll
GET /me to see the new tier land.
Card details never pass through our servers. Cancellation happens in the Stripe portal, so there is exactly one place a subscription can be cancelled; a cancelled account drops to Free rather than losing API access.
See also: Plans·Usage and limits
All endpoints
Twelve routes, one error shape, one bearer token.
| Method | Path | Key | Purpose |
|---|---|---|---|
GET |
/health |
— | Liveness |
GET |
/plans |
— | Plans and limits |
POST |
/accounts |
— | Sign up, returns first key |
POST |
/convert |
✓ | ZPL to PDF, SVG or PNG |
GET |
/me |
✓ | The key's own account |
GET |
/me/usage |
✓ | Used against each limit |
POST |
/me/keys |
✓ | Issue a key |
GET |
/me/keys |
✓ | List keys |
DELETE |
/me/keys/{id} |
✓ | Revoke a key |
POST |
/billing/checkout |
✓ | Upgrade link |
POST |
/billing/portal |
✓ | Card, cancel |
GET |
/billing/events |
✓ | Payment history |
See also: Error codes·Authentication
Error codes
Branch on error.code; it is stable, the message is
not.
Every failure at every layer returns the same envelope, so one handler covers all of them.
{
"error": {
"code": "rate_limit_exceeded_per_second",
"message": "too many requests per second for this plan",
"context": { "limit": "5", "window": "1s", "retry_after": "1" }
}
}
Quota and access
| Status | Code | context |
|---|---|---|
401 |
unauthorized |
— |
429 |
rate_limit_exceeded_per_second |
limit, window,
retry_after
|
429 |
rate_limit_exceeded_per_day |
limit, window,
retry_after
|
402 |
monthly_label_quota_exceeded |
labels_used, labels_limit,
tier
|
403 |
output_dpi_not_available |
requested_dpi, max_dpi,
tier
|
Your input
| Status | Code | context |
|---|---|---|
400 |
invalid_zpl |
phase |
400 |
invalid_request_parameter |
parameter, observed,
accepted
|
413 |
input_size_limit_exceeded |
actual_bytes, max_bytes |
413 |
label_limit_exceeded |
effective_output_pages, max_labels
|
422 |
unsupported_zpl_command |
command, label_index,
byte_offset
|
Ours, not yours
| Status | Code | What to do |
|---|---|---|
502 |
upstream_unavailable |
Retry with backoff |
504 |
conversion_timeout |
Retry, or send fewer labels |
503 |
resource_limit_exceeded |
Retry with backoff |
Neither 502 nor 504 counts against your
monthly labels. Error context never contains raw ZPL or field data
— only safe operational values such as the command name and a byte
offset.
See also: Handling errors·Retry and backoff
Supported ZPL
Render the thirty commands real label files actually use.
Labels must be complete, non-nested, uppercase
^XA … ^XZ frames.
^XA ^XZ ^PW ^LL ^JM ^FO ^FT ^A0 ^CF ^BY
^GB ^BC ^BQ ^BX ^B3 ^BE ^BU ^B2 ^B7 ^FD
^FS ^GF ^XG ~DG ^PQ ^CI ^MC ^PM ^LR ^SZ
Barcodes
Eight symbologies: Code 128 (^BC), Code 39
(^B3), EAN-13 (^BE), UPC-A
(^BU), Interleaved 2 of 5 (^B2), QR
(^BQ), Data Matrix (^BX) and PDF417
(^B7).
Anything outside this set returns 422, naming the
command, the label it appeared on, and its byte offset in your
input — enough to find the one occurrence that failed in a file
that repeats the same command thousands of times.
See also: Error codes·Choosing a label
Rate limits
Understand which of the two 429s you hit, and when it clears.
They are separate codes on purpose: a per-second breach clears in a second, a daily one not until 00:00 UTC. A client that retried a daily breach after one second would simply be refused until midnight.
| Code | Clears | Retry-After |
|---|---|---|
rate_limit_exceeded_per_second |
Within a second | 1 |
rate_limit_exceeded_per_day |
00:00 UTC | Seconds until midnight |
monthly_label_quota_exceeded |
Next month, or on upgrade | — |
Billing routes sit outside the rate limiter: a customer who has exhausted their ceiling is exactly the one most likely to be upgrading, and throttling that path would block the fix for the condition that triggered it.
See also: Usage and limits·Retry and backoff
Handling errors
Write one handler and switch on the code.
The envelope is identical whether the gateway refused you or the engine did, so there is no second shape to parse.
def convert(zpl, fmt="pdf"):
r = requests.post(f"{API}/convert", params={"format": fmt},
headers={"Authorization": f"Bearer {KEY}"}, data=zpl)
if r.ok:
return r.content
err = r.json()["error"]
code, ctx = err["code"], err.get("context", {})
if code == "rate_limit_exceeded_per_second":
time.sleep(int(ctx["retry_after"])); return convert(zpl, fmt)
if code == "monthly_label_quota_exceeded":
raise QuotaExhausted(ctx["labels_used"], ctx["labels_limit"])
if code == "unsupported_zpl_command":
raise BadLabel(ctx["command"], ctx["label_index"], ctx["byte_offset"])
raise ApiError(code, err["message"])
Never match on message. It is written for people and
may be reworded at any time; code is the contract.
See also: Error codes·Retry and backoff
Retry and backoff
Retry only what will succeed on a second attempt.
| Retry | Do not retry |
|---|---|
429 after Retry-After |
400 invalid_zpl |
502 upstream_unavailable |
401 unauthorized |
503 resource_limit_exceeded |
413 size or label limit |
504 conversion_timeout |
422 unsupported command |
Honour Retry-After when it is present; it is exact,
and guessing is worse. For 5xx, use exponential
backoff with jitter and a ceiling — two clients retrying in
lockstep turn a brief outage into a longer one.
Read X-RateLimit-Remaining-Second on successful
responses and pace yourself by it, rather than discovering the
ceiling by hitting it. When it reaches 0, wait
X-RateLimit-Reset-Second — the window is keyed to the
wall clock, so that is usually a fraction of a second, and waiting
a whole second instead sleeps through most of the next window.
Better still, queue instead of retrying. The ceiling is per account, not per process — five workers sending one request each per second are five per second between them. Put the pacing where every request for the account passes through it, and the surplus is delayed rather than refused: measured on one Free key, twelve requests from three workers complete 12 of 12 through a shared queue, against 5 of 12 without one. Same ceiling, same work — the difference is who does the queueing.
Set your queue one below the ceiling. The counter uses a fixed window keyed to the wall clock while your queue runs on its own, so a client pacing itself at exactly the limit can still be refused at a window boundary. One reserved slot costs almost no throughput and makes the queue stable.
See also: Rate limits·Response headers
Rotating a leaked key
Replace a compromised key without interrupting live traffic.
Issue the replacement first. Revoking your only key locks you out of the endpoint you would use to issue another.
# 1. Issue the replacement, using the key that leaked.
curl -X POST $API/me/keys -H "Authorization: Bearer $OLD"
# 2. Deploy the new key, then find the leaked key's id.
curl $API/me/keys -H "Authorization: Bearer $NEW"
# 3. Revoke. Effective immediately.
curl -X DELETE $API/me/keys/$LEAKED_ID -H "Authorization: Bearer $NEW"
Revocation takes effect on the next request — there is no cache to
wait out. The revoked key stays visible in
GET /me/keys with a
revoked_at timestamp, which is what you want while
working out what it touched.
See also: API keys·Authentication
Caching conversions
Reuse output safely, because the same input always returns the same bytes.
Conversion is deterministic: identical ZPL with identical
parameters produces identical output, every time. That makes
responses safe to cache on a hash of the request body plus
format and dpi, and safe to diff in
tests.
Two practical consequences:
- Cached responses cost no labels. Quota is spent per conversion, so serving from your own cache is free.
-
Always send
Accept-Encoding: gzip. A 30-label PDF drops from 22 KB to 1.6 KB. It is not on by default and costs nothing.
See also: Response headers·Usage and limits