May 2, 2026

API Certificate Generator: Build a PDF Certificate SDK

CertificatesAPISDKPDF generationDeveloper tools

An API certificate generator turns recipient data into a polished, downloadable PDF certificate via a single HTTP request — no design software, no manual export. Whether you are issuing course completion certificates for an LMS, professional credentials for a training platform, or participation badges for an event, the same pattern applies: POST the recipient data, receive a PDF.

This guide shows how to build a thin, reusable SDK wrapper around the FUNBREW PDF API certificate generator endpoint, so your entire application shares one well-tested module instead of scattering API keys and template logic across files.

You will end up with:

  • A CertificateGenerator class (Python) and createCertificateGenerator factory (Node.js)
  • Template management that separates HTML design from code
  • Built-in retry with exponential backoff
  • A verification endpoint pattern for tamper-proof certificates

This guide is focused on SDK design (wrapper class, retry, tamper-proof IDs). For the full pipeline architecture (template design, S3 archival, QR verification, email delivery), see the hub guide: Certificate PDF Automation Guide. For bulk generation from a CSV — 100 to 10,000 recipients — see the Bulk Certificate Generator Guide. If your goal is event-platform certificate batches (Eventbrite/Hopin exports), go to the Event Certificate Bulk Generator Guide. For LMS webhook integration and async batch pipelines, see the PDF Certificate SDK Quickstart.

Try the certificate template interactively in the FUNBREW PDF Playground.

Core SDK Design

The SDK has three responsibilities:

  1. Template rendering — merge recipient data into the HTML template
  2. PDF generation — POST rendered HTML to the API and return raw bytes
  3. Reliability — retry on transient errors, surface actionable messages on client errors
CertificateGenerator
  ├── templates/          # HTML template files
  │   └── completion.html
  ├── generate(data)      # → Buffer / bytes
  ├── generateToFile(data, path)
  └── generateToBase64(data) → string

Python SDK

Installation

pip install requests jinja2

The SDK Module

# certificate_generator/sdk.py
import os
import time
import hashlib
import hashlib
import datetime
from pathlib import Path
from typing import Optional

import requests
from jinja2 import Environment, FileSystemLoader


class CertificateGenerator:
    """Reusable SDK for generating PDF certificates via FUNBREW PDF API."""

    DEFAULT_OPTIONS = {
        "format": "A4",
        "landscape": True,
        "printBackground": True,
        "margin": {"top": "0", "right": "0", "bottom": "0", "left": "0"},
    }

    def __init__(
        self,
        api_key: Optional[str] = None,
        api_url: str = "https://pdf.funbrew.cloud/api/v1/generate",
        template_dir: Optional[str] = None,
        max_retries: int = 3,
        timeout: int = 120,
    ):
        self.api_key = api_key or os.environ["FUNBREW_API_KEY"]
        self.api_url = api_url
        self.max_retries = max_retries
        self.timeout = timeout

        template_path = template_dir or str(Path(__file__).parent / "templates")
        self._jinja = Environment(
            loader=FileSystemLoader(template_path),
            autoescape=True,
        )

    # ── Public API ──────────────────────────────────────────────────────────

    def generate(
        self,
        template_name: str,
        data: dict,
        options: Optional[dict] = None,
    ) -> bytes:
        """Render template and return PDF bytes."""
        html = self._render(template_name, data)
        return self._post_with_retry(html, options or self.DEFAULT_OPTIONS)

    def generate_to_file(
        self,
        path: str,
        template_name: str,
        data: dict,
        options: Optional[dict] = None,
    ) -> None:
        """Generate certificate and write to file."""
        pdf_bytes = self.generate(template_name, data, options)
        Path(path).write_bytes(pdf_bytes)

    def generate_to_base64(
        self,
        template_name: str,
        data: dict,
        options: Optional[dict] = None,
    ) -> str:
        """Generate certificate and return Base64-encoded string."""
        import base64
        return base64.b64encode(self.generate(template_name, data, options)).decode()

    # ── Static helpers ───────────────────────────────────────────────────────

    @staticmethod
    def make_certificate_id(
        recipient_name: str,
        course_name: str,
        issued_date: str,
        salt: Optional[str] = None,
    ) -> str:
        """Generate a tamper-proof, deterministic certificate ID."""
        secret = salt or os.environ["CERTIFICATE_SALT"]
        payload = f"{recipient_name}|{course_name}|{issued_date}|{secret}"
        digest = hashlib.sha256(payload.encode()).hexdigest()[:16].upper()
        date_str = datetime.date.today().strftime("%Y%m%d")
        return f"CERT-{date_str}-{digest}"

    # ── Internals ────────────────────────────────────────────────────────────

    def _render(self, template_name: str, data: dict) -> str:
        tpl = self._jinja.get_template(template_name)
        return tpl.render(**data)

    def _post_with_retry(self, html: str, options: dict) -> bytes:
        for attempt in range(1, self.max_retries + 1):
            try:
                resp = requests.post(
                    self.api_url,
                    headers={"Authorization": f"Bearer {self.api_key}"},
                    json={"html": html, "options": options},
                    timeout=self.timeout,
                )
                # 4xx: client error — fail immediately
                if 400 <= resp.status_code < 500:
                    resp.raise_for_status()
                resp.raise_for_status()
                return resp.content

            except requests.HTTPError as exc:
                if attempt == self.max_retries:
                    raise
                if exc.response and 400 <= exc.response.status_code < 500:
                    raise  # never retry client errors
            except (requests.Timeout, requests.ConnectionError):
                if attempt == self.max_retries:
                    raise

            delay = 2 ** (attempt - 1)  # 1s → 2s → 4s
            time.sleep(delay)

        raise RuntimeError("Unreachable")  # mypy guard

