Back to Blog
Guide February 26, 2026 · 5 min read

How to Generate a PDF Ticket or Boarding Pass Automatically

Generate print-ready PDF tickets, boarding passes, and event confirmations when a booking is made — branded, barcoded, and emailed automatically. No ticketing platform lock-in.

Event platforms generate tickets, but they're branded with the platform's chrome. If you're running a conference, a workshop, a transit system, or any booking flow on your own infrastructure, you need to generate tickets yourself.

Here's how to render a professional PDF ticket — with QR code, barcode, seat info, and event details — triggered on booking confirmation.

Event ticket template

function renderEventTicketHtml({ ticket }) {
  const {
    eventName, eventDate, eventTime, venue, venueAddress,
    attendeeName, ticketId, ticketType, seatSection, seatRow, seatNumber,
    orderId, doorTime,
  } = ticket;

  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: 8.5in 3.5in; margin: 0; }
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      width: 8.5in;
      height: 3.5in;
      font-family: -apple-system, 'Segoe UI', sans-serif;
      display: flex;
      overflow: hidden;
    }

    /* Left panel — event info */
    .main {
      flex: 1;
      background: #111;
      color: white;
      padding: 0.4in 0.4in;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      position: relative;
      overflow: hidden;
    }
    .main::after {
      content: '';
      position: absolute;
      top: -60px; right: -60px;
      width: 200px; height: 200px;
      border-radius: 50%;
      background: rgba(255,255,255,0.04);
    }

    .ticket-type {
      font-size: 10px;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 2px;
      color: rgba(255,255,255,0.5);
    }
    .event-name {
      font-size: 28px;
      font-weight: 900;
      line-height: 1.1;
      margin-top: 8px;
    }
    .event-meta {
      display: flex;
      gap: 24px;
      margin-top: 12px;
    }
    .meta-item .label {
      font-size: 9px;
      text-transform: uppercase;
      letter-spacing: 1px;
      color: rgba(255,255,255,0.4);
      margin-bottom: 2px;
    }
    .meta-item .value { font-size: 13px; font-weight: 600; }

    /* Middle divider — perforated look */
    .divider {
      width: 2px;
      background: repeating-linear-gradient(
        to bottom,
        #333 0px, #333 8px,
        transparent 8px, transparent 14px
      );
      position: relative;
      flex-shrink: 0;
    }
    .divider::before, .divider::after {
      content: '';
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      width: 20px;
      height: 20px;
      background: white;
      border-radius: 50%;
    }
    .divider::before { top: -10px; }
    .divider::after { bottom: -10px; }

    /* Right panel — stub */
    .stub {
      width: 2.2in;
      background: white;
      padding: 0.35in 0.3in;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
    }
    .attendee-name { font-size: 15px; font-weight: 800; color: #111; }
    .seat-label { font-size: 9px; text-transform: uppercase; letter-spacing: 1px; color: #999; margin-bottom: 2px; }
    .seat-value { font-size: 22px; font-weight: 900; color: #111; }

    .qr-placeholder {
      background: #f0f0f0;
      border: 1px solid #ddd;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 9px;
      color: #999;
      text-align: center;
      padding: 8px;
      border-radius: 4px;
    }
    .ticket-id {
      font-family: monospace;
      font-size: 9px;
      color: #999;
      letter-spacing: 1px;
      text-align: center;
      margin-top: 4px;
    }
  </style>
</head>
<body>
  <div class="main">
    <div>
      <div class="ticket-type">${ticketType}</div>
      <div class="event-name">${eventName}</div>
    </div>

    <div class="event-meta">
      <div class="meta-item">
        <div class="label">Date</div>
        <div class="value">${eventDate}</div>
      </div>
      <div class="meta-item">
        <div class="label">Time</div>
        <div class="value">${eventTime}</div>
      </div>
      <div class="meta-item">
        <div class="label">Doors</div>
        <div class="value">${doorTime}</div>
      </div>
    </div>

    <div>
      <div style="font-size:11px;color:rgba(255,255,255,0.5);margin-bottom:2px">${venue}</div>
      <div style="font-size:10px;color:rgba(255,255,255,0.35)">${venueAddress}</div>
    </div>
  </div>

  <div class="divider"></div>

  <div class="stub">
    <div>
      <div style="font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#999;margin-bottom:2px">Attendee</div>
      <div class="attendee-name">${attendeeName}</div>
    </div>

    ${seatSection ? `
    <div class="seat-info">
      <div class="seat-label">Section / Row / Seat</div>
      <div class="seat-value">${seatSection} · ${seatRow} · ${seatNumber}</div>
    </div>` : ""}

    <div>
      <!-- In production: render a real QR code with a library like qrcode -->
      <div class="qr-placeholder" style="width:80px;height:80px;font-size:8px">
        QR<br>${ticketId}
      </div>
      <div class="ticket-id">${ticketId}</div>
    </div>
  </div>
</body>
</html>`;
}

Add a real QR code

import QRCode from "qrcode";

async function renderTicketWithQR(ticket) {
  // Generate QR code as data URL
  const qrDataUrl = await QRCode.toDataURL(
    `https://yourapp.com/tickets/verify/${ticket.ticketId}`,
    { width: 120, margin: 1 }
  );

  const html = renderEventTicketHtml({ ticket })
    .replace(
      `<!-- In production: render a real QR code with a library like qrcode -->
      <div class="qr-placeholder" style="width:80px;height:80px;font-size:8px">
        QR<br>${ticket.ticketId}
      </div>`,
      `<img src="${qrDataUrl}" width="80" height="80" style="border-radius:4px" />`
    );

  return html;
}

Generate PDF on booking confirmation

async function issueTicket(booking) {
  const ticketId = `TKT-${Date.now().toString(36).toUpperCase()}`;

  const html = await renderTicketWithQR({
    eventName: booking.event.name,
    eventDate: new Date(booking.event.startsAt).toLocaleDateString("en-US", {
      weekday: "long", month: "long", day: "numeric", year: "numeric",
    }),
    eventTime: new Date(booking.event.startsAt).toLocaleTimeString("en-US", {
      hour: "numeric", minute: "2-digit",
    }),
    doorTime: new Date(booking.event.doorsAt).toLocaleTimeString("en-US", {
      hour: "numeric", minute: "2-digit",
    }),
    venue: booking.event.venue.name,
    venueAddress: booking.event.venue.address,
    attendeeName: booking.attendee.name,
    ticketType: booking.ticketType,
    seatSection: booking.seat?.section,
    seatRow: booking.seat?.row,
    seatNumber: booking.seat?.number,
    ticketId,
    orderId: booking.orderId,
  });

  const res = await fetch("https://pagebolt.dev/api/v1/pdf", {
    method: "POST",
    headers: {
      "x-api-key": process.env.PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html }),
  });

  const pdf = Buffer.from(await res.arrayBuffer());

  // Store and email
  await uploadToS3(pdf, `tickets/${ticketId}.pdf`);
  await db.tickets.create({ data: { ticketId, bookingId: booking.id } });
  await emailTicket({ booking, pdf, ticketId });

  return { ticketId, pdf };
}

Stripe webhook handler

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET
  );

  if (event.type === "payment_intent.succeeded") {
    const booking = await db.bookings.findOne({
      where: { stripePaymentIntentId: event.data.object.id },
      include: { event: { include: { venue: true } }, attendee: true, seat: true },
    });

    if (booking) await issueTicket(booking);
  }

  res.json({ received: true });
});

Multi-ticket order (batch)

async function issueOrderTickets(order) {
  const tickets = await Promise.all(
    order.bookings.map((booking) => issueTicket(booking))
  );

  // Combine all tickets into one PDF for download
  // (merge PDFs client-side or use a PDF merge library)
  return tickets;
}

The ticket renders at 8.5×3.5 inches — standard event ticket dimensions, printable on any laser or inkjet. QR code links to your own verification endpoint so scanning works offline.

Try it free

100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.

Get API Key — Free