Shipping Labels and Event Tickets: QR Codes and Barcodes in PDFs with Api2Pdf

Summary. Shipping labels, packing slips, event tickets, asset tags, boarding-pass-style receipts: they all need a barcode or QR code that scans on the first try, on a page that prints at an exact physical size. Api2Pdf covers both halves. The /zebra endpoint (powered by ZXing) turns a value into a PNG barcode or QR code, and /chrome/pdf/html lays it out at whatever page size your printer expects. This guide builds two working generators, 4x6 thermal shipping labels in Python and signed QR event tickets in Node.js, plus the details that decide whether a barcode scans: format choice, resolution, quiet zones and page sizing. Both scripts use plain HTTP, so they work with any language and any SDK version.

How the pieces fit

  1. Generate the code. GET /zebra?format=...&value=... returns a PNG. You choose the symbology (QR_CODE, CODE_128, EAN_13 and so on) and the pixel size.
  2. Embed it. Base64 the PNG into a data: URI and drop it into your HTML template as an <img>.
  3. Render at physical size. Send the HTML to POST /chrome/pdf/html with width and height set to the label or ticket stock (4in x 6in, for example) and zero margins. One HTML <section> per page gives you a multi-page PDF that a label printer can run in one job.

Step 1: generate a barcode or QR code

/zebra is a GET endpoint authorized with the apikey query string parameter. format and value are required. The optional parameters are width and height (integers, in pixels), showlabel (prints the human-readable text under the bars; defaults to false and is not supported by every format) and outputBinary (defaults to true; set it to false to get a JSON response instead of the image). URL-encode the value, especially when it is itself a URL:

curl -o qr.png \
  "https://v2.api2pdf.com/zebra?apikey=YOUR-API-KEY&format=QR_CODE&value=https%3A%2F%2Fexample.com%2Ft%2F1Z999AA10000000001&width=400&height=400"

curl -o tracking.png \
  "https://v2.api2pdf.com/zebra?apikey=YOUR-API-KEY&format=CODE_128&value=1Z999AA10000000001&width=900&height=260"

The formats you will reach for most:

Use caseformatNotes
Links, tickets, anything a phone scansQR_CODE2D, holds URLs and arbitrary text, tolerates some damage
Tracking numbers, order and SKU ids, warehouse labelsCODE_1281D, full ASCII, compact, read by every handheld scanner
Legacy industrial and asset tagsCODE_391D, uppercase letters, digits and a few symbols; wider than Code 128
Retail productsEAN_13, UPC_A, EAN_8, UPC_EDigits only, fixed length, with a check digit
Small parts, electronics, pharmaDATA_MATRIX2D and very dense, good for tiny labels
IDs and transport documentsPDF_417, AZTEC2D stacked or matrix codes that hold a lot of data

The full list (Codabar, Code 93, ITF, MaxiCode, RSS 14, RSS Expanded, MSI, Plessey, Intelligent Mail and more) is on the barcode options page.

Step 2: embed the image in your HTML

You could point an <img> straight at the /zebra URL and let Chrome fetch it while rendering. It works, but it puts your API key inside the HTML. If that HTML is ever logged, cached or shown in a browser preview, the key leaks with it:

<!-- Works, but your API key is now inside the HTML -->
<img src="https://v2.api2pdf.com/zebra?apikey=YOUR-API-KEY&format=QR_CODE&value=ORDER-48211">

Fetch the PNG on your server and embed the bytes instead:

<!-- Fetched server side, embedded as bytes -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." alt="">

This keeps the key out of the markup, removes a network fetch from the render (so there is no race with the page load), and lets you cache barcodes. The same tracking number always produces the same image, so a reprint never needs a second /zebra call.

Step 3: lay out the page at the stock size

Thermal label printers want a PDF whose page size matches the stock exactly. Set it twice, once in CSS with @page and once in the Chrome options, and give each label a fixed-size box with overflow: hidden so one long address cannot push content onto an extra blank page:

@page { size: 4in 6in; margin: 0; }
.label { width: 4in; height: 6in; box-sizing: border-box; overflow: hidden;
         page-break-after: always; break-after: page; }
.label:last-child { page-break-after: auto; break-after: auto; }
.track img { width: 3.6in; height: 1.1in; image-rendering: pixelated; }
"options": {
  "width": "4in", "height": "6in",
  "marginTop": "0", "marginBottom": "0", "marginLeft": "0", "marginRight": "0",
  "printBackground": true
}

