April 29, 2026

Bulk Certificate Generator for Events: Eventbrite CSV to Tier-Based PDFs in Minutes

Event certificatesBulk generationEventbriteConferencePDF API

A 1,000-person conference ends on Friday. By Monday morning, every attendee expects an email with a personalized attendance certificate — and the speakers want a separate, branded recognition PDF. Doing this by hand is not an option.

This guide walks through a production-ready event certificate pipeline built on the FUNBREW PDF API: pulling the attendee list from Eventbrite or Hopin, mapping ticket types to certificate templates, generating PDFs in parallel, and delivering them with QR verification.

This guide is focused on the event-specific CSV-to-PDF pipeline — ticket-tier template routing, Eventbrite/Hopin/Peatix/connpass CSV ingestion, and on-site instant issuance. For the full pipeline architecture (template design, QR verification, S3 archival, email delivery), see the hub guide: Certificate PDF Automation Guide. To build a reusable SDK wrapper with retry and tamper-proof IDs, see the Certificate PDF SDK Guide. For the broader bulk-issuance pattern (corporate training, online courses, credential programs), see the Bulk Certificate Generator guide.

Why Events Need a Dedicated Pipeline

Event certificate workflows differ from generic credential issuance in three important ways:

  1. Spiky volume. A 5,000-attendee conference produces zero certificates for months, then 5,000 in a 4-hour window
  2. Ticket-tier diversity. General Admission, Workshop Pass, Speaker Pass, and VIP each need a different layout — sometimes within the same event
  3. Tight timing windows. Attendees expect a certificate within 24–48 hours of the event ending, while LinkedIn-shareable formats are top of mind

The patterns below are tuned for these constraints.

Event Certificate Types

Type Issued To Typical Trigger Template Cue
Attendance All checked-in attendees Event end "attended ___ on ___"
Completion Workshop / hands-on participants Workshop end "successfully completed ___"
Speaker recognition Confirmed speakers Day after event "presented ___ at ___"
CPD / CPE credits Professional attendees Post-event verification Includes credit number and accreditor
Volunteer / staff Crew and volunteers Event end "served as ___"
Sponsor recognition Sponsoring companies Event end Brand logo and tier

A typical conference issues three to four of these types from a single attendee list. The trick is mapping ticket_type (or attendee_role) to template in one dictionary and reusing the same generation pipeline.

Exporting Attendees from Event Platforms

Eventbrite

The Eventbrite dashboard exports a CSV with columns including Order #, First Name, Last Name, Email, Ticket Type, and Attendee Status. The Attendee Status column is the source of truth for check-in:

Order #,First Name,Last Name,Email,Ticket Type,Attendee Status,Event Date
EB1234,Sarah,Connor,sarah@example.com,General Admission,Checked In,2026-04-26
EB1235,John,Doe,john@example.com,Workshop Pass,Checked In,2026-04-26
EB1236,Alice,Lee,alice@example.com,Speaker Pass,Attending,2026-04-26

For programmatic export, the Eventbrite API endpoint /v3/events/{event_id}/attendees/ returns the same data as JSON.

Hopin / Bizzabo

Both expose an attendee export with email, ticket_type, session_attendance (a list of session IDs), and engagement_score. For multi-session conferences, session_attendance lets you generate certificates that list which sessions each attendee actually joined.

Peatix and connpass

Common in Japan-based events. Peatix's CSV uses Shift-JIS encoding by default — pass encoding="shift_jis" when reading in Python, or use iconv-lite in Node.js. connpass's API returns JSON in UTF-8 and is easier to integrate.

Mapping Ticket Type to Template

Centralize the routing in one dictionary so the rest of the pipeline stays generic.

// template-router.js
const fs = require('fs');
const path = require('path');

const TEMPLATE_MAP = {
  'General Admission': 'attendance.html',
  'Workshop Pass': 'completion.html',
  'Speaker Pass': 'speaker.html',
  'VIP': 'attendance-vip.html',
  'Volunteer': 'volunteer.html',
  'Sponsor': 'sponsor.html',
};

