API Certificate Generator: Build a PDF Certificate SDK
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
CertificateGeneratorclass (Python) andcreateCertificateGeneratorfactory (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:
- Template rendering — merge recipient data into the HTML template
- PDF generation — POST rendered HTML to the API and return raw bytes
- 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:
- PDF Certificate Automation Guide — Hub guide: end-to-end pipeline covering templates, batch generation, S3 archival, and email delivery
- PDF Certificate API Integration Guide — LMS webhooks, Moodle integration, authentication, and error handling
- Bulk Certificate Generator Guide — CSV to PDF for 100–10,000 recipients with ZIP bundling
- Bulk Certificate Generator for Events — Eventbrite CSV, ticket-tier templates, and on-site QR verification
- HTML Certificate Templates — Five copy-paste HTML/CSS certificate designs (completion, award, diploma, attendance, credential)
- FUNBREW PDF API Documentation — Full API reference and authentication
- FUNBREW PDF use cases — Certificate generation use case overview
Summary
A thin SDK wrapper around the FUNBREW PDF API gives you:
- Template isolation — HTML design lives in files, not string literals in code
- Deterministic IDs — HMAC-based certificate IDs that are tamper-proof and database-friendly
- Built-in resilience — automatic retry with exponential backoff for transient errors
- 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.