Invalid Date

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

Powered by FUNBREW PDF