PDFPipe

Use case

Ticket PDF API

Generate event tickets, boarding passes, cinema tickets, and transport passes as PDFs. One HTTP call, your own HTML design, your own QR codes, 500 free per month.

Embed QR codes via data URI

Generate the QR in your own code, encode it as a data URI, and drop it straight into an <img src="..."> tag. The renderer draws it exactly as a browser would. No image upload step.

4x6 tickets or full A4

Set width: '4in' and height: '6in' for a standard ticket, or format: 'A4' for event programmes and multi-ticket sheets. Any custom millimeter or inch size works.

Instant URL for email delivery

Add store: true and get a stable URL back in the same response. Drop it into your confirmation email immediately. No second storage call.

Real rendering, real CSS Grid

Tickets are laid out with CSS Grid, dashed tear-off stubs, and web fonts. Everything that works in a browser works here: gradients, flexbox, custom fonts, all of it.

Accurate cut marks

Use CSS print rules and exact page dimensions so cut lines, bleed, and stub perforations land where you draw them. What you design is what prints.

Batch tickets for group bookings

Starter+ plans batch up to 10-500 tickets per call for a group order or full event manifest. A webhook fires when the batch is done, so no polling.

1. Design the ticket template

A 4x6 ticket with a dark header, a seat block, a tear-off stub, and a slot for the QR code. Note the {{qrCodeDataUri}} placeholder in the image source. You generate that value yourself in the next step.