const templateCache = {};

function loadTemplate(name) {
  if (!templateCache[name]) {
    templateCache[name] = fs.readFileSync(path.join('templates', name), 'utf-8');
  }
  return templateCache[name];
}

function pickTemplate(attendee) {
  const templateFile = TEMPLATE_MAP[attendee.ticket_type] || 'attendance.html';
  return loadTemplate(templateFile);
}

module.exports = { pickTemplate };

When a new ticket type appears, add one row to TEMPLATE_MAP. Nothing else in the pipeline changes.

Bulk Generation Pipeline (Node.js)

npm install csv-parse handlebars axios p-limit qrcode
// event-certificate-generator.js
const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const Handlebars = require('handlebars');
const axios = require('axios');
const pLimit = require('p-limit');
const QRCode = require('qrcode');
const crypto = require('crypto');

const { pickTemplate } = require('./template-router');

const API_KEY = process.env.FUNBREW_API_KEY;
const API_URL = 'https://pdf.funbrew.cloud/api/v1/generate';
const VERIFY_BASE = 'https://example.com/verify';
const HMAC_SECRET = process.env.CERT_HMAC_SECRET;
const CONCURRENCY = 8;

function loadAttendees(csvPath) {
  const content = fs.readFileSync(csvPath, 'utf-8');
  const rows = parse(content, { columns: true, skip_empty_lines: true });
  // Eventbrite uses "Checked In" / "Attending" — only certify those who actually showed up
  return rows.filter((r) => r['Attendee Status'] === 'Checked In');
}

function signCertificateId(attendeeId, eventId) {
  const payload = `${eventId}:${attendeeId}`;
  const sig = crypto.createHmac('sha256', HMAC_SECRET).update(payload).digest('hex').slice(0, 12);
  return `${eventId}-${attendeeId}-${sig}`;
}

async function buildQrDataUrl(certId) {
  const url = `${VERIFY_BASE}/${certId}`;
  return await QRCode.toDataURL(url, { width: 200, margin: 1 });
}

async function generateOne(attendee, eventMeta, limit) {
  return limit(async () => {
    const templateHtml = pickTemplate(attendee);
    const template = Handlebars.compile(templateHtml);
    const certId = signCertificateId(attendee['Order #'], eventMeta.id);
    const qrDataUrl = await buildQrDataUrl(certId);

    const html = template({
      name: `${attendee['First Name']} ${attendee['Last Name']}`,
      ticket_type: attendee['Ticket Type'],
      event_name: eventMeta.name,
      event_date: attendee['Event Date'],
      cert_id: certId,
      qr_data_url: qrDataUrl,
    });

    try {
      const response = await axios.post(
        API_URL,
        {
          html,
          options: {
            format: 'A4',
            landscape: true,
            printBackground: true,
            margin: { top: '0', right: '0', bottom: '0', left: '0' },
          },
        },
        {
          headers: { Authorization: `Bearer ${API_KEY}` },
          responseType: 'arraybuffer',
          timeout: 60000,
        }
      );
      return {
        certId,
        attendeeId: attendee['Order #'],
        email: attendee['Email'],
        buffer: Buffer.from(response.data),
        status: 'success',
      };
    } catch (err) {
      return {
        certId,
        attendeeId: attendee['Order #'],
        email: attendee['Email'],
        error: err.message,
        status: 'failed',
      };
    }
  });
}

async function generateEventCertificates(csvPath, eventMeta, outputDir = './output') {
  fs.mkdirSync(outputDir, { recursive: true });
  const attendees = loadAttendees(csvPath);
  console.log(`Issuing ${attendees.length} certificates for "${eventMeta.name}"`);

  const limit = pLimit(CONCURRENCY);
  const results = await Promise.all(attendees.map((a) => generateOne(a, eventMeta, limit)));

  const succeeded = results.filter((r) => r.status === 'success');
  const failed = results.filter((r) => r.status === 'failed');

  for (const r of succeeded) {
    fs.writeFileSync(path.join(outputDir, `${r.certId}.pdf`), r.buffer);
  }

  if (failed.length) {
    fs.writeFileSync(
      path.join(outputDir, 'failed.json'),
      JSON.stringify(failed.map(({ buffer, ...rest }) => rest), null, 2)
    );
  }

  console.log(`Done: ${succeeded.length} succeeded / ${failed.length} failed`);
  return { succeeded, failed };
}