width, height and the four margins are standard Headless Chrome options. Two lines in that CSS matter more than they look. image-rendering: pixelated stops Chrome from smoothing the edges of bars and modules when it scales the PNG, which keeps edges crisp. And the bottom label must not force a page break, or you get a blank trailing page that wastes a label.

The complete Python script: 4x6 shipping labels

This script fetches a Code 128 tracking barcode and a QR code for every shipment in parallel, caches them, builds one HTML document with one 4x6 section per label, and renders a single multi-page PDF. It uses requests rather than the Python SDK: the api2pdf 2.1.0 package on PyPI has no barcode method yet (the client.Zebra helper in the GitHub README is not in that release), and raw HTTP keeps the script independent of the SDK version.

"""Render a batch of 4x6 shipping labels, each with a Code 128 tracking barcode
and a QR code, into one print-ready PDF with Api2Pdf."""
import base64
import html
import os
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache

import requests

API_KEY = os.environ["API2PDF_API_KEY"]
BASE = os.environ.get("API2PDF_BASE", "https://v2.api2pdf.com")
session = requests.Session()


@lru_cache(maxsize=4096)
def barcode_data_uri(fmt: str, value: str, width: int, height: int) -> str:
    """Fetch a PNG from /zebra and return it as a data: URI."""
    r = session.get(
        f"{BASE}/zebra",
        params={"apikey": API_KEY, "format": fmt, "value": value,
                "width": width, "height": height},
        timeout=30,
    )
    r.raise_for_status()
    if not r.headers.get("Content-Type", "").startswith("image/"):
        raise RuntimeError(f"/zebra returned {r.headers.get('Content-Type')}: {r.text[:200]}")
    return "data:image/png;base64," + base64.b64encode(r.content).decode()


CSS = """
@page { size: 4in 6in; margin: 0; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; }
.label { width: 4in; height: 6in; padding: 0.2in; overflow: hidden;
         page-break-after: always; break-after: page;
         display: flex; flex-direction: column; gap: 0.12in; }
.label:last-child { page-break-after: auto; break-after: auto; }
.from { font-size: 9pt; line-height: 1.3; }
.to { border: 2px solid #000; padding: 0.12in; font-size: 13pt; line-height: 1.35; font-weight: bold; }
.svc { font-size: 22pt; font-weight: 900; border-bottom: 3px solid #000; }
.track img { display: block; width: 3.6in; height: 1.1in; image-rendering: pixelated; }
.track .num { font: bold 12pt monospace; text-align: center; letter-spacing: 1px; }
.bottom { display: flex; justify-content: space-between; align-items: flex-end; margin-top: auto; }
.bottom img { width: 1.3in; height: 1.3in; image-rendering: pixelated; }
.meta { font-size: 9pt; line-height: 1.4; }
"""


def label_html(s: dict) -> str:
    e = lambda v: html.escape(str(v))
    track = barcode_data_uri("CODE_128", s["tracking"], 900, 260)
    qr = barcode_data_uri("QR_CODE", f"https://example.com/t/{s['tracking']}", 400, 400)
    return f"""
<section class="label">
  <div class="from">FROM: {e(s['from'])}</div>
  <div class="svc">{e(s['service'])}</div>
  <div class="to">SHIP TO:<br>{'<br>'.join(e(line) for line in s['to'])}</div>
  <div class="track"><img src="{track}" alt=""><div class="num">{e(s['tracking'])}</div></div>
  <div class="bottom">
    <div class="meta">Order {e(s['order'])}<br>Weight {e(s['weight'])}<br>Ship date {e(s['date'])}</div>
    <img src="{qr}" alt="">
  </div>
</section>"""


def render_labels(shipments: list[dict], file_name: str) -> dict:
    # Fetch barcodes in parallel; the cache dedupes repeats.
    with ThreadPoolExecutor(max_workers=8) as pool:
        sections = list(pool.map(label_html, shipments))
    doc = f"<!doctype html><html><head><meta charset='utf-8'><style>{CSS}</style></head><body>{''.join(sections)}</body></html>"
    r = session.post(
        f"{BASE}/chrome/pdf/html",
        headers={"Authorization": API_KEY},
        json={
            "html": doc,
            "fileName": file_name,
            "inline": False,
            "options": {
                "width": "4in", "height": "6in",
                "marginTop": "0", "marginBottom": "0", "marginLeft": "0", "marginRight": "0",
                "printBackground": True,
            },
        },
        timeout=120,
    )
    r.raise_for_status()
    result = r.json()
    if not result.get("Success"):
        raise RuntimeError(result.get("Error"))
    return result