templates/ticket.html
<!-- templates/ticket.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500&display=swap');
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { width: 4in; height: 6in; overflow: hidden; background: #f5efe2; font-family: 'Inter', system-ui, sans-serif; }
    .ticket {
      width: 100%; height: 100%;
      display: grid; grid-template-rows: auto 1fr auto;
      border: 2px solid #1d1812; background: #fffdf8;
    }
    .header {
      background: #1d1812; color: #f5efe2;
      padding: 18px 22px;
      display: flex; justify-content: space-between; align-items: center;
    }
    .header .kind { font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase; color: #d23a1d; }
    .header .event { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 700; line-height: 1.1; }
    .header .num { font-family: monospace; font-size: 11px; color: #cfc6b4; }
    .body {
      display: grid; grid-template-columns: 1fr auto;
      gap: 18px; padding: 22px;
    }
    .fields { display: grid; gap: 14px; align-content: start; }
    .field .label { font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: #999; }
    .field .value { font-family: 'Space Grotesk', sans-serif; font-size: 16px; font-weight: 500; color: #1d1812; margin-top: 2px; }
    .seat { font-size: 30px; font-weight: 700; }
    .qr { text-align: center; align-self: start; }
    .qr img { width: 120px; height: 120px; display: block; }
    .qr .caption { font-family: monospace; font-size: 9px; color: #999; margin-top: 6px; letter-spacing: 0.1em; }
    .stub {
      border-top: 2px dashed #1d1812;
      padding: 14px 22px;
      display: flex; justify-content: space-between; align-items: center;
      font-size: 11px; color: #555;
    }
    .stub .accent { color: #d23a1d; font-weight: 500; }
  </style>
</head>
<body>
  <div class="ticket">
    <div class="header">
      <div>
        <div class="kind">Admission ticket</div>
        <div class="event">{{event.name}}</div>
      </div>
      <div class="num">#{{ticketNumber}}</div>
    </div>

    <div class="body">
      <div class="fields">
        <div class="field">
          <div class="label">Attendee</div>
          <div class="value">{{attendee.name}}</div>
        </div>
        <div class="field">
          <div class="label">Date and time</div>
          <div class="value">{{event.date}}</div>
        </div>
        <div class="field">
          <div class="label">Venue</div>
          <div class="value">{{event.venue}}</div>
        </div>
        <div class="field">
          <div class="label">Seat</div>
          <div class="value seat">{{seatNumber}}</div>
        </div>
      </div>

      <div class="qr">
        <!-- You generate this data URI yourself, see the code below -->
        <img src="{{qrCodeDataUri}}" alt="Scan to validate" />
        <div class="caption">Scan at entry</div>
      </div>
    </div>

    <div class="stub">
      <span>Ticket <span class="accent">#{{ticketNumber}}</span></span>
      <span>{{event.venue}}</span>
    </div>
  </div>
</body>
</html>

2. Generate the QR code, then the ticket (Node.js)

The QR code is yours to produce. Render it to a data URI with the qrcode package, inject it into the template, and send the finished HTML. The stable URL comes back in the response.

ticket.ts
// Generate the QR data URI, embed it, then call the API
// npm install qrcode mustache

import QRCode from "qrcode";
import Mustache from "mustache";
import { readFileSync } from "fs";

const template = readFileSync("./templates/ticket.html", "utf-8");

type TicketData = {
  ticketNumber: string;
  seatNumber: string;
  attendee: { name: string };
  event: { name: string; date: string; venue: string };
};

export async function generateTicket(data: TicketData): Promise<string> {
  // 1. Build the QR payload (the value scanners read at entry).
  //    Use a signed token, not raw data, so tickets cannot be forged.
  const qrPayload = `https://tickets.yourapp.com/v/${data.ticketNumber}`;

  // 2. Render the QR code to a PNG data URI in your own code.
  const qrCodeDataUri = await QRCode.toDataURL(qrPayload, {
    margin: 0,
    width: 240,
    errorCorrectionLevel: "M",
  });

  // 3. Inject the data URI straight into the <img src="...">.
  const html = Mustache.render(template, { ...data, qrCodeDataUri });

  // 4. Send the HTML to the API. The renderer draws the embedded QR as-is.
  const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      store: true,
      filename: `ticket-${data.ticketNumber}.pdf`,
      options: {
        // 4x6 ticket size. Use format: "A4" for full-page event programmes.
        width: "4in",
        height: "6in",
        margin: { top: "0", bottom: "0", left: "0", right: "0" },
        print_background: true,
      },
    }),
  });

  if (!res.ok) throw new Error(`Ticket generation failed: ${res.status}`);
  const { document_url } = await res.json();
  return document_url;
}

3. The same flow in Python

Build the QR data URI with qrcode, render the template with jinja2, and post it with httpx.

ticket.py
# Generate the QR data URI, render with jinja2, send with httpx
# pip install qrcode[pil] jinja2 httpx

import os
import io
import base64
import qrcode
import httpx
from jinja2 import Environment, FileSystemLoader

env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("ticket.html")


def qr_data_uri(payload: str) -> str:
    """Render a QR code to a PNG data URI in your own code."""
    img = qrcode.make(payload, border=0)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    encoded = base64.b64encode(buf.getvalue()).decode("ascii")
    return f"data:image/png;base64,{encoded}"


def generate_ticket(ticket: dict) -> str:
    # 1. Build a signed QR payload, not raw ticket data.
    payload = f"https://tickets.yourapp.com/v/{ticket['ticketNumber']}"

    # 2 + 3. Render the QR and inject it into the template.
    html = template.render(qrCodeDataUri=qr_data_uri(payload), **ticket)

    # 4. Send the HTML to the API.
    resp = httpx.post(
        "https://api.pdfpipe.xyz/v1/pdf",
        headers={"Authorization": f"Bearer {os.environ['PDFPIPE_API_KEY']}"},
        json={
            "html": html,
            "store": True,
            "filename": f"ticket-{ticket['ticketNumber']}.pdf",
            "options": {
                "width": "4in",
                "height": "6in",
                "margin": {"top": "0", "bottom": "0", "left": "0", "right": "0"},
                "print_background": True,
            },
        },
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json()["document_url"]

4. Store and email on purchase

Wire this to your payment or order webhook. Each seat in a group booking gets its own ticket, the URL goes into your database, and one confirmation email carries every download link.

webhook.ts
// Store the ticket and email the download link on purchase
// store: true returns a stable URL, so there is no separate upload step.

app.post("/webhooks/order-paid", async (req, res) => {
  const { orderId } = req.body;
  const order = await db.orders.findById(orderId);

  // Each line item in a group booking gets its own ticket.
  for (const item of order.items) {
    const url = await generateTicket({
      ticketNumber: item.ticketNumber,
      seatNumber: item.seat,
      attendee: { name: item.attendeeName },
      event: {
        name: order.event.name,
        date: order.event.startsAt.toLocaleString("en-US", {
          weekday: "short", month: "short", day: "numeric",
          hour: "numeric", minute: "2-digit",
        }),
        venue: order.event.venue,
      },
    });

    await db.tickets.update(item.id, { pdfUrl: url });
  }

  // Send one confirmation email with download links for the whole order.
  const tickets = await db.tickets.findByOrder(orderId);
  await sendEmail({
    to: order.buyerEmail,
    subject: `Your tickets for ${order.event.name}`,
    body: tickets
      .map((t) => `Seat ${t.seat}: ${t.pdfUrl}`)
      .join("\n"),
  });

  res.json({ ok: true, count: order.items.length });
});

Pricing

PlanPriceTickets / monthStored retention
HobbyFree5001 day
Starter$193,00030 days
Growth$4915,0001 year
Scale$14950,0001 year
Business$499100,0001 year

Start issuing tickets

500 tickets a month free. No credit card. Key issued instantly after signup.