generateEventCertificates(
  './attendees-eventbrite.csv',
  { id: 'devconf-2026', name: 'DevConf 2026' }
).catch(console.error);

The pipeline is intentionally split into loadAttendeesgenerateOne → orchestrator. When you switch from Eventbrite to Hopin, you only rewrite loadAttendees. When you switch from PDF download to email delivery, you only rewrite the orchestrator's tail.

Bulk Generation Pipeline (Python)

pip install aiohttp jinja2 qrcode pillow
# event_certificate_generator.py
import asyncio
import base64
import csv
import hashlib
import hmac
import io
import json
import os
from pathlib import Path

import aiohttp
import qrcode
from jinja2 import Template

API_KEY = os.environ["FUNBREW_API_KEY"]
API_URL = "https://pdf.funbrew.cloud/api/v1/generate"
VERIFY_BASE = "https://example.com/verify"
HMAC_SECRET = os.environ["CERT_HMAC_SECRET"].encode()
CONCURRENCY = 8

TEMPLATE_MAP = {
    "General Admission": "attendance.html",
    "Workshop Pass": "completion.html",
    "Speaker Pass": "speaker.html",
    "VIP": "attendance-vip.html",
    "Volunteer": "volunteer.html",
    "Sponsor": "sponsor.html",
}
_template_cache: dict[str, Template] = {}


def pick_template(ticket_type: str) -> Template:
    name = TEMPLATE_MAP.get(ticket_type, "attendance.html")
    if name not in _template_cache:
        path = Path("templates") / name
        # Peatix CSV uses shift_jis; attendee CSV is utf-8 here
        _template_cache[name] = Template(path.read_text(encoding="utf-8"))
    return _template_cache[name]


