Skip to main content

ZPL how-to

Send ZPL to a Zebra printer from C#

A Zebra printer draws the label itself from the ZPL it receives. Your C# only has to get the ZPL there unchanged, which rules out the usual .NET printing classes.

What goes wrong

PrintDocument and the rest of System.Drawing.Printing draw graphics through the printer driver. Give them ZPL and the printer prints the ZPL source as text, or a blank label. The ZPL has to reach the printer as raw bytes, by one of the two routes below.

The code below needs .NET 6 or later, in a project with the defaults a new one gets: ImplicitUsings and Nullable both on. It does not build on .NET Framework.

The label used throughout, on 4 × 2 inch stock:

zpl
^XA
^FO40,40^A0N,50,50^FDHello from C#^FS
^XZ

A network printer: port 9100

A networked Zebra printer listens on TCP port 9100 and prints whatever ZPL arrives there. Open a connection, write the bytes, close it.

csharp
using System.Net.Sockets;
using System.Text;

public static class ZebraPrinter
{
    // Port 9100 is the printer's raw port: whatever arrives there is printed as ZPL.
    public static async Task SendAsync(string host, string zpl, int port = 9100)
    {
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        using var client = new TcpClient();
        await client.ConnectAsync(host, port, timeout.Token);
        await using var stream = client.GetStream();
        await stream.WriteAsync(Encoding.UTF8.GetBytes(zpl), timeout.Token);
    }
}
csharp
await ZebraPrinter.SendAsync("192.168.1.50", "^XA^FO40,40^A0N,50,50^FDHello from C#^FS^XZ");
  • Use the printer's IP address or host name. Print a configuration label from the printer to find it.
  • Port 9100 has to be reachable from where the code runs: a firewall between them shows up as a timeout or a refused connection, not as an error from the printer.
  • Several labels can go in one write, one ^XA … ^XZ block after another.
  • Port 9100 sends nothing back, so a write that succeeds means the printer received the bytes, not that the label printed. To check for an open head or no paper, send ~HS and read the status the printer returns.

A USB printer on Windows: the spooler, in RAW mode

A printer installed in Windows takes jobs through the print spooler. Sent with the data type RAW, the spooler passes the bytes straight to the printer and the driver never renders them. .NET has no managed API for this, so it calls winspool.drv directly. This also works for a printer shared from another machine.

csharp
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;

public static class RawPrinter
{
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private class DocInfo
    {
        public string? DocName;
        public string? OutputFile;
        public string? DataType;
    }

