Bulk Certificate Generator for Events: Eventbrite CSV to Tier-Based PDFs in Minutes
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:
- Spiky volume. A 5,000-attendee conference produces zero certificates for months, then 5,000 in a 4-hour window
- Ticket-tier diversity. General Admission, Workshop Pass, Speaker Pass, and VIP each need a different layout — sometimes within the same event
- 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 loadAttendees → generateOne → 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.jsonto 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:
- Certificate PDF Automation Guide — Hub guide: complete pipeline covering templates, S3 archival, QR verification, and email delivery
- Bulk Certificate Generator Guide — Generic CSV→bulk PDF patterns for courses and credential programs
- PDF Certificate API Integration Guide — LMS webhooks, authentication, and error handling for production systems
- API Certificate Generator Guide — Build a reusable SDK wrapper with retry logic and tamper-proof IDs
- HTML Certificate Templates — Completion, award, attendance, and credential layouts ready to copy-paste
- PDF QR Code Integration Guide — QR generation, sizing, and error correction
- PDF Batch Processing Guide — Concurrency, retries, and queue design at scale
- HTML to PDF Japanese Font Guide — Multi-language font stacks for international events