Search sections across the site.

Documentation

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

See also: API keys·Plans

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