    [DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool OpenPrinter(string name, out IntPtr printer, IntPtr defaults);

    [DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool StartDocPrinter(IntPtr printer, int level, [In] DocInfo doc);

    [DllImport("winspool.drv", SetLastError = true)]
    private static extern bool WritePrinter(IntPtr printer, byte[] bytes, int count, out int written);

    [DllImport("winspool.drv", SetLastError = true)] private static extern bool StartPagePrinter(IntPtr printer);
    [DllImport("winspool.drv", SetLastError = true)] private static extern bool EndPagePrinter(IntPtr printer);
    [DllImport("winspool.drv", SetLastError = true)] private static extern bool EndDocPrinter(IntPtr printer);
    [DllImport("winspool.drv")] private static extern bool ClosePrinter(IntPtr printer);

    // Sends ZPL to a printer installed in Windows. "RAW" tells the spooler to
    // pass the bytes through, so the driver never renders them.
    public static void Send(string printerName, string zpl)
    {
        if (!OpenPrinter(printerName, out var printer, IntPtr.Zero)) throw new Win32Exception();
        try
        {
            if (!StartDocPrinter(printer, 1, new DocInfo { DocName = "Label", DataType = "RAW" }))
                throw new Win32Exception();
            try
            {
                if (!StartPagePrinter(printer)) throw new Win32Exception();
                var bytes = Encoding.UTF8.GetBytes(zpl);
                if (!WritePrinter(printer, bytes, bytes.Length, out var written)) throw new Win32Exception();
                if (written != bytes.Length)
                    throw new IOException($"The spooler took {written} of {bytes.Length} bytes.");
                if (!EndPagePrinter(printer)) throw new Win32Exception();
            }
            finally { EndDocPrinter(printer); }
        }
        finally { ClosePrinter(printer); }
    }
}
csharp
RawPrinter.Send("ZDesigner ZD421-203dpi ZPL", zpl);
  • For a printer on this machine, the name is the printer's name exactly as Windows shows it under Printers & scanners. For one shared from another machine, use its share path, \\server\share: the name Windows shows for it is not one OpenPrinter accepts.
  • Windows only: on Linux or macOS, send to port 9100 or through CUPS as a raw queue.

Things that trip people up

  • The printer prints the ZPL as text. It is not in ZPL mode: some Zebra models also speak EPL or a line-print mode. Set the printer language to ZPL from its menu or its web page.
  • Part of a value is missing. A ^ or ~ in your data is read as a command. Printing ^, ~ and _ in ZPL shows the fix.
  • Accented letters print wrong. The code above sends UTF-8. Put ^CI28 after ^XA so the printer reads the bytes as UTF-8; it needs firmware V60.14, V50.14 or later.

The same label with zeepler

Send this body to POST https://api.zeepler.com/v1/zeeplify with your API key in the x-api-key header. It is a working request, not an illustration: on every change, our tests run this same file through the API's code and compare the result with the ZPL below.

json
1{
2 "config": {
3 "size": "4x2"
4 },
5 "data": {
6 "item": "Hex bolt M8 x 40",
7 "sku": "HB-M8-40-Z"
8 },
9 "templates": [
10 {
11 "type": "Text",
12 "coords": [40, 40],
13 "font": "0,50",
14 "dataPath": "item"
15 },
16 {
17 "type": "BarCode",
18 "coords": [40, 120],
19 "dataPath": "sku"
20 }
21 ]
22}

And the ZPL that comes back, ready to send to the printer:

zpl
^XA^MCY^XZ
    ^XA
    ^SZ2
    ^MMT
    ^MNY
    ^LL406
    ^PW812
    ^LH0,0
^CF0,50^FO40,40^FDHex bolt M8 x 40^FS
^BY2,2,59^FO40,120^BCN,,N,N,N,A^FDHB-M8-40-Z^FS
^XZ

Instead of building ZPL strings in C#, send the values and the layout as JSON and send whatever comes back to the printer with either method above. This is the C# that sent the request shown:

csharp
using System.Net.Http.Json;

public static class Labels
{
    // The key comes from configuration, never from source code. Fail here, not
    // with a 403 from the API, when it is missing.
    private static readonly string ApiKey =
        Environment.GetEnvironmentVariable("ZEEPLER_API_KEY") is { Length: > 0 } key
            ? key
            : throw new InvalidOperationException("Set ZEEPLER_API_KEY to your zeepler API key.");

    // One HttpClient for the life of the app.
    private static readonly HttpClient Zeepler = new()
    {
        BaseAddress = new Uri("https://api.zeepler.com/v1/"),
        DefaultRequestHeaders = { { "x-api-key", ApiKey } },
    };

    public static async Task<string> BinLabelAsync(string item, string sku)
    {
        var body = new
        {
            config = new { size = "4x2" },
            data = new { item, sku },
            templates = new object[]
            {
                new { type = "Text", coords = new[] { 40, 40 }, font = "0,50", dataPath = "item" },
                new { type = "BarCode", coords = new[] { 40, 120 }, dataPath = "sku" },
            },
        };
        using var response = await Zeepler.PostAsJsonAsync("zeeplify", body);
        var text = await response.Content.ReadAsStringAsync();
        // A 400 lists every problem with the body, each with the field it is in.
        if (!response.IsSuccessStatusCode)
            throw new HttpRequestException($"zeeplify answered {(int)response.StatusCode}: {text}");
        return text;
    }
}
csharp
var zpl = await Labels.BinLabelAsync("Hex bolt M8 x 40", "HB-M8-40-Z");
await ZebraPrinter.SendAsync("192.168.1.50", zpl);

zeepler escapes ^, ~ and _ in the values for you, and answers a body it cannot turn into a label with a 400 naming each problem field, before anything reaches the printer.

zeepler's ZPL does not set ^CI28. If your values have accented letters or any other character outside ASCII, add it to each label before sending:

csharp
zpl = zpl.Replace("^XA", "^XA^CI28");

Sending it

  • Call the API from your own server or integration engine, not from a web page. An API key in a browser is a key anyone can copy.
  • A successful call answers 200 with the ZPL as plain text. A request the API cannot turn into a label answers 400 with every problem listed by field, and is not billed.
  • Send the ZPL to a Zebra or other ZPL printer at 203 dpi. At 300 dpi the same label prints at two-thirds of the size.

The API reference has every field and status code.

Related guides

  • QR code size in ZPL: How ^BQ magnification sets the size of a QR code, and how to work out the size in dots.
  • Printing ^, ~ and _ in ZPL: Why a caret or tilde in your data cuts the field short, and how ^FH prints them.
  • Font size in ZPL: Setting text height with ^A and ^CF, converting points to dots, and why ^CF carries over.

Help with an integration

Email info@zeepler.com. You will get a reply within 2 business days. The support policy covers response times and version changes.