Issuing 500 attendance certificates after a conference, sending 1,000 completion certificates to an online course cohort, running monthly batch issuance for a credential program — at this scale, manual generation is not a viable option.
This guide walks through building a bulk certificate PDF generator using the FUNBREW PDF API: loading recipient data from CSV, merging it into an HTML template, calling the API in parallel, and bundling the output into a ZIP. Node.js and Python examples are provided throughout.
For certificate template design, see the HTML Certificate Templates collection. For the single-certificate automation pipeline, see the Certificate PDF Automation Guide. General large-scale PDF generation patterns are covered in the PDF Batch Processing Guide.
When Bulk Issuance Becomes Necessary
Industry Use Cases
| Industry | Use Case | Typical Volume |
|---|---|---|
| Online education / eLearning | Course completion certificates | 100–5,000/month |
| Corporate training | Compliance training certificates | 50–500/month |
| Conferences & seminars | Attendance & CPD certificates | 100–10,000 per event |
| Credential programs | Qualification & renewal certificates | 50–1,000/month |
| Schools & universities | Diplomas & honor certificates | Large batch once per year |
| Sports events | Finisher & participation certificates | 200–5,000 per event |
The Limits of Manual Issuance
Ten certificates — manageable by hand. Past 100, problems compound fast.
- Time: 5 minutes per certificate means 100 certificates take 8+ hours
- Human error: Name misspellings and wrong dates — risk grows with volume
- Scalability: Monthly recurring runs and sudden spikes are hard to absorb
- Brand consistency: Manual layouts drift subtly from certificate to certificate
Once you regularly issue more than 100 certificates, the automation investment pays for itself quickly.
How the Bulk Certificate Pipeline Works
The end-to-end flow is straightforward:
Load CSV → Validate data → Render HTML templates → Call API (parallel) → Save PDFs → Bundle as ZIP
CSV Structure
id,name,email,course,completion_date,score
001,Jane Smith,jane@example.com,Advanced Python,April 18 2026,92
002,John Doe,john@example.com,Advanced Python,April 18 2026,88
003,Alice Lee,alice@example.com,Advanced Python,April 18 2026,95
Certificate Template HTML
Placeholders like {{name}} are replaced with real values before the HTML is sent to the API. For a full template library covering completion certificates, awards, diplomas, and credential certificates, see HTML Certificate Templates.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 0; }
body {
margin: 0; padding: 0;
font-family: "Georgia", "Times New Roman", serif;
background: #fff;
}
.certificate {
width: 297mm; height: 210mm;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; position: relative;
}
.border { position: absolute; top: 10mm; left: 10mm; right: 10mm; bottom: 10mm; border: 3px double #1a365d; }
.inner { position: absolute; top: 15mm; left: 15mm; right: 15mm; bottom: 15mm; border: 1px solid #c4a35a; }
.content { position: relative; z-index: 1; padding: 20mm 30mm; }
.label { font-size: 11pt; color: #888; letter-spacing: 0.4em; margin-bottom: 8mm; text-transform: uppercase; }
.title { font-size: 32pt; color: #1a365d; font-weight: bold; margin-bottom: 12mm; }
.name { font-size: 22pt; color: #333; border-bottom: 2px solid #1a365d; padding-bottom: 3mm; display: inline-block; min-width: 140mm; margin-bottom: 10mm; }
.body-text { font-size: 12pt; color: #555; line-height: 2; margin-bottom: 12mm; }
.meta { display: flex; justify-content: center; gap: 20mm; font-size: 10pt; color: #666; margin-bottom: 10mm; }
.org { font-size: 13pt; color: #1a365d; font-weight: bold; }
.cert-id { position: absolute; bottom: 18mm; right: 20mm; font-size: 8pt; color: #aaa; }
</style>
</head>
<body>
<div class="certificate">
<div class="border"></div>
<div class="inner"></div>
<div class="content">
<div class="label">Certificate of Completion</div>
<div class="title">Certificate</div>
<div class="name">{{name}}</div>
<div class="body-text">
has successfully completed all requirements of<br>
<strong>{{course}}</strong>
</div>
<div class="meta">
<span>Date: {{completion_date}}</span>
<span>Score: {{score}}</span>
</div>
<div class="org">{{organization}}</div>
</div>
<div class="cert-id">No. {{id}}</div>
</div>
</body>
</html>
Implementation
Node.js Implementation
npm install csv-parse handlebars archiver axios p-limit
// bulk-certificate-generator.js
const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const Handlebars = require('handlebars');
const archiver = require('archiver');
const axios = require('axios');
const pLimit = require('p-limit');
const API_KEY = process.env.FUNBREW_API_KEY;
const API_URL = 'https://pdf.funbrew.cloud/api/v1/generate';
const CONCURRENCY = 5; // adjust based on your plan's rate limits
/**
* Read a CSV file and return an array of recipient objects.
*/
function loadRecipients(csvPath) {
const content = fs.readFileSync(csvPath, 'utf-8');
return parse(content, { columns: true, skip_empty_lines: true });
}
/**
* Generate one certificate PDF via the API.
* Retries up to `retries` times with exponential backoff.
*/
async function generateOneCertificate(recipient, template, retries = 3) {
const html = template({
...recipient,
organization: 'Tech Academy Inc.',
});
for (let attempt = 1; attempt <= retries; attempt++) {
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 {
id: recipient.id,
name: recipient.name,
buffer: Buffer.from(response.data),
status: 'success',
};
} catch (err) {
if (attempt === retries) {
console.error(`Failed [${recipient.id}] ${recipient.name}: ${err.message}`);
return { id: recipient.id, name: recipient.name, error: err.message, status: 'failed' };
}
// Exponential backoff: 1s → 2s → 4s
await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1)));
}
}
}
/**
* Bundle successful PDFs into a single ZIP file.
*/
function archivePdfs(results, outputZipPath) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputZipPath);
const archive = archiver('zip', { zlib: { level: 6 } });
output.on('close', () => resolve(archive.pointer()));
archive.on('error', reject);
archive.pipe(output);
for (const r of results) {
if (r.status === 'success') {
archive.append(r.buffer, { name: `certificate-${r.id}-${r.name}.pdf` });
}
}
archive.finalize();
});
}
/**
* Main pipeline: CSV → bulk PDF generation → ZIP.
*/
async function bulkGenerateCertificates(csvPath, outputDir = './output') {
fs.mkdirSync(outputDir, { recursive: true });
const templateHtml = fs.readFileSync('certificate-template.html', 'utf-8');
const template = Handlebars.compile(templateHtml);
const recipients = loadRecipients(csvPath);
console.log(`Generating ${recipients.length} certificates...`);
const limit = pLimit(CONCURRENCY);
const results = await Promise.all(
recipients.map((r) => limit(() => generateOneCertificate(r, template)))
);
const succeeded = results.filter((r) => r.status === 'success');
const failed = results.filter((r) => r.status === 'failed');
console.log(`Done: ${succeeded.length} succeeded / ${failed.length} failed`);
if (failed.length > 0) {
const failedPath = path.join(outputDir, 'failed.json');
fs.writeFileSync(failedPath, JSON.stringify(failed, null, 2));
console.log(`Failed list saved to: ${failedPath}`);
}
const zipPath = path.join(outputDir, `certificates-${Date.now()}.zip`);
const zipBytes = await archivePdfs(succeeded, zipPath);
console.log(`ZIP created: ${zipPath} (${(zipBytes / 1024 / 1024).toFixed(1)} MB)`);
return { succeeded: succeeded.length, failed: failed.length, zipPath };
}
// Run
bulkGenerateCertificates('./recipients.csv', './output')
.then(({ succeeded, failed, zipPath }) => {
console.log('\n=== Summary ===');
console.log(`Succeeded: ${succeeded} / Failed: ${failed}`);
console.log(`ZIP: ${zipPath}`);
})
.catch(console.error);
Python Implementation
pip install aiohttp aiofiles jinja2
# bulk_certificate_generator.py
import asyncio
import csv
import json
import zipfile
import os
from datetime import datetime
from pathlib import Path
import aiohttp
from jinja2 import Template
API_KEY = os.environ["FUNBREW_API_KEY"]
API_URL = "https://pdf.funbrew.cloud/api/v1/generate"
CONCURRENCY = 5 # adjust to your plan's rate limits
def load_recipients(csv_path: str) -> list[dict]:
"""Load a CSV file and return a list of recipient dicts."""
with open(csv_path, encoding="utf-8") as f:
return list(csv.DictReader(f))
async def generate_one(
session: aiohttp.ClientSession,
recipient: dict,
template: Template,
semaphore: asyncio.Semaphore,
retries: int = 3,
) -> dict:
"""Generate one certificate PDF, respecting concurrency and retrying on failure."""
html = template.render(**recipient, organization="Tech Academy Inc.")
async with semaphore:
for attempt in range(1, retries + 1):
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_bytes = await resp.read()
return {
"id": recipient["id"],
"name": recipient["name"],
"bytes": pdf_bytes,
"status": "success",
}
error = f"HTTP {resp.status}"
except Exception as e:
error = str(e)
if attempt < retries:
await asyncio.sleep(2 ** (attempt - 1)) # exponential backoff
print(f"Failed [{recipient['id']}] {recipient['name']}: {error}")
return {"id": recipient["id"], "name": recipient["name"], "error": error, "status": "failed"}
def archive_pdfs(results: list[dict], output_zip: Path) -> int:
"""Bundle successful PDFs into a ZIP file and return the file size in bytes."""
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
for r in results:
if r["status"] == "success":
filename = f"certificate-{r['id']}-{r['name']}.pdf"
zf.writestr(filename, r["bytes"])
return output_zip.stat().st_size
async def bulk_generate(csv_path: str, output_dir: str = "./output") -> dict:
"""Main pipeline: CSV → bulk PDF generation → ZIP."""
out = Path(output_dir)
out.mkdir(exist_ok=True)
with open("certificate-template.html") as f:
template = Template(f.read())
recipients = load_recipients(csv_path)
print(f"Generating {len(recipients)} certificates...")
semaphore = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession() as session:
tasks = [generate_one(session, r, template, semaphore) for r in recipients]
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"]
print(f"Done: {len(succeeded)} succeeded / {len(failed)} failed")
if failed:
failed_path = out / "failed.json"
failed_path.write_text(
json.dumps(
[{"id": r["id"], "name": r["name"], "error": r["error"]} for r in failed],
indent=2,
)
)
print(f"Failed list saved to: {failed_path}")
zip_path = out / f"certificates-{datetime.now().strftime('%Y%m%d%H%M%S')}.zip"
zip_bytes = archive_pdfs(results, zip_path)
print(f"ZIP created: {zip_path} ({zip_bytes / 1024 / 1024:.1f} MB)")
return {"succeeded": len(succeeded), "failed": len(failed), "zip_path": str(zip_path)}
if __name__ == "__main__":
result = asyncio.run(bulk_generate("recipients.csv"))
print("\n=== Summary ===")
print(f"Succeeded: {result['succeeded']} / Failed: {result['failed']}")
print(f"ZIP: {result['zip_path']}")
Rate Limit Management
The FUNBREW PDF API enforces per-second request limits. Exceeding them returns 429 Too Many Requests. Both implementations above include exponential backoff. If 429 responses are frequent, lower CONCURRENCY or add a dynamic throttle.
Recommended Concurrency by Volume
| Volume | Recommended CONCURRENCY | Estimated time |
|---|---|---|
| Up to 100 | 5 | 1–3 min |
| 100–500 | 5–10 | 5–15 min |
| 500–2,000 | 10–20 | 15–60 min |
| 2,000+ | Use an async queue | Chunked runs |
Dynamic 429 Handling in Node.js
// Axios interceptor: auto-sleep and retry on 429
axios.interceptors.response.use(null, async (err) => {
if (err.response?.status === 429) {
const retryAfter = parseInt(err.response.headers['retry-after'] || '5');
console.warn(`Rate limited. Waiting ${retryAfter}s...`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return axios(err.config);
}
return Promise.reject(err);
});
Performance Optimization
Chunked Processing for Large Volumes
Rather than loading all 10,000 recipients into memory at once, process them in chunks of 500.
async def bulk_generate_chunked(csv_path: str, chunk_size: int = 500):
recipients = load_recipients(csv_path)
all_results = []
for i in range(0, len(recipients), chunk_size):
chunk = recipients[i:i + chunk_size]
print(f"Chunk {i // chunk_size + 1}: processing {len(chunk)} recipients...")
results = await process_chunk(chunk)
all_results.extend(results)
await asyncio.sleep(2) # brief pause between chunks
return all_results
Async Queue for 2,000+ Certificates
For very large volumes, pair the API with a job queue such as BullMQ (Node.js) or Celery (Python).
// BullMQ certificate generation queue (conceptual)
const { Queue, Worker } = require('bullmq');
const certQueue = new Queue('certificate-generation');
async function enqueueCertificates(recipients) {
const jobs = recipients.map((r) => ({
name: 'generate',
data: r,
opts: { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
}));
await certQueue.addBulk(jobs);
}
const worker = new Worker(
'certificate-generation',
async (job) => {
const pdf = await generateOneCertificate(job.data);
await saveToS3(job.data.id, pdf);
},
{ concurrency: 10 }
);
Post-Issuance Operations
Automated Email Delivery
Send each generated certificate directly to the recipient as soon as it is created.
const nodemailer = require('nodemailer');
async function sendCertificateEmail(recipient, pdfBuffer) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transporter.sendMail({
from: process.env.FROM_EMAIL,
to: recipient.email,
subject: `Your Certificate of Completion — ${recipient.course}`,
text: `Dear ${recipient.name},\n\nCongratulations on completing "${recipient.course}". Please find your certificate attached.\n\nBest regards,\nTech Academy`,
attachments: [
{
filename: `certificate-${recipient.id}.pdf`,
content: pdfBuffer,
},
],
});
}
QR Code Verification
Embed a QR code in each certificate that links to a verification page. This lets anyone scan the certificate and confirm it is legitimate.
<!-- Add to certificate template -->
<div class="cert-id">No. {{id}}</div>
<div class="qr-code">
<img src="{{qr_url}}" width="20mm" height="20mm" alt="Verification QR">
</div>
import qrcode
import io
import base64
def generate_qr_data_url(certificate_id: str, verify_base_url: str) -> str:
"""Return a base64 PNG data URL for a QR code linking to the verification page."""
url = f"{verify_base_url}/verify/{certificate_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}"
# Attach QR data URL to each recipient before rendering
for recipient in recipients:
recipient["qr_url"] = generate_qr_data_url(
recipient["id"], "https://example.com"
)
Tamper Detection with HMAC
Sign certificate IDs with an HMAC so that forged or altered IDs can be detected at the verification endpoint.
import hmac
import hashlib
SECRET_KEY = os.environ["CERT_SECRET_KEY"].encode()
def sign_certificate_id(cert_id: str) -> str:
"""Append a short HMAC signature to a certificate ID."""
sig = hmac.new(SECRET_KEY, cert_id.encode(), hashlib.sha256).hexdigest()[:12]
return f"{cert_id}-{sig}"
def verify_certificate_id(signed_id: str) -> bool:
"""Verify that a signed certificate ID has not been tampered with."""
if '-' not in signed_id:
return False
cert_id, sig = signed_id.rsplit('-', 1)
expected = hmac.new(SECRET_KEY, cert_id.encode(), hashlib.sha256).hexdigest()[:12]
return hmac.compare_digest(sig, expected)
Error Handling Best Practices
Always use the failed-ID retry pattern: record which IDs failed, and reprocess only those. Re-running the full batch risks issuing duplicate certificates to recipients who already received theirs.
async def retry_failed(failed_json_path: str):
"""Reload failed IDs from a previous run and retry them."""
with open(failed_json_path) as f:
failed = json.load(f)
print(f"Retrying {len(failed)} failed certificates...")
retry_recipients = [{"id": r["id"], "name": r["name"], ...} for r in failed]
return await bulk_generate_from_list(retry_recipients)
The failed list is written to output/failed.json. To retry:
# Node.js
node bulk-certificate-generator.js --retry ./output/failed.json
# Python
python bulk_certificate_generator.py --retry ./output/failed.json
Summary
| Concern | Recommended Approach |
|---|---|
| Data input | CSV → list of dicts |
| Template engine | Handlebars (JS) / Jinja2 (Python) |
| Concurrency | Semaphore (CONCURRENCY = 5–10) |
| Rate limit handling | Exponential backoff retry |
| Output | ZIP bundle for batch download |
| Failure handling | Record failed IDs, retry only those |
| Verification | QR code + HMAC signature |
Use the FUNBREW PDF Playground to verify your certificate template renders correctly before writing integration code. Full API documentation is at FUNBREW PDF Docs. For certificate template designs, see the HTML Certificate Templates collection. For the single-certificate automation pipeline, see the Certificate PDF Automation Guide.
Related Articles
- HTML Certificate Templates — Completion, award, diploma, attendance, and credential templates
- Certificate PDF Automation Guide — From template design to batch issuance
- PDF Batch Processing Guide — Best practices for large-scale PDF generation
- PDF Template Engine Guide — Data binding with Handlebars, Jinja2 & more
- HTML to PDF CSS Snippets — Page breaks, margins, and font control
- Production Guide — Production readiness checklist