def load_attendees(csv_path: str) -> list[dict]:
    with open(csv_path, encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    return [r for r in rows if r["Attendee Status"] == "Checked In"]


def sign_certificate_id(attendee_id: str, event_id: str) -> str:
    payload = f"{event_id}:{attendee_id}".encode()
    sig = hmac.new(HMAC_SECRET, payload, hashlib.sha256).hexdigest()[:12]
    return f"{event_id}-{attendee_id}-{sig}"


def build_qr_data_url(cert_id: str) -> str:
    url = f"{VERIFY_BASE}/{cert_id}"
    qr = qrcode.QRCode(box_size=4, border=1)
    qr.add_data(url)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    b64 = base64.b64encode(buf.getvalue()).decode()
    return f"data:image/png;base64,{b64}"


async def generate_one(
    session: aiohttp.ClientSession,
    attendee: dict,
    event_meta: dict,
    semaphore: asyncio.Semaphore,
) -> dict:
    async with semaphore:
        template = pick_template(attendee["Ticket Type"])
        cert_id = sign_certificate_id(attendee["Order #"], event_meta["id"])
        qr_data_url = build_qr_data_url(cert_id)

        html = template.render(
            name=f"{attendee['First Name']} {attendee['Last Name']}",
            ticket_type=attendee["Ticket Type"],
            event_name=event_meta["name"],
            event_date=attendee["Event Date"],
            cert_id=cert_id,
            qr_data_url=qr_data_url,
        )

        try:
            async with session.post(
                API_URL,
                headers={"Authorization": f"Bearer {API_KEY}"},
                json={
                    "html": html,
                    "options": {
                        "format": "A4",
                        "landscape": True,
                        "printBackground": True,
                        "margin": {"top": "0", "right": "0", "bottom": "0", "left": "0"},
                    },
                },
                timeout=aiohttp.ClientTimeout(total=60),
            ) as resp:
                if resp.status == 200:
                    pdf = await resp.read()
                    return {
                        "cert_id": cert_id,
                        "attendee_id": attendee["Order #"],
                        "email": attendee["Email"],
                        "bytes": pdf,
                        "status": "success",
                    }
                error = f"HTTP {resp.status}"
        except Exception as e:
            error = str(e)

        return {
            "cert_id": cert_id,
            "attendee_id": attendee["Order #"],
            "email": attendee["Email"],
            "error": error,
            "status": "failed",
        }


async def generate_event_certificates(
    csv_path: str, event_meta: dict, output_dir: str = "./output"
) -> dict:
    out = Path(output_dir)
    out.mkdir(exist_ok=True)
    attendees = load_attendees(csv_path)
    print(f"Issuing {len(attendees)} certificates for '{event_meta['name']}'")

    semaphore = asyncio.Semaphore(CONCURRENCY)
    async with aiohttp.ClientSession() as session:
        tasks = [generate_one(session, a, event_meta, semaphore) for a in attendees]
        results = await asyncio.gather(*tasks)

    succeeded = [r for r in results if r["status"] == "success"]
    failed = [r for r in results if r["status"] == "failed"]

    for r in succeeded:
        (out / f"{r['cert_id']}.pdf").write_bytes(r["bytes"])

    if failed:
        (out / "failed.json").write_text(
            json.dumps(
                [{k: v for k, v in r.items() if k != "bytes"} for r in failed], indent=2
            )
        )

    print(f"Done: {len(succeeded)} succeeded / {len(failed)} failed")
    return {"succeeded": succeeded, "failed": failed}


if __name__ == "__main__":
    asyncio.run(
        generate_event_certificates(
            "attendees-eventbrite.csv",
            {"id": "devconf-2026", "name": "DevConf 2026"},
        )
    )

On-Site Real-Time Issuance

Some events print certificates at the venue on a small thermal or A4 printer the moment an attendee checks in or completes a workshop. The pattern:

QR check-in scan → webhook → certificate API → printer queue

Because PDF generation typically takes 1–2 seconds, the attendee can pick up their certificate before they have walked away from the kiosk.

// minimal kiosk webhook handler
const express = require('express');
const { generateOne } = require('./event-certificate-generator');
const { printToKiosk } = require('./kiosk-printer');

const app = express();
app.use(express.json());

app.post('/checkin', async (req, res) => {
  const { attendee, event } = req.body;
  const result = await generateOne(attendee, event, (fn) => fn());
  if (result.status === 'success') {
    await printToKiosk(result.buffer);
    return res.json({ printed: true, certId: result.certId });
  }
  res.status(500).json({ error: result.error });
});

app.listen(3000);

For high-throughput venues (50+ check-ins per minute), keep a small worker pool of pre-warmed HTTPS connections and pre-build the per-template Handlebars functions on startup.

Post-Event Email Delivery

The most common pattern: generate everything overnight, email each PDF the following morning.

# delivery.py
import smtplib
from email.message import EmailMessage

SMTP_HOST = os.environ["SMTP_HOST"]
SMTP_USER = os.environ["SMTP_USER"]
SMTP_PASS = os.environ["SMTP_PASS"]
FROM_ADDR = "certificates@example.com"


def send_certificate_email(recipient: dict, pdf_path: Path, event_name: str) -> None:
    msg = EmailMessage()
    msg["From"] = FROM_ADDR
    msg["To"] = recipient["email"]
    msg["Subject"] = f"Your certificate from {event_name}"
    msg.set_content(
        f"Hi {recipient['name']},\n\n"
        f"Thank you for joining {event_name}. Your certificate is attached.\n"
        "Add it to your LinkedIn profile or share with your manager.\n\n"
        f"Verify it any time at https://example.com/verify/{recipient['cert_id']}\n"
    )
    msg.add_attachment(
        pdf_path.read_bytes(),
        maintype="application",
        subtype="pdf",
        filename=pdf_path.name,
    )

    with smtplib.SMTP_SSL(SMTP_HOST, 465) as s:
        s.login(SMTP_USER, SMTP_PASS)
        s.send_message(msg)

For larger sends (1,000+ recipients), use a transactional email provider (SendGrid, Postmark, AWS SES) instead of raw SMTP — they handle bounces, complaints, and per-second throttling for you.

CPD / CPE Credit Numbering

Professional attendees often need a verifiable credit number on their certificate (CPD for medical, CPE for accounting, PDU for project management). The number must be:

  • Sequential within an issuing organization
  • Unique across all certificates
  • Auditable years later

A PostgreSQL sequence is the cleanest implementation:

CREATE SEQUENCE cpd_credit_seq START 1000001;

INSERT INTO certificates (event_id, attendee_id, credit_number, issued_at)
VALUES ($1, $2, nextval('cpd_credit_seq'), NOW())
RETURNING credit_number;

In the template, render as CPD-{{credit_number}}. Atomic allocation guarantees no duplicates even when the bulk job runs eight requests in parallel.

Localization for International Conferences

Add a language column to the attendee CSV and route to the matching template:

LOCALIZED_TEMPLATES = {
    ("General Admission", "en"): "attendance-en.html",
    ("General Admission", "ja"): "attendance-ja.html",
    ("General Admission", "fr"): "attendance-fr.html",
    ("Workshop Pass", "en"): "completion-en.html",
    ("Workshop Pass", "ja"): "completion-ja.html",
}

def pick_template(ticket_type: str, language: str) -> Template:
    key = (ticket_type, language)
    name = LOCALIZED_TEMPLATES.get(key) or LOCALIZED_TEMPLATES[(ticket_type, "en")]
    ...

Noto Sans and Noto Sans CJK are pre-installed on the FUNBREW PDF API, so Japanese, Chinese, and Korean text render correctly without extra font configuration. See HTML to PDF Japanese Font Guide for the font-family stack.

Verification Endpoint

The QR code embedded in each certificate points to a verification URL. The endpoint validates the HMAC signature and returns whether the certificate is genuine.

// verify.js
const express = require('express');
const crypto = require('crypto');

const HMAC_SECRET = process.env.CERT_HMAC_SECRET;
const app = express();

app.get('/verify/:certId', async (req, res) => {
  const { certId } = req.params;
  const parts = certId.split('-');
  if (parts.length < 3) return res.status(400).send('Invalid certificate ID');

  const sig = parts.pop();
  const payload = parts.join(':').replace(/^([^:]+):/, '$1:'); // event_id:attendee_id
  const expected = crypto.createHmac('sha256', HMAC_SECRET).update(payload).digest('hex').slice(0, 12);

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(404).send('Certificate not found or tampered');
  }

  // Optionally look up the certificate in the DB for richer info
  res.send(`Certificate ${certId} is valid.`);
});

app.listen(4000);

The HMAC alone is enough to prove a certificate was issued by your system. Add a database lookup if you also want to display the holder's name and event details on the verification page.

Operational Checklist

Before the event:

  • Decide which ticket tiers receive which template
  • Run a 5-attendee test batch end-to-end
  • Confirm SPF/DKIM/DMARC pass on the sending domain
  • Pre-warm DNS resolution for the API endpoint

During the event:

  • Monitor check-in rates so the post-event batch is sized correctly
  • Capture sponsor and speaker rosters for separate runs

After the event:

  • Run the bulk batch within 24 hours
  • Email tokenized download links instead of attaching huge PDFs to large recipient lists
  • Save failed.json to long-term storage for re-issue requests

Try It in the Playground

Paste a single attendee certificate template into the FUNBREW PDF Playground and verify the layout at A4 landscape before integrating it into the bulk pipeline. It is the fastest feedback loop for QR placement and font fallback testing.

Related

This guide is the event-specific spoke in the certificate series. It covers one stage: issuing certificates from an event platform CSV. For the complete automation pipeline from template design to delivery, start with the Certificate PDF Automation Guide, which is the hub for the entire series.

Other guides in the series:

Powered by FUNBREW PDF