if __name__ == "__main__":
    shipments = [
        {"tracking": f"1Z999AA1{n:010d}", "order": f"SO-{48210 + n}", "service": "GROUND",
         "from": "Acme Supply, 100 Main St, Austin TX 78701",
         "to": ["Jordan Lee", "42 Elm Street, Apt 5", "Portland OR 97205"],
         "weight": "2.4 lb", "date": "2026-10-30"}
        for n in range(1, 4)
    ]
    out = render_labels(shipments, "labels-2026-10-30.pdf")
    print(out["FileUrl"], out["Cost"])

Run it with API2PDF_API_KEY=your-key python3 labels.py. You get a three-page PDF, each page 4 x 6 inches (288 x 432 points), ready to send to a Zebra, Rollo or DYMO 4XL. For a laser printer, change the page size to Letter or A4 and lay the labels out in a CSS grid; the barcode code does not change.

The same idea in Node.js: event tickets with signed QR codes

A QR code that holds just a ticket number is easy to forge: anyone can generate TKT-2026-00002. Put a short HMAC signature next to the id instead. The gate scanner recomputes the signature with your secret and rejects anything that does not match, even offline. This script renders one 8 x 3 inch ticket per page with Node 18+ and the built-in fetch:

// Event tickets: one PDF page per ticket, each with a signed QR code.
// Node 18+ (global fetch). Run: API2PDF_API_KEY=... node tickets.mjs
import { createHmac } from "node:crypto";
import { writeFile } from "node:fs/promises";

const API_KEY = process.env.API2PDF_API_KEY;
const BASE = process.env.API2PDF_BASE ?? "https://v2.api2pdf.com";
const SECRET = process.env.TICKET_SECRET ?? "change-me";

// The QR carries the ticket id plus an HMAC, so the scanner can reject forgeries offline.
const sign = (id) => `${id}.${createHmac("sha256", SECRET).update(id).digest("base64url").slice(0, 22)}`;

async function qrDataUri(value, size = 500) {
  const qs = new URLSearchParams({ apikey: API_KEY, format: "QR_CODE", value, width: size, height: size });
  const res = await fetch(`${BASE}/zebra?${qs}`);
  const type = res.headers.get("content-type") ?? "";
  if (!res.ok || !type.startsWith("image/")) {
    throw new Error(`/zebra ${res.status} ${type}: ${(await res.text()).slice(0, 200)}`);
  }
  return `data:image/png;base64,${Buffer.from(await res.arrayBuffer()).toString("base64")}`;
}

const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

function ticketHtml(t, qr) {
  return `<section class="ticket">
    <div class="info">
      <div class="event">${esc(t.event)}</div>
      <div>${esc(t.venue)} &middot; ${esc(t.when)}</div>
      <div class="seat">Section ${esc(t.section)} &middot; Row ${esc(t.row)} &middot; Seat ${esc(t.seat)}</div>
      <div class="holder">${esc(t.holder)}</div>
      <div class="id">${esc(t.id)}</div>
    </div>
    <img src="${qr}" alt="">
  </section>`;
}

const CSS = `
  @page { size: 8in 3in; margin: 0; }
  body { margin: 0; font-family: Arial, Helvetica, sans-serif; }
  .ticket { width: 8in; height: 3in; box-sizing: border-box; padding: .3in; overflow: hidden;
            display: flex; justify-content: space-between; align-items: center;
            border-left: .35in solid #0b1220; break-after: page; }
  .ticket:last-child { break-after: auto; }
  .event { font-size: 24pt; font-weight: 800; margin-bottom: .08in; }
  .seat { font-size: 14pt; font-weight: 700; margin-top: .15in; }
  .holder { margin-top: .1in; }
  .id { font: 9pt monospace; color: #555; margin-top: .1in; }
  img { width: 2.3in; height: 2.3in; image-rendering: pixelated; }`;

export async function renderTickets(tickets, fileName) {
  const qrs = await Promise.all(tickets.map((t) => qrDataUri(sign(t.id))));
  const html = `<!doctype html><html><head><meta charset="utf-8"><style>${CSS}</style></head><body>${
    tickets.map((t, i) => ticketHtml(t, qrs[i])).join("")}</body></html>`;

  const res = await fetch(`${BASE}/chrome/pdf/html`, {
    method: "POST",
    headers: { Authorization: API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      html, fileName, inline: false,
      options: { width: "8in", height: "3in", marginTop: "0", marginBottom: "0",
                 marginLeft: "0", marginRight: "0", printBackground: true },
    }),
  });
  const out = await res.json();
  if (!out.Success) throw new Error(out.Error);
  return out;
}

