Issuing completion certificates to training participants, sending qualification certificates to exam passers, bulk-generating event attendance awards — these certificate workflows are time-consuming when done manually and prone to errors. A missed name, a wrong date, a misformatted layout can undermine trust in the credential itself.
A PDF API lets you fully automate the process: merge database records into an HTML template and render a pixel-perfect PDF in milliseconds. This guide walks through certificate PDF automation from template design to bulk issuance, using FUNBREW PDF as the generation engine.
For the underlying batch processing mechanics, see the PDF Batch Processing Guide. Template engine patterns are covered in the PDF Template Engine Guide. If this is your first time using the API, the Quickstart by Language walks you through your first request in curl, Python, Node.js, or PHP. For a broader overview, start with the HTML-to-PDF Complete Guide. If you prefer managing certificate content in Markdown, see the Markdown to PDF API Guide.
Certificate Use Cases
Completion Certificates
Auto-issue certificates to every learner who finishes an online course or corporate training program.
- Dynamically insert recipient name, course title, and completion date
- Conditionally show score or pass/distinction grade
- Embed a QR code linking to a verification URL
Qualification Certificates
Automate issuance for professional certifications and licenses.
- Calculate expiry date automatically
- Generate unique certificate serial numbers
- Produce renewal certificates with updated dates
Participation and Award Certificates
Bulk-generate certificates for conference attendees or competition winners.
- Swap in event name, date, and venue dynamically
- Vary the design based on award tier (Gold / Silver / Bronze)
Template Design
HTML Template Structure
Certificate design is fully controlled via HTML and CSS. Use print-oriented CSS to target specific paper sizes. For a deep dive into print CSS techniques that translate faithfully to PDF, see PDF-Specific CSS Layout Tips.
<!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;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.certificate {
width: 270mm;
height: 185mm;
border: 4px double #2c5282;
padding: 20mm 25mm;
text-align: center;
position: relative;
}
.certificate-title {
font-size: 36pt;
font-weight: bold;
color: #2c5282;
margin-bottom: 10mm;
letter-spacing: 0.05em;
}
.recipient-name {
font-size: 28pt;
border-bottom: 2px solid #2c5282;
padding-bottom: 3mm;
margin: 8mm auto;
display: inline-block;
min-width: 150mm;
}
.course-name {
font-size: 16pt;
color: #4a5568;
margin: 5mm 0;
}
.completion-date {
font-size: 12pt;
color: #718096;
margin-top: 8mm;
}
.certificate-id {
position: absolute;
bottom: 8mm;
right: 12mm;
font-size: 8pt;
color: #a0aec0;
}
</style>
</head>
<body>
<div class="certificate">
<div class="certificate-title">Certificate of Completion</div>
<div class="course-name">{{courseName}}</div>
<div class="recipient-name">{{recipientName}}</div>
<p style="font-size:13pt; margin-top:6mm; line-height:1.8;">
This certifies that the above named has successfully completed<br>
the requirements of this course.
</p>
<div class="completion-date">Completed on: {{completionDate}}</div>
<div class="certificate-id">Certificate No: {{certificateId}}</div>
</div>
</body>
</html>
Data Binding
Replace template variables ({{variableName}}) with real data before sending to the API.
# Python + Jinja2
from jinja2 import Template
template_str = open("certificate.html").read()
html = Template(template_str).render(
recipientName="Jane Smith",
courseName="Advanced Python Development",
completionDate="March 31, 2026",
certificateId="CERT-2026-001234",
)
// Node.js + Handlebars
const Handlebars = require('handlebars');
const fs = require('fs');
const template = Handlebars.compile(fs.readFileSync('certificate.html', 'utf-8'));
const html = template({
recipientName: 'Jane Smith',
courseName: 'Advanced Python Development',
completionDate: 'March 31, 2026',
certificateId: 'CERT-2026-001234',
});
Code Examples: Single Certificate
curl
HTML_CONTENT='...(certificate HTML)...'
curl -X POST https://pdf.funbrew.cloud/api/v1/generate \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"html\": $(echo "$HTML_CONTENT" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),
\"options\": {
\"format\": \"A4\",
\"landscape\": true,
\"printBackground\": true,
\"margin\": {\"top\": \"0\", \"right\": \"0\", \"bottom\": \"0\", \"left\": \"0\"}
}
}" \
--output "certificate-001.pdf"
Python (single certificate)
import requests
from jinja2 import Template
def generate_certificate(
recipient_name: str,
course_name: str,
completion_date: str,
certificate_id: str,
api_key: str,
) -> bytes:
"""Generate a single certificate PDF."""
template_str = open("certificate.html").read()
html = Template(template_str).render(
recipientName=recipient_name,
courseName=course_name,
completionDate=completion_date,
certificateId=certificate_id,
)
response = requests.post(
"https://pdf.funbrew.cloud/api/v1/generate",
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=120,
)
response.raise_for_status()
return response.content
# Usage
pdf = generate_certificate(
recipient_name="Jane Smith",
course_name="Advanced Python Development",
completion_date="March 31, 2026",
certificate_id="CERT-2026-001234",
api_key="your-api-key",
)
with open("certificate-jane.pdf", "wb") as f:
f.write(pdf)
Node.js (single certificate)
const axios = require('axios');
const Handlebars = require('handlebars');
const fs = require('fs');
async function generateCertificate(data, apiKey) {
const templateStr = fs.readFileSync('certificate.html', 'utf-8');
const html = Handlebars.compile(templateStr)(data);
const response = await axios.post(
'https://pdf.funbrew.cloud/api/v1/generate',
{
html,
options: {
format: 'A4',
landscape: true,
printBackground: true,
margin: { top: '0', right: '0', bottom: '0', left: '0' },
},
},
{
headers: { Authorization: `Bearer ${apiKey}` },
responseType: 'arraybuffer',
timeout: 120000,
}
);
return Buffer.from(response.data);
}
Batch Generation: Issuing Hundreds of Certificates
When a training cohort of 100 completes, you want to generate all certificates simultaneously — not one by one. If you also generate invoices or reports at scale, the Invoice PDF Automation Guide shows similar patterns and is a useful companion. For the full architecture of high-volume batch jobs, the PDF Batch Processing Guide goes into greater depth.
Python (batch with async)
import asyncio
import aiohttp
from jinja2 import Template
from pathlib import Path
async def generate_one(session, recipient, template, api_key, output_dir):
html = template.render(**recipient)
try:
async with session.post(
"https://pdf.funbrew.cloud/api/v1/generate",
headers={"Authorization": f"Bearer {api_key}"},
json={
"html": html,
"options": {"format": "A4", "landscape": True, "printBackground": True},
},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status == 200:
data = await resp.read()
path = output_dir / f"cert-{recipient['certificateId']}.pdf"
path.write_bytes(data)
return {"id": recipient["certificateId"], "status": "success"}
return {"id": recipient["certificateId"], "status": "failed", "error": f"HTTP {resp.status}"}
except Exception as e:
return {"id": recipient["certificateId"], "status": "failed", "error": str(e)}
async def batch_generate(recipients, api_key, output_dir="certs"):
template = Template(open("certificate.html").read())
out = Path(output_dir)
out.mkdir(exist_ok=True)
sem = asyncio.Semaphore(5) # max 5 concurrent requests
async def limited(session, r):
async with sem:
return await generate_one(session, r, template, api_key, out)
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(*[limited(session, r) for r in recipients])
success = sum(1 for r in results if r["status"] == "success")
print(f"Done: {success}/{len(recipients)} succeeded")
return results
# Usage
recipients = [
{"recipientName": "Jane Smith", "courseName": "Python Dev", "completionDate": "March 31, 2026", "certificateId": "CERT-001"},
{"recipientName": "John Doe", "courseName": "Python Dev", "completionDate": "March 31, 2026", "certificateId": "CERT-002"},
]
asyncio.run(batch_generate(recipients, api_key="your-api-key"))
Node.js (batch with concurrency control)
const axios = require('axios');
const Handlebars = require('handlebars');
const fs = require('fs');
const path = require('path');
function semaphore(limit) {
let count = 0;
const queue = [];
return {
async acquire() {
if (count < limit) { count++; return; }
await new Promise((r) => queue.push(r));
count++;
},
release() {
count--;
if (queue.length) queue.shift()();
},
};
}
async function batchGenerateCertificates(recipients, apiKey, outputDir = 'certs') {
const template = Handlebars.compile(fs.readFileSync('certificate.html', 'utf-8'));
fs.mkdirSync(outputDir, { recursive: true });
const sem = semaphore(5);
const results = await Promise.allSettled(
recipients.map(async (r) => {
await sem.acquire();
try {
const response = await axios.post(
'https://pdf.funbrew.cloud/api/v1/generate',
{ html: template(r), options: { format: 'A4', landscape: true, printBackground: true } },
{ headers: { Authorization: `Bearer ${apiKey}` }, responseType: 'arraybuffer', timeout: 120000 }
);
fs.writeFileSync(path.join(outputDir, `cert-${r.certificateId}.pdf`), Buffer.from(response.data));
return { id: r.certificateId, status: 'success' };
} catch (err) {
return { id: r.certificateId, status: 'failed', error: err.message };
} finally {
sem.release();
}
})
);
const succeeded = results.filter((r) => r.value?.status === 'success').length;
console.log(`Done: ${succeeded}/${recipients.length} succeeded`);
return results;
}
Google Sheets Integration: Certificate Generator from Spreadsheet
Many teams manage recipient lists in Google Sheets. You can read the spreadsheet data and generate bulk certificate PDFs directly.
Python with Google Sheets API
import asyncio
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build
from jinja2 import Template
import aiohttp
from pathlib import Path
SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
def read_recipients_from_sheet(spreadsheet_id: str, range_name: str) -> list[dict]:
"""Read recipient data from a Google Sheet."""
creds = Credentials.from_service_account_file("credentials.json", scopes=SCOPES)
service = build("sheets", "v4", credentials=creds)
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id, range=range_name
).execute()
rows = result.get("values", [])
# First row is headers: Name, Course, Date, CertificateID
headers = rows[0]
recipients = []
for row in rows[1:]:
recipients.append({
"recipientName": row[0],
"courseName": row[1],
"completionDate": row[2],
"certificateId": row[3] if len(row) > 3 else f"CERT-{len(recipients)+1:04d}",
})
return recipients
async def generate_certificates_from_sheet(
spreadsheet_id: str,
range_name: str = "Sheet1!A1:D",
api_key: str = "",
output_dir: str = "certs",
):
"""End-to-end: read Google Sheet → generate bulk certificate PDFs."""
recipients = read_recipients_from_sheet(spreadsheet_id, range_name)
print(f"Found {len(recipients)} recipients in spreadsheet")
template = Template(open("certificate.html").read())
out = Path(output_dir)
out.mkdir(exist_ok=True)
sem = asyncio.Semaphore(5)
async def generate_one(session, r):
async with sem:
html = template.render(**r)
try:
async with session.post(
"https://pdf.funbrew.cloud/api/v1/generate",
headers={"Authorization": f"Bearer {api_key}"},
json={
"html": html,
"options": {"format": "A4", "landscape": True, "printBackground": True},
},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status == 200:
(out / f"cert-{r['certificateId']}.pdf").write_bytes(await resp.read())
return {"id": r["certificateId"], "status": "success"}
return {"id": r["certificateId"], "status": "failed", "error": f"HTTP {resp.status}"}
except Exception as e:
return {"id": r["certificateId"], "status": "failed", "error": str(e)}
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(*[generate_one(session, r) for r in recipients])
success = sum(1 for r in results if r["status"] == "success")
print(f"Done: {success}/{len(recipients)} succeeded")
return results
# Usage
asyncio.run(generate_certificates_from_sheet(
spreadsheet_id="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
api_key="your-api-key",
))
Node.js with Google Sheets API
const { google } = require('googleapis');
const Handlebars = require('handlebars');
const fs = require('fs');
const path = require('path');
const axios = require('axios');
async function readRecipientsFromSheet(spreadsheetId, range = 'Sheet1!A1:D') {
const auth = new google.auth.GoogleAuth({
keyFile: 'credentials.json',
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
});
const sheets = google.sheets({ version: 'v4', auth });
const res = await sheets.spreadsheets.values.get({ spreadsheetId, range });
const [headers, ...rows] = res.data.values;
return rows.map((row, i) => ({
recipientName: row[0],
courseName: row[1],
completionDate: row[2],
certificateId: row[3] || `CERT-${String(i + 1).padStart(4, '0')}`,
}));
}
async function generateFromSheet(spreadsheetId, apiKey, outputDir = 'certs') {
const recipients = await readRecipientsFromSheet(spreadsheetId);
console.log(`Found ${recipients.length} recipients`);
const template = Handlebars.compile(fs.readFileSync('certificate.html', 'utf-8'));
fs.mkdirSync(outputDir, { recursive: true });
for (const r of recipients) {
try {
const response = await axios.post(
'https://pdf.funbrew.cloud/api/v1/generate',
{ html: template(r), options: { format: 'A4', landscape: true, printBackground: true } },
{ headers: { Authorization: `Bearer ${apiKey}` }, responseType: 'arraybuffer', timeout: 120000 }
);
fs.writeFileSync(path.join(outputDir, `cert-${r.certificateId}.pdf`), Buffer.from(response.data));
console.log(`✓ ${r.certificateId}`);
} catch (err) {
console.error(`✗ ${r.certificateId}: ${err.message}`);
}
}
}
CSV File Alternative
If you don't use Google Sheets, a CSV file works just as well:
import csv
def read_recipients_from_csv(filepath: str) -> list[dict]:
"""Read recipient data from a CSV file."""
recipients = []
with open(filepath) as f:
reader = csv.DictReader(f)
for row in reader:
recipients.append({
"recipientName": row["name"],
"courseName": row["course"],
"completionDate": row["date"],
"certificateId": row.get("id", f"CERT-{len(recipients)+1:04d}"),
})
return recipients
Certificate Template Customization
Award Tier Variations
Generate different certificate designs based on achievement level (Gold, Silver, Bronze):
// Handlebars helper for tier-based styling
Handlebars.registerHelper('tierColor', function(tier) {
const colors = { gold: '#D4AF37', silver: '#C0C0C0', bronze: '#CD7F32' };
return colors[tier.toLowerCase()] || '#2c5282';
});
Handlebars.registerHelper('tierLabel', function(tier) {
const labels = { gold: 'With Distinction', silver: 'With Merit', bronze: 'Completion' };
return labels[tier.toLowerCase()] || 'Completion';
});
<!-- In your template -->
<div class="certificate" style="border-color: {{tierColor tier}};">
<div class="certificate-title" style="color: {{tierColor tier}};">
Certificate of {{tierLabel tier}}
</div>
<!-- ... -->
</div>
Multi-Language Certificate Templates
For international organizations issuing certificates in multiple languages:
TEMPLATES = {
"en": "certificate-en.html",
"ja": "certificate-ja.html",
"zh": "certificate-zh.html",
}
def generate_localized_certificate(recipient: dict, lang: str = "en"):
template_file = TEMPLATES.get(lang, TEMPLATES["en"])
template = Template(open(template_file).read())
return template.render(**recipient)
Certificate Serial Numbers and Verification
Generating Serial Numbers
import hashlib
import datetime
def generate_certificate_id(recipient_id: str, course_id: str) -> str:
date_str = datetime.date.today().strftime("%Y%m%d")
hash_input = f"{recipient_id}-{course_id}-{date_str}"
short_hash = hashlib.sha256(hash_input.encode()).hexdigest()[:8].upper()
return f"CERT-{date_str}-{short_hash}"
# e.g. CERT-20260331-A3F7B2C1
QR Code Verification
Embed a QR code that links to a public verification page.
<div id="qr-container"></div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(
document.getElementById('qr-container'),
'https://example.com/verify/{{certificateId}}',
{ width: 80 }
);
</script>
Browse real-world certificate automation examples in the Use Cases gallery. You can also explore invoice automation at /use-cases/invoices and report generation at /use-cases/reports. For generating certificates from a Next.js or Nuxt app, see the Next.js & Nuxt PDF API Guide. Automated report generation follows similar patterns — see the Report PDF Generation Guide.
Digital Signatures and Authorized Signer Images
Many certificate workflows require a visible signature — an instructor's name, a director's autograph, or an official stamp. You can embed signature images directly in the HTML template.
Signature Image Embedding
<div class="signatures" style="display: flex; justify-content: space-around; margin-top: 15mm;">
<div class="signer" style="text-align: center;">
<img src="data:image/png;base64,{{signatureB64}}"
alt="Signature" width="120" height="60"
style="display: block; margin: 0 auto;">
<div style="border-top: 1px solid #333; padding-top: 2mm; font-size: 10pt;">
{{signerName}}<br>
<span style="font-size: 8pt; color: #718096;">{{signerTitle}}</span>
</div>
</div>
<div class="stamp" style="text-align: center;">
<img src="data:image/png;base64,{{stampB64}}"
alt="Official Stamp" width="80" height="80">
</div>
</div>
Generating Signature Data Server-Side
import base64
def load_signature(signer_id: str) -> dict:
"""Load signer's signature image and metadata."""
sig_path = f"signatures/{signer_id}.png"
with open(sig_path, "rb") as f:
sig_b64 = base64.b64encode(f.read()).decode()
return {
"signatureB64": sig_b64,
"signerName": "Dr. Sarah Johnson",
"signerTitle": "Director of Education",
}
# Merge signature data into the template context
recipient_data = {**recipient, **load_signature("sarah-johnson")}
html = template.render(**recipient_data)
const fs = require('fs');
function loadSignature(signerId) {
const sigB64 = fs.readFileSync(`signatures/${signerId}.png`).toString('base64');
return {
signatureB64: sigB64,
signerName: 'Dr. Sarah Johnson',
signerTitle: 'Director of Education',
};
}
const data = { ...recipient, ...loadSignature('sarah-johnson') };
const html = template(data);
Email Delivery: Send Certificates Directly to Recipients
After generating certificates, the next step is typically delivering them by email. Combine PDF generation with an email service to build a fully automated pipeline.
Python with SMTP
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.mime.text import MIMEText
def send_certificate_email(
recipient_email: str,
recipient_name: str,
pdf_bytes: bytes,
certificate_id: str,
smtp_config: dict,
):
"""Send a certificate PDF as an email attachment."""
msg = MIMEMultipart()
msg["From"] = smtp_config["from"]
msg["To"] = recipient_email
msg["Subject"] = f"Your Certificate of Completion — {certificate_id}"
body = f"Dear {recipient_name},\n\nPlease find your certificate attached.\n\nBest regards"
msg.attach(MIMEText(body, "plain"))
attachment = MIMEApplication(pdf_bytes, _subtype="pdf")
attachment.add_header("Content-Disposition", "attachment", filename=f"{certificate_id}.pdf")
msg.attach(attachment)
with smtplib.SMTP(smtp_config["host"], smtp_config["port"]) as server:
server.starttls()
server.login(smtp_config["user"], smtp_config["password"])
server.send_message(msg)
Node.js with Nodemailer
const nodemailer = require('nodemailer');
async function sendCertificateEmail(recipientEmail, recipientName, pdfBuffer, certificateId) {
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: recipientEmail,
subject: `Your Certificate of Completion — ${certificateId}`,
text: `Dear ${recipientName},\n\nPlease find your certificate attached.\n\nBest regards`,
attachments: [{ filename: `${certificateId}.pdf`, content: pdfBuffer }],
});
}
Full Pipeline: Generate + Email
async def generate_and_send(recipients, api_key, smtp_config):
"""Generate certificates and email them to each recipient."""
results = await batch_generate(recipients, api_key)
for recipient, result in zip(recipients, results):
if result["status"] == "success":
pdf_path = f"certs/cert-{recipient['certificateId']}.pdf"
pdf_bytes = open(pdf_path, "rb").read()
send_certificate_email(
recipient_email=recipient["email"],
recipient_name=recipient["recipientName"],
pdf_bytes=pdf_bytes,
certificate_id=recipient["certificateId"],
smtp_config=smtp_config,
)
print(f"Sent to {recipient['email']}")
Storage and Archival Best Practices
Certificate PDFs often need to be stored for compliance, audit trails, or re-download by recipients. Here are recommended patterns.
Cloud Storage with S3
import boto3
def archive_certificate(pdf_bytes: bytes, certificate_id: str, metadata: dict) -> str:
"""Upload a certificate PDF to S3 and return the URL."""
s3 = boto3.client("s3")
key = f"certificates/{certificate_id}.pdf"
s3.put_object(
Bucket="my-certificates-bucket",
Key=key,
Body=pdf_bytes,
ContentType="application/pdf",
Metadata={
"recipient": metadata["recipientName"],
"course": metadata["courseName"],
"issued": metadata["completionDate"],
},
)
return f"s3://my-certificates-bucket/{key}"
Database Record for Audit Trail
# After generating and archiving, record the issuance
def record_issuance(db, certificate_id: str, recipient: dict, s3_url: str):
db.execute(
"""INSERT INTO certificate_records
(certificate_id, recipient_name, recipient_email, course_name, issued_at, s3_url)
VALUES (?, ?, ?, ?, NOW(), ?)""",
(certificate_id, recipient["recipientName"], recipient["email"],
recipient["courseName"], s3_url),
)
Presigned URL for Recipient Download
def get_download_url(certificate_id: str, expires_in: int = 3600) -> str:
"""Generate a time-limited download URL for a certificate."""
s3 = boto3.client("s3")
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": "my-certificates-bucket", "Key": f"certificates/{certificate_id}.pdf"},
ExpiresIn=expires_in,
)
Error Handling and Retry
In batch jobs, some requests will inevitably fail. See the PDF API Error Handling Guide for retry strategies with exponential backoff. Process only failed IDs on the next run to avoid regenerating certificates that already succeeded. To receive instant failure notifications without polling, wire up the Webhook Integration Guide.
failed_ids = {r["id"] for r in results if r["status"] == "failed"}
failed_recipients = [r for r in recipients if r["certificateId"] in failed_ids]
asyncio.run(batch_generate(failed_recipients, api_key="your-api-key"))
Security and Compliance
Certificate issuance is a high-trust workflow. Approach security at every layer.
API Key Management
Follow the PDF API Security Guide for API key management. In production, always load keys from environment variables and ensure keys never appear in logs or error messages.
# .env
FUNBREW_PDF_API_KEY=sk-prod-xxxxxxxxxxxx
# Load in your app (never hardcode)
export FUNBREW_PDF_API_KEY
Data Privacy and GDPR Compliance
Certificates typically contain personal data (recipient names, course completions). Apply these principles:
- Minimal data retention: Store only what is required for verification purposes. Define a retention period (e.g., 7 years for compliance certificates) and delete records automatically after expiry.
- Encryption at rest: Encrypt PDF files stored in S3 or equivalent cloud storage using server-side encryption (SSE-S3 or SSE-KMS).
- Access control: Issue time-limited presigned URLs (max 1 hour) for downloads rather than public object URLs. This prevents unauthorized access if a URL leaks.
- Audit logging: Record every certificate issuance event (who, when, which certificate ID) in an immutable audit log. This is required for ISO 27001 compliance.
- Right to erasure: Implement a mechanism to delete a recipient's certificate data upon request (GDPR Article 17). Use the certificate ID as the primary key for targeted deletion.
def delete_certificate_data(certificate_id: str, db_conn, s3_bucket: str) -> None:
"""Remove certificate from S3 and database for GDPR erasure requests."""
import boto3
s3 = boto3.client("s3")
s3.delete_object(Bucket=s3_bucket, Key=f"certificates/{certificate_id}.pdf")
db_conn.execute(
"DELETE FROM certificates WHERE certificate_id = ?",
(certificate_id,)
)
db_conn.commit()
print(f"Certificate {certificate_id} deleted for GDPR erasure.")
Certificate Integrity and Anti-Forgery
To ensure issued certificates can be verified as authentic:
| Measure | Implementation | Purpose |
|---|---|---|
| QR verification link | Embed https://verify.example.com/{certificateId} |
Recipients can prove authenticity |
| Hash-based serial numbers | SHA-256 of (name + course + date + secret salt) | Unforgeable without access to salt |
| PDF metadata | Set Author, Title, Producer fields |
Traceability in PDF metadata viewer |
| Digital signature | Sign the PDF with a trusted certificate (optional) | Legally recognized non-repudiation |
import hashlib, secrets
SERIAL_SALT = secrets.token_hex(32) # Store securely, never change
def generate_certificate_id(recipient_name: str, course: str, date: str) -> str:
"""Generate a deterministic, unforgeable certificate ID."""
payload = f"{recipient_name}|{course}|{date}|{SERIAL_SALT}"
digest = hashlib.sha256(payload.encode()).hexdigest()[:16].upper()
return f"CERT-{date[:4]}-{digest}"
SSL/TLS and Transport Security
All API calls to FUNBREW PDF must use HTTPS (enforced by the API). For the verification endpoint serving certificate status pages:
- Use TLS 1.2 or higher
- Set HTTP Strict Transport Security (HSTS) headers
- Validate the recipient's access using a time-limited signed token, not just the certificate ID
Template Design Best Practices
Well-designed templates reduce errors and ensure consistent output across bulk runs. Apply these patterns.
Use a Design Token System
Define colors, fonts, and sizes as CSS variables at the top of your template. This makes global style changes — such as a brand refresh — a single-file edit:
:root {
--brand-primary: #2c5282;
--brand-secondary: #4a5568;
--font-heading: 'Georgia', 'Times New Roman', serif;
--font-body: 'Inter', sans-serif;
--cert-width: 270mm;
--cert-height: 185mm;
--cert-padding: 20mm 25mm;
}
.certificate {
border: 4px double var(--brand-primary);
padding: var(--cert-padding);
}
.certificate-title {
color: var(--brand-primary);
font-family: var(--font-heading);
}
Separate Template from Data
Never hardcode recipient data into the template file. Keep the template HTML clean of real names and dates — use only placeholder variables:
<!-- Good: clean variable-only template -->
<div class="recipient-name">{{recipientName}}</div>
<div class="course-name">{{courseName}}</div>
<div class="completion-date">{{completionDate}}</div>
<div class="certificate-id">Certificate ID: {{certificateId}}</div>
<img class="qr-code" src="{{qrCodeDataUri}}" alt="Verification QR">
<!-- Bad: data mixed into template -->
<div class="recipient-name">Jane Smith</div>
Pre-generate QR Codes as Base64 Data URIs
Avoid external QR code service dependencies at render time. Pre-generate QR codes server-side and embed them as Base64 data URIs before calling the PDF API:
import qrcode, io, base64
def qr_code_data_uri(url: str) -> str:
"""Return a base64 data URI for a QR code image."""
qr = qrcode.QRCode(box_size=4, border=2)
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}"
# Usage
recipient["qrCodeDataUri"] = qr_code_data_uri(
f"https://verify.example.com/{recipient['certificateId']}"
)
Test Your Template Against Edge Cases
Before a bulk run, test the template with these edge-case inputs:
| Edge Case | What to Check |
|---|---|
| Very long name (50+ characters) | Does it overflow the name underline? |
| Non-ASCII characters (accents, CJK) | Are fonts loaded for these code points? |
| Missing optional fields | Does the template degrade gracefully? |
Special characters (&, <, >) |
Are they HTML-escaped by the template engine? |
Handlebars escapes HTML by default with {{variable}}. Jinja2 escapes with {{ variable }} in autoescape mode. Always enable autoescape for user-supplied data to prevent XSS in rendered HTML.
Related Guides
- Bulk Certificate Generator Guide — High-volume batch issuance patterns
- HTML Certificate Templates — Ready-to-use certificate HTML/CSS templates
- PDF API Batch Processing — Concurrency, queuing, and throughput strategies
- PDF Template Engine Guide — Handlebars, Jinja2, and Mustache patterns
- PDF API Security Guide — API key management and secure delivery
- PDF API Error Handling — Retry strategies with exponential backoff
- API Documentation — Full FUNBREW PDF API reference
Summary
Key steps for certificate PDF automation:
- Design in HTML/CSS: Use
@pageand print CSS to control paper size and margins - Bind data with a template engine: Handlebars or Jinja2 for clean variable injection
- Batch with concurrency control: Use a semaphore to limit parallel requests and avoid rate limits
- Assign serial numbers: Hash-based IDs ensure uniqueness without a central counter
- Enable QR verification: Link each certificate to a public verification endpoint
- Handle failures gracefully: Record failures and re-run only those IDs
- Secure and comply: Encrypt storage, apply GDPR principles, and log every issuance event
- Use design tokens: CSS variables make global template changes safe and consistent
Use the FUNBREW PDF Playground to test your certificate template before writing integration code. Full API docs are at the API Reference. If your volume is growing and you are evaluating plan options, see the Pricing Comparison for a breakdown of rate limits and features across tiers. For serverless certificate issuance, see the Serverless PDF Guide. Ruby/Go implementations are covered in the Ruby/Go Quickstart. Django/FastAPI integration is in the Django/FastAPI Guide. Docker/Kubernetes deployment is covered in the Docker & Kubernetes Guide. For the full production readiness checklist, see the Production Guide. Compare features across PDF APIs in the PDF API Comparison.