Certificate Template

<!-- certificate_generator/templates/completion.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4 landscape; margin: 0; }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      width: 297mm; height: 210mm;
      font-family: Georgia, 'Times New Roman', serif;
      display: flex; align-items: center; justify-content: center;
      background: #fff;
    }
    .cert {
      width: 270mm; height: 185mm;
      border: 4px double #2c5282;
      padding: 18mm 22mm;
      text-align: center;
      position: relative;
    }
    .issuer  { font-size: 10pt; color: #718096; letter-spacing: 2px; text-transform: uppercase; }
    .title   { font-size: 34pt; font-weight: bold; color: #2c5282; margin: 6mm 0; }
    .awarded { font-size: 11pt; color: #4a5568; margin-bottom: 4mm; }
    .name    { font-size: 26pt; border-bottom: 2px solid #2c5282; display: inline-block; min-width: 140mm; padding-bottom: 2mm; }
    .course  { font-size: 14pt; color: #4a5568; margin: 5mm 0; }
    .date    { font-size: 11pt; color: #718096; margin-top: 6mm; }
    .cert-id { position: absolute; bottom: 6mm; right: 10mm; font-size: 7pt; color: #a0aec0; }
    .cert-id a { color: #a0aec0; }
  </style>
</head>
<body>
  <div class="cert">
    <div class="issuer">{{ issuer_name }}</div>
    <div class="title">Certificate of Completion</div>
    <div class="awarded">This is to certify that</div>
    <div class="name">{{ recipient_name }}</div>
    <div class="course">has successfully completed <strong>{{ course_name }}</strong></div>
    <div class="date">{{ completion_date }}</div>
    <div class="cert-id">
      ID: <a href="{{ verify_url }}/{{ certificate_id }}">{{ certificate_id }}</a>
    </div>
  </div>
</body>
</html>

Usage

from certificate_generator.sdk import CertificateGenerator

gen = CertificateGenerator()

cert_id = CertificateGenerator.make_certificate_id(
    recipient_name="Jane Smith",
    course_name="Python for Data Engineers",
    issued_date="2026-05-02",
)

gen.generate_to_file(
    path=f"output/{cert_id}.pdf",
    template_name="completion.html",
    data={
        "recipient_name": "Jane Smith",
        "course_name": "Python for Data Engineers",
        "completion_date": "May 2, 2026",
        "certificate_id": cert_id,
        "issuer_name": "Acme Learning Platform",
        "verify_url": "https://yoursite.com/verify",
    },
)
print(f"Saved: output/{cert_id}.pdf")

Node.js SDK

Installation

npm install axios handlebars

The SDK Module

// certificate-generator/sdk.js
const axios = require('axios');
const Handlebars = require('handlebars');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

const DEFAULT_OPTIONS = {
  format: 'A4',
  landscape: true,
  printBackground: true,
  margin: { top: '0', right: '0', bottom: '0', left: '0' },
};

/**
 * Factory function — returns a certificate generator bound to one API key.
 *
 * @param {object} config
 * @param {string} [config.apiKey]         defaults to FUNBREW_API_KEY env var
 * @param {string} [config.apiUrl]         defaults to FUNBREW PDF API
 * @param {string} [config.templateDir]    directory with Handlebars templates
 * @param {number} [config.maxRetries=3]
 * @param {number} [config.timeoutMs=120000]
 */
function createCertificateGenerator({
  apiKey = process.env.FUNBREW_API_KEY,
  apiUrl = 'https://pdf.funbrew.cloud/api/v1/generate',
  templateDir = path.join(__dirname, 'templates'),
  maxRetries = 3,
  timeoutMs = 120_000,
} = {}) {
  const templateCache = new Map();

  // ── Internals ─────────────────────────────────────────────────────────────

  function loadTemplate(templateName) {
    if (!templateCache.has(templateName)) {
      const src = fs.readFileSync(path.join(templateDir, templateName), 'utf-8');
      templateCache.set(templateName, Handlebars.compile(src));
    }
    return templateCache.get(templateName);
  }

  async function postWithRetry(html, options) {
    let lastError;
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const response = await axios.post(
          apiUrl,
          { html, options },
          {
            headers: { Authorization: `Bearer ${apiKey}` },
            responseType: 'arraybuffer',
            timeout: timeoutMs,
          }
        );
        return Buffer.from(response.data);
      } catch (err) {
        lastError = err;
        const status = err.response?.status;
        // 4xx errors are client-side — do not retry
        if (status >= 400 && status < 500) throw err;
        if (attempt < maxRetries) {
          await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
        }
      }
    }
    throw lastError;
  }

  // ── Public API ─────────────────────────────────────────────────────────────

  async function generate(templateName, data, options = DEFAULT_OPTIONS) {
    const html = loadTemplate(templateName)(data);
    return postWithRetry(html, options);
  }

  async function generateToFile(filePath, templateName, data, options) {
    const buf = await generate(templateName, data, options);
    fs.writeFileSync(filePath, buf);
  }

  async function generateToBase64(templateName, data, options) {
    const buf = await generate(templateName, data, options);
    return buf.toString('base64');
  }

  return { generate, generateToFile, generateToBase64 };
}

/**
 * Generate a tamper-proof, deterministic certificate ID.
 */
function makeCertificateId(recipientName, courseName, issuedDate, salt) {
  const secret = salt || process.env.CERTIFICATE_SALT;
  const payload = `${recipientName}|${courseName}|${issuedDate}|${secret}`;
  const digest = crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16).toUpperCase();
  const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  return `CERT-${datePart}-${digest}`;
}

module.exports = { createCertificateGenerator, makeCertificateId };

Usage

const { createCertificateGenerator, makeCertificateId } = require('./certificate-generator/sdk');
const path = require('path');

const gen = createCertificateGenerator({
  templateDir: path.join(__dirname, 'certificate-generator/templates'),
});

async function issueOneCertificate() {
  const certId = makeCertificateId(
    'Jane Smith',
    'Advanced Node.js',
    '2026-05-02',
  );

  await gen.generateToFile(
    `output/${certId}.pdf`,
    'completion.html',
    {
      recipientName: 'Jane Smith',
      courseName: 'Advanced Node.js',
      completionDate: 'May 2, 2026',
      certificateId: certId,
      issuerName: 'Acme Learning Platform',
      verifyUrl: 'https://yoursite.com/verify',
    },
  );

  console.log(`Saved: output/${certId}.pdf`);
}

issueOneCertificate();

Extending the SDK

Multiple Template Support

Add templates for different certificate types without changing the SDK code:

templates/
├── completion.html      # course completion
├── achievement.html     # skill badge / achievement
├── participation.html   # attendance / participation
└── qualification.html   # professional qualification

Choose the right template at the call site:

# Python
gen.generate_to_file(
    path="output/cert.pdf",
    template_name="achievement.html",   # ← swap template
    data={"recipient_name": "Bob", ...},
)
// Node.js
await gen.generateToFile(
    'output/cert.pdf',
    'achievement.html',               // ← swap template
    { recipientName: 'Bob', ... },
);

Bulk Generation with Concurrency Control

# Python: asyncio + semaphore
import asyncio
import csv
import httpx

async def generate_async(client, sem, gen, row):
    async with sem:
        cert_id = CertificateGenerator.make_certificate_id(
            row["name"], row["course"], row["date"]
        )
        html = gen._render("completion.html", {
            "recipient_name": row["name"],
            "course_name": row["course"],
            "completion_date": row["date"],
            "certificate_id": cert_id,
            "issuer_name": "Acme Academy",
            "verify_url": "https://yoursite.com/verify",
        })
        resp = await client.post(
            gen.api_url,
            headers={"Authorization": f"Bearer {gen.api_key}"},
            json={"html": html, "options": CertificateGenerator.DEFAULT_OPTIONS},
            timeout=120,
        )
        resp.raise_for_status()
        with open(f"output/{cert_id}.pdf", "wb") as f:
            f.write(resp.content)
        print(f"  Done: {cert_id}")


async def bulk_generate(csv_path: str, concurrency: int = 5):
    gen = CertificateGenerator()
    sem = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient() as client:
        with open(csv_path) as f:
            rows = list(csv.DictReader(f))
        tasks = [generate_async(client, sem, gen, row) for row in rows]
        await asyncio.gather(*tasks)


asyncio.run(bulk_generate("recipients.csv"))
// Node.js: p-limit
const pLimit = require('p-limit');
const { parse } = require('csv-parse/sync');
const fs = require('fs');
const { createCertificateGenerator, makeCertificateId } = require('./sdk');

async function bulkGenerate(csvPath, concurrency = 5) {
  const limit = pLimit(concurrency);
  const gen = createCertificateGenerator();
  const rows = parse(fs.readFileSync(csvPath), { columns: true });

  await Promise.all(rows.map(row =>
    limit(async () => {
      const certId = makeCertificateId(row.name, row.course, row.date);
      await gen.generateToFile(`output/${certId}.pdf`, 'completion.html', {
        recipientName: row.name,
        courseName: row.course,
        completionDate: row.date,
        certificateId: certId,
        issuerName: 'Acme Academy',
        verifyUrl: 'https://yoursite.com/verify',
      });
      console.log(`Done: ${certId}`);
    })
  ));
}

bulkGenerate('recipients.csv');

Verification Endpoint (Express)

// routes/verify.js
const express = require('express');
const { db } = require('../database');
const router = express.Router();

router.get('/:certificateId', async (req, res) => {
  const record = await db.query(
    'SELECT * FROM certificates WHERE certificate_id = ? AND status = ?',
    [req.params.certificateId, 'active']
  );

  if (!record) {
    return res.status(404).json({ valid: false, message: 'Certificate not found.' });
  }

  res.json({
    valid: true,
    certificate_id: record.certificate_id,
    recipient_name: record.recipient_name,
    course_name: record.course_name,
    issued_at: record.issued_at,
  });
});

module.exports = router;

Error Handling Reference

HTTP Status Cause SDK Behaviour
400 Bad Request Invalid HTML or options Raises immediately — do not retry
401 Unauthorized Invalid API key Raises immediately — check env var
429 Too Many Requests Rate limit exceeded Retries with backoff
500 Internal Server Error Server-side issue Retries with backoff
Timeout Generation too slow Retries with backoff

Related Guides

This guide focuses on SDK design — one stage of the certificate workflow. For the complete end-to-end pipeline, start with the PDF Certificate Automation Guide, the hub guide for the entire certificate series.

Other guides in the series:

Summary

A thin SDK wrapper around the FUNBREW PDF API gives you:

  1. Template isolation — HTML design lives in files, not string literals in code
  2. Deterministic IDs — HMAC-based certificate IDs that are tamper-proof and database-friendly
  3. Built-in resilience — automatic retry with exponential backoff for transient errors
  4. Easy bulk scaling — pass the generate() function through a concurrency limiter for batch runs

Start by testing your template in the FUNBREW PDF Playground, then drop the SDK into your project using the code above.

Powered by FUNBREW PDF