const tickets = Array.from({ length: 4 }, (_, i) => ({
  id: `TKT-2026-${String(i + 1).padStart(5, "0")}`, event: "DevConf 2026", venue: "Hall B",
  when: "Nov 14, 2026 9:00 AM", section: "A", row: 3, seat: 10 + i, holder: "Sam Rivera",
}));
const pdf = await renderTickets(tickets, "devconf-tickets.pdf");
console.log(pdf.FileUrl);

And the check at the door, in Python:

import hashlib, hmac, base64

SECRET = b"change-me"

def is_valid(scanned: str) -> bool:
    ticket_id, _, sig = scanned.rpartition(".")
    digest = hmac.new(SECRET, ticket_id.encode(), hashlib.sha256).digest()
    expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()[:22]
    return bool(ticket_id) and hmac.compare_digest(sig, expected)

If you already use the Node SDK, api2pdf 2.1.0 on npm includes zebraGenerateBarcode (alias zebraGenerate), which returns the image bytes when outputBinary is true:

const Api2Pdf = require("api2pdf")   // npm api2pdf 2.1.0
const client = new Api2Pdf(process.env.API2PDF_API_KEY)

const png = await client.zebraGenerateBarcode("CODE_128", "1Z999AA10000000001", {
  width: 900, height: 260, showLabel: false, outputBinary: true
})
// png is the image (a Buffer) to base64 into a data: URI

Make it scan on the first try

  • Request more pixels than you display. Ask /zebra for an image at least two to three times the printed size (a 900 px wide Code 128 for a 3.6 inch slot) and size it down with CSS. A 203 dpi thermal printer needs about 730 dots across 3.6 inches; a 300 px source image would be stretched and blurred.
  • Never stretch a 1D barcode unevenly. Changing the aspect ratio changes bar width ratios. Keep the width and height you requested in proportion with the CSS box.
  • Leave a quiet zone. Scanners need blank margin on both sides of a 1D code and around a QR code. Do not let borders, text or the page edge touch the symbol.
  • Pick the right symbology for the data. EAN_13 and UPC_A only accept digits of a fixed length; alphanumeric ids belong in CODE_128. Long URLs make dense QR codes, so use a short redirect such as https://example.com/t/ID.
  • Print the value as text too. Damaged labels get keyed in by hand. Either use showlabel=true (where the format supports it) or print the value in your own HTML, as both scripts above do.
  • Black on white, no transparency. Keep printBackground on and avoid light grey bars or colored backgrounds behind the code.

Cost and scale

Every call is billed on the standard pay-as-you-go plan, per MB of bandwidth plus per second of compute (see pricing). Barcode PNGs and label PDFs are small, so a batch of labels costs very little. Two habits keep it that way: cache barcode images by value so reprints and duplicates are free, and render many labels into one PDF rather than one request per label. For very large batches (thousands of labels with images), split them into chunks of a few hundred pages per request, or point the render at V2 XL for more memory and a longer timeout. Generated files are hosted for 24 hours. If labels carry customer addresses, call DELETE /file/{responseId} once you have downloaded the PDF.

Troubleshooting

SymptomLikely causeFix
/zebra returns 402 with an x402Version JSON bodyNo apikey on the request, so the endpoint offered x402 pay-per-request insteadAdd apikey=YOUR-API-KEY to the query string
You got JSON instead of a PNGoutputBinary=false was sent, or the request failedCheck the status code and Content-Type before base64-encoding, as both scripts do
Retail code fails to generateWrong length or non-digits for EAN_13 / UPC_ASend the exact digit count the symbology expects, or switch to CODE_128
Barcode prints but will not scanImage upscaled, stretched, or crowding the edgeRequest a larger image, keep the aspect ratio, add white space around it
Extra blank page after every labelContent taller than the page, or a page break after the last labeloverflow: hidden on the label box and break-after: auto on :last-child
Label prints at the wrong sizePDF page size and printer stock differ, or the print dialog scales to fitMatch width/height and @page to the stock; print at 100% / actual size

Wrap-up

Barcodes are one of those features that look like a library decision and turn into a font, DPI and page-size problem. With Api2Pdf it is two calls: /zebra makes a crisp PNG for any common symbology, and /chrome/pdf/html puts it on a page the exact size of your label or ticket. Grab an API key at portal.api2pdf.com, run the label script against a few test orders, and print one page at actual size before you wire it into your fulfillment flow. The full endpoint reference is in the interactive API docs.