How to test ZPL without a Zebra printer
Zebra printers have no screen, no preview, and no test mode. The traditional way to test a label is to print it, walk to the printer, and squint, which is why label bugs ship to production more than any other kind of output bug. Two better ways:
1. Render the ZPL to an image
Paste ZPL into the free viewer and get a pixel-true PNG at exact label size and density: what you see is what the printhead does. Or from code:
curl https://api.stripyhorse.io/v1/render.png \
-H "X-Api-Key: sh_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"zpl":"^XA^FO50,50^A0N,45,45^FDHello^FS^XZ","preset":"4x6"}' -o label.png
Rendering answers "what does this ZPL look like?", but it doesn't test the thing that actually breaks in production: your system's printing pipeline.
2. Point your code at a virtual Zebra printer
Real label bugs live in the integration: the template variables your WMS fills in, the
bytes your print service actually sends, the ~HS status polling, the reconnect logic
when the printer jams. To test those you need something that behaves like a printer,
not an image converter.
A virtual Zebra printer is a hosted endpoint that speaks the printer's own protocols:
- Raw TCP - the same port-9100 socket every warehouse system already prints to. Point your existing code at a different host:port; zero code changes. Each printer gets its own port, and the demo below is on 9101.
~HShost status - three STX/ETX lines with fault flags, paper-out, buffer counts, exactly like firmware, so your status-polling code is exercised for real.- SGD queries (
! U1 getvar "device.friendly_name") for discovery flows. - Every job it receives is rendered and stored (the raw ZPL your system sent and the PNG of what it would print), queryable over REST.
Try it right now against the public demo printer, no signup:
printf '^XA^FO50,50^A0N,45,45^FDhello from my terminal^FS^XZ' | nc -q1 print.stripyhorse.io 9101
Many warehouse networks and printer configs allow-list printers by IP address and reject hostnames outright. The account page and the demo show the current address next to the hostname for exactly that case (it is the hostname's own DNS record, looked up for you); prefer the hostname where you can, since the address can change when the service moves.
Testing label printing in CI
This is where virtual printers earn their keep. The pattern:
- Your pipeline creates a printer via the API and gets a TCP address.
- Your fulfillment code runs against that address, exactly as in production.
- The test asserts on captured jobs, and fails the build if a template change broke a label.
sim = SimulatorApi(client)
printer = sim.create_printer(CreatePrinterInputBody(name="ci-run", preset="4x6"))
run_fulfillment_against(f"{printer.tcp.host}:{printer.tcp.port}")
jobs = sim.list_jobs(printer.id)
assert len(jobs.jobs) == 1
assert jobs.jobs[0].status == "rendered"
Golden-diff the rendered PNGs and a one-pixel barcode regression fails in CI instead of on the dock. You can also inject faults (paper out, head open, paused) and watch jobs hold in the receive buffer, then flush on clear: the failure modes you can never trigger politely on production hardware.
Or fit a finite roll and let the printer find the end of it for you:
curl https://api.stripyhorse.io/v1/printers/$PRINTER_ID/media \
-H "X-Api-Key: $STRIPY_HORSE_API_KEY" -H "Content-Type: application/json" \
-d '{"labels":500,"ribbonMetres":74}'
The roll runs down as labels print, paper out raises itself when it is spent, and a run that is longer than the roll prints what is left and holds the rest. That is the bug that only ever shows up on the night shift, on demand and in CI.
Keeping strangers off your CI printer
A printer port answers whoever finds it, the same as a 9100 printer on a LAN. That is what lets any driver talk to it unchanged, and it also means someone who guesses the port could put a label on your job wall and spend your monthly quota.
For CI, where the runner's address is different every run, have the label name its own
printer. Create it with accessMode: "token" (or flip it on the printer's page), and put
the printer's ingest token in a ZPL comment:
printf '^XA^FX STRIPYHORSE:pit_your_token_here ^FS^FO50,50^A0N,45,45^FDhello^FS^XZ' | nc -q1 print.stripyhorse.io 9100
^FX is a comment in ZPL, so the stream is still valid ZPL that begins with ^XA. That
matters if your code builds the label itself and checks it before sending, which plenty
of print clients do: nothing has to go above the ^XA, and the comment can sit anywhere
in the first few kilobytes of the stream.
The token is taken out before the label is rendered, so what prints, and what you see on the job wall, is the label you wrote. A stream that names no printer is refused out loud rather than silently swallowed, because a print client whose write succeeded and whose label never appeared is the worst way for this to fail.
Sending the same bytes to a real Zebra prints the label and ignores the comment, which is
the point of using one: every printer that speaks ZPL understands ^FX.
accessMode: "ip" is the other way in, and it is for one situation: a system whose output
you cannot change. An old WMS, a shipping station, software nobody has the source to any
more. It sends the label it has always sent, so the address it arrives from names the
printer, because nothing in the stream can. Claim the address once and every connection
from it lands on your printer with nothing added to the stream at all. If you can put the
token in the label, do that instead: it works from an address that is different every
run, and this one does not.
FAQ
Is there a free ZPL viewer online?
Yes. stripyhorse.io/tools/zpl-viewer renders multi-label streams at 6–24 dpmm with rotation, free, no signup. Anonymous renders carry a small FREE PREVIEW watermark; a free account removes it. Labelary is the long-standing alternative; ours adds exact label-size presets and the virtual-printer integration.
Can I run a Zebra emulator locally?
Zebra's own ZebraDesigner has no emulator, and hobby emulators cover fragments of ZPL. The practical options are a rendering engine (this site's is open-source-derived and cross-validated on thousands of carrier labels) or a hosted virtual printer with the TCP/status behavior included.
Does the virtual printer support ^XA…^XZ framing quirks?
Yes: partial frames buffer until complete, multiple labels per connection work, and leftovers flush on disconnect, matching real firmware behavior.
Published 2026-08-24 · Ben Faerber, software engineer, five years building warehouse fulfillment and label-printing systems; maintainer of pdf-to-zpl (27,000+ installs powering production label generation).