PDF Certificate API Integration Guide
Issuing completion certificates, qualification certificates, and event participation awards at scale requires automating the generation pipeline. A certificate generator API lets you go from recipient data to a signed, branded PDF in under a second — without manual design work.
This guide covers everything you need to integrate a PDF certificate API into your system: authentication, template design, LMS webhook integration, error handling, and verification endpoint setup — with complete Node.js and Python examples using FUNBREW PDF as the generation engine.
For ready-to-use certificate HTML templates, see the HTML Certificate Templates guide. For issuing 100+ certificates in a single batch run, see the Bulk Certificate Generator Guide. For the complete certificate issuance pipeline including signatures, email delivery, and S3 archival, see the PDF Certificate Automation Guide.
How a Certificate Generator API Works
A certificate generator API receives an HTML template with recipient data merged in, and returns a pixel-perfect PDF. The flow is straightforward:
[Your System]
|
| POST /api/v1/generate
| { html: "<certificate HTML>", options: { format: "A4", landscape: true } }
|
v
[FUNBREW PDF API]
|
| ← PDF binary (bytes)
v
[Your System]
|
+-- Save to database
+-- Archive to S3
+-- Email to recipient
The template rendering step — filling {{recipientName}} with real data — happens in your system using a template engine (Jinja2, Handlebars, etc.). The API handles the HTML-to-PDF conversion.
Integration Architecture Patterns
Pattern 1: Synchronous (On-Demand)
Generate and return a certificate immediately upon request.
User → System → PDF API → PDF → Return to user
Best for: Single certificates, web app "download now" flows, small volumes
Implementation: Synchronous HTTP request, return PDF bytes directly in the response
Pattern 2: Asynchronous (Queue-Based)
Process large batches in the background and notify recipients when done.
Trigger → Job Queue → Worker → PDF API → S3 → Email notification
Best for: 100+ certificates, cohort completions, end-of-term batch issuance
Implementation: BullMQ, Celery, or similar task queue
Pattern 3: Event-Driven (Webhook)
Automatically generate certificates in response to LMS or HR system events.
LMS completion event → Webhook → Certificate service → PDF API → Deliver
Best for: Fully automated LMS/HR integration, zero manual intervention
Basic Implementation: Direct API Call
curl (Testing & Validation)
# Prepare certificate HTML
read -r -d '' CERT_HTML << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 0; }
body { width: 297mm; height: 210mm; display: flex; align-items: center; justify-content: center; font-family: Georgia, serif; }
.cert { width: 270mm; height: 185mm; border: 4px double #2c5282; padding: 20mm 25mm; text-align: center; position: relative; }
.title { font-size: 36pt; font-weight: bold; color: #2c5282; margin-bottom: 10mm; }
.name { font-size: 28pt; border-bottom: 2px solid #2c5282; display: inline-block; min-width: 150mm; padding-bottom: 3mm; }
.course { font-size: 16pt; color: #4a5568; margin: 5mm 0; }
.date { font-size: 12pt; color: #718096; margin-top: 8mm; }
.cert-id { position: absolute; bottom: 8mm; right: 12mm; font-size: 8pt; color: #a0aec0; }
</style>
</head>
<body>
<div class="cert">
<div class="title">Certificate of Completion</div>
<div class="course">Advanced Python Development</div>
<div class="name">Jane Smith</div>
<p style="font-size:13pt; margin-top:6mm; line-height:1.8;">has successfully completed the requirements of this course.</p>
<div class="date">Completed on: April 20, 2026</div>
<div class="cert-id">Certificate No: CERT-20260420-A3F7B2C1</div>
</div>
</body>
</html>
EOF
curl -X POST https://pdf.funbrew.cloud/api/v1/generate \
-H "Authorization: Bearer $FUNBREW_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"html\": $(echo "$CERT_HTML" | 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.pdf"
echo "Generated: $(wc -c < certificate.pdf) bytes"
Python: Certificate Generator Function
import os
import requests
from jinja2 import Template
API_URL = "https://pdf.funbrew.cloud/api/v1/generate"
API_KEY = os.environ["FUNBREW_API_KEY"]
CERTIFICATE_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 0; }
body {
width: 297mm; height: 210mm;
font-family: 'Georgia', 'Times New Roman', serif;
display: flex; align-items: center; justify-content: center;
}
.cert {
width: 270mm; height: 185mm;
border: 4px double #2c5282; padding: 20mm 25mm;
text-align: center; position: relative;
}
.title { font-size: 36pt; font-weight: bold; color: #2c5282; margin-bottom: 10mm; }
.name { font-size: 28pt; border-bottom: 2px solid #2c5282; display: inline-block; min-width: 150mm; padding-bottom: 3mm; }
.course { font-size: 16pt; color: #4a5568; margin: 5mm 0; }
.date { font-size: 12pt; color: #718096; margin-top: 8mm; }
.cert-id { position: absolute; bottom: 8mm; right: 12mm; font-size: 8pt; color: #a0aec0; }
</style>
</head>
<body>
<div class="cert">
<div class="title">Certificate of Completion</div>
<div class="course">{{ course_name }}</div>
<div class="name">{{ recipient_name }}</div>
<p style="font-size:13pt; margin-top:6mm; line-height:1.8;">
has successfully completed the requirements of this course.
</p>
<div class="date">Completed on: {{ completion_date }}</div>
<div class="cert-id">Certificate No: {{ certificate_id }}</div>
</div>
</body>
</html>"""
def generate_certificate_pdf(
recipient_name: str,
course_name: str,
completion_date: str,
certificate_id: str,
) -> bytes:
"""Generate a certificate PDF and return the raw bytes."""
html = Template(CERTIFICATE_TEMPLATE).render(
recipient_name=recipient_name,
course_name=course_name,
completion_date=completion_date,
certificate_id=certificate_id,
)
response = requests.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=120,
)
response.raise_for_status()
return response.content
# Usage
pdf = generate_certificate_pdf(
recipient_name="Jane Smith",
course_name="Advanced Python Development",
completion_date="April 20, 2026",
certificate_id="CERT-20260420-A3F7B2C1",
)
with open("certificate.pdf", "wb") as f:
f.write(pdf)
print(f"Generated: {len(pdf):,} bytes")
Node.js: Certificate API Client
const axios = require('axios');
const Handlebars = require('handlebars');
const API_URL = 'https://pdf.funbrew.cloud/api/v1/generate';
const API_KEY = process.env.FUNBREW_API_KEY;
const CERT_TEMPLATE = Handlebars.compile(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 0; }
body { width: 297mm; height: 210mm; font-family: Georgia, serif; display: flex; align-items: center; justify-content: center; }
.cert { width: 270mm; height: 185mm; border: 4px double #2c5282; padding: 20mm 25mm; text-align: center; position: relative; }
.title { font-size: 36pt; font-weight: bold; color: #2c5282; margin-bottom: 10mm; }
.name { font-size: 28pt; border-bottom: 2px solid #2c5282; display: inline-block; min-width: 150mm; }
.course { font-size: 16pt; color: #4a5568; margin: 5mm 0; }
.date { font-size: 12pt; color: #718096; margin-top: 8mm; }
.cert-id { position: absolute; bottom: 8mm; right: 12mm; font-size: 8pt; color: #a0aec0; }
</style>
</head>
<body>
<div class="cert">
<div class="title">Certificate of Completion</div>
<div class="course">{{courseName}}</div>
<div class="name">{{recipientName}}</div>
<p style="font-size:13pt; margin-top:6mm; line-height:1.8;">has successfully completed the requirements of this course.</p>
<div class="date">Completed on: {{completionDate}}</div>
<div class="cert-id">Certificate No: {{certificateId}}</div>
</div>
</body>
</html>`);
/**
* Generate a certificate PDF and return a Buffer.
*/
async function generateCertificatePdf({ recipientName, courseName, completionDate, certificateId }) {
const html = CERT_TEMPLATE({ recipientName, courseName, completionDate, certificateId });
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: 120000,
}
);
return Buffer.from(response.data);
}
// Usage
(async () => {
const pdf = await generateCertificatePdf({
recipientName: 'Jane Smith',
courseName: 'Advanced Node.js Development',
completionDate: 'April 20, 2026',
certificateId: 'CERT-20260420-B4G8C3D2',
});
require('fs').writeFileSync('certificate.pdf', pdf);
console.log(`Generated: ${pdf.length.toLocaleString()} bytes`);
})();
LMS and HR System Integration
Moodle Webhook Integration (Python/Flask)
Configure Moodle to send a webhook on course_completed, then handle it server-side:
from flask import Flask, request, jsonify
import hashlib, datetime, os
from generate_certificate import generate_certificate_pdf
from send_email import send_certificate_email
from archive import save_to_s3
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MOODLE_WEBHOOK_SECRET"]
def verify_webhook(req) -> bool:
"""Verify the Moodle webhook signature."""
signature = req.headers.get("X-Moodle-Signature", "")
payload = req.get_data()
expected = hashlib.sha256(f"{WEBHOOK_SECRET}{payload.decode()}".encode()).hexdigest()
return signature == expected
@app.route("/webhooks/course-completion", methods=["POST"])
def handle_course_completion():
if not verify_webhook(request):
return jsonify({"error": "Invalid signature"}), 401
data = request.json
if data.get("event") != "course_completed":
return jsonify({"status": "ignored"}), 200
user = data["user"]
course = data["course"]
completion_date = datetime.datetime.fromisoformat(data["completiondate"]).strftime("%B %d, %Y")
certificate_id = generate_certificate_id(
recipient_name=f"{user['firstname']} {user['lastname']}",
course_name=course["fullname"],
date=completion_date,
)
pdf_bytes = generate_certificate_pdf(
recipient_name=f"{user['firstname']} {user['lastname']}",
course_name=course["fullname"],
completion_date=completion_date,
certificate_id=certificate_id,
)
save_to_s3(pdf_bytes, certificate_id)
send_certificate_email(
recipient_email=user["email"],
recipient_name=f"{user['firstname']} {user['lastname']}",
pdf_bytes=pdf_bytes,
certificate_id=certificate_id,
)
return jsonify({"status": "success", "certificate_id": certificate_id}), 200
def generate_certificate_id(recipient_name: str, course_name: str, date: str) -> str:
salt = os.environ["CERTIFICATE_SALT"]
payload = f"{recipient_name}|{course_name}|{date}|{salt}"
digest = hashlib.sha256(payload.encode()).hexdigest()[:16].upper()
return f"CERT-{datetime.date.today().strftime('%Y%m%d')}-{digest}"
Generic Webhook Handler (Node.js/Express)
// webhooks/course-completion.js
const express = require('express');
const crypto = require('crypto');
const { generateCertificatePdf } = require('../pdf/certificate');
const { saveToS3 } = require('../storage/s3');
const { sendCertificateEmail } = require('../email/mailer');
const router = express.Router();
function verifyWebhook(req) {
const secret = process.env.WEBHOOK_SECRET;
const signature = req.headers['x-webhook-signature'];
const expected = crypto
.createHmac('sha256', secret)
.update(req.rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature || '', 'utf-8'),
Buffer.from(expected, 'utf-8')
);
}
router.post('/course-completion', express.raw({ type: '*/*' }), async (req, res) => {
if (!verifyWebhook(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const data = JSON.parse(req.rawBody);
if (data.event !== 'course_completed') {
return res.status(200).json({ status: 'ignored' });
}
// Acknowledge immediately to avoid webhook timeout
res.status(200).json({ status: 'accepted' });
try {
const certificateId = generateCertificateId(data);
const pdfBuffer = await generateCertificatePdf({
recipientName: `${data.user.firstName} ${data.user.lastName}`,
courseName: data.course.name,
completionDate: new Date(data.completionDate).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
}),
certificateId,
});
await saveToS3(pdfBuffer, certificateId);
await sendCertificateEmail({
email: data.user.email,
name: `${data.user.firstName} ${data.user.lastName}`,
pdfBuffer,
certificateId,
});
} catch (error) {
console.error('Certificate generation failed:', error);
// Send alert (Slack, PagerDuty, etc.)
}
});
function generateCertificateId(data) {
const salt = process.env.CERTIFICATE_SALT;
const payload = `${data.user.email}|${data.course.id}|${data.completionDate}|${salt}`;
const digest = crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16).toUpperCase();
return `CERT-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${digest}`;
}
module.exports = router;
Error Handling and Retry Strategy
Python: Retry with Exponential Backoff
import time
import requests
import os
def generate_with_retry(
html: str,
options: dict,
max_retries: int = 3,
base_delay: float = 1.0,
) -> bytes:
"""Generate a certificate PDF with exponential backoff retries."""
for attempt in range(1, max_retries + 1):
try:
response = requests.post(
"https://pdf.funbrew.cloud/api/v1/generate",
headers={"Authorization": f"Bearer {os.environ['FUNBREW_API_KEY']}"},
json={"html": html, "options": options},
timeout=120,
)
# 4xx errors are client-side — do not retry
if 400 <= response.status_code < 500:
response.raise_for_status()
response.raise_for_status()
return response.content
except requests.exceptions.HTTPError as e:
if attempt == max_retries or (e.response and 400 <= e.response.status_code < 500):
raise
delay = base_delay * (2 ** (attempt - 1))
print(f"Attempt {attempt} failed ({e}). Retrying in {delay}s...")
time.sleep(delay)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
if attempt == max_retries:
raise
delay = base_delay * (2 ** (attempt - 1))
print(f"Attempt {attempt} connection error ({e}). Retrying in {delay}s...")
time.sleep(delay)
Error Response Reference
| HTTP Status | Cause | Action |
|---|---|---|
| 400 Bad Request | Invalid HTML or options | Fix input — do not retry |
| 401 Unauthorized | Invalid or missing API key | Check env var — do not retry |
| 429 Too Many Requests | Rate limit exceeded | Retry with exponential backoff |
| 500 Internal Server Error | Server-side error | Retry with exponential backoff |
| 503 Service Unavailable | Maintenance / overload | Wait and retry |
| Timeout | Generation taking too long | Increase timeout, then retry |
Certificate Data Management
Database Schema
-- Certificate issuance records
CREATE TABLE certificate_records (
id BIGSERIAL PRIMARY KEY,
certificate_id VARCHAR(64) NOT NULL UNIQUE, -- CERT-YYYYMMDD-XXXXXXXXXXXXXXXX
recipient_name VARCHAR(255) NOT NULL,
recipient_email VARCHAR(255) NOT NULL,
course_name VARCHAR(255) NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL = no expiry
s3_url TEXT, -- archive location
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active | revoked
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cert_recipient_email ON certificate_records (recipient_email);
CREATE INDEX idx_cert_course_name ON certificate_records (course_name);
CREATE INDEX idx_cert_issued_at ON certificate_records (issued_at);
Certificate Verification Endpoint
Provide a public endpoint so recipients can verify certificate authenticity:
# Flask: certificate verification endpoint
@app.route("/verify/<certificate_id>", methods=["GET"])
def verify_certificate(certificate_id: str):
record = db.query(
"SELECT * FROM certificate_records WHERE certificate_id = ? AND status = 'active'",
(certificate_id,)
).fetchone()
if not record:
return jsonify({"valid": False, "message": "Certificate not found or has been revoked."}), 404
if record["expires_at"] and record["expires_at"] < datetime.datetime.now(datetime.timezone.utc):
return jsonify({"valid": False, "message": "This certificate has expired."}), 200
return jsonify({
"valid": True,
"certificate_id": record["certificate_id"],
"recipient_name": record["recipient_name"],
"course_name": record["course_name"],
"issued_at": record["issued_at"].isoformat(),
"expires_at": record["expires_at"].isoformat() if record["expires_at"] else None,
})
Security Best Practices
API Key Rotation
# Rotate API keys every 90 days
# 1. Issue a new API key from FUNBREW PDF dashboard
# 2. Update the secret in your secrets manager
aws secretsmanager put-secret-value \
--secret-id prod/funbrew-pdf-api-key \
--secret-string '{"api_key":"sk-prod-new-key-xxxx"}'
# 3. Deploy the updated secret
# 4. Revoke the old key
Application-Level Rate Limiting
import redis
from functools import wraps
r = redis.Redis.from_url(os.environ["REDIS_URL"])
def rate_limit(key_prefix: str, max_requests: int, window_seconds: int):
"""Per-user rate limit decorator."""
def decorator(func):
@wraps(func)
def wrapper(*args, user_id: str = "anonymous", **kwargs):
key = f"{key_prefix}:{user_id}"
count = r.incr(key)
if count == 1:
r.expire(key, window_seconds)
if count > max_requests:
raise Exception(f"Rate limit exceeded: {max_requests} per {window_seconds}s")
return func(*args, user_id=user_id, **kwargs)
return wrapper
return decorator
# Limit to 10 certificates per user per hour
@rate_limit("cert_generation", max_requests=10, window_seconds=3600)
def issue_certificate(user_id: str, data: dict) -> bytes:
return generate_certificate_pdf(**data)
Related Guides
This guide covers system integration — 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
- API Certificate Generator Guide — Build a reusable SDK wrapper with retry logic and tamper-proof IDs
- Bulk Certificate Generator Guide — Issue 100 to 10,000+ certificates in a single batch run
- Bulk Certificate Generator for Events — Event-specific pipeline: Eventbrite CSV, ticket-tier templates, and QR verification
- HTML Certificate Templates — Five copy-paste HTML/CSS templates for different certificate types
- PDF Template Engine Guide — Handlebars, Jinja2, and Mustache patterns
- PDF API Error Handling Guide — Retry strategies with exponential backoff
- API Documentation — Full FUNBREW PDF API reference
Summary
Key points for integrating a certificate generator API:
- Choose the right architecture: Synchronous for on-demand single certificates; async queue for batch runs; event-driven for LMS/HR integration
- Separate template from data: Use a template engine to fill placeholders — never hardcode recipient data into the template file
- Generate unforgeable certificate IDs: SHA-256 hash of (name + course + date + secret salt) creates a deterministic, tamper-proof serial number
- Implement retry logic: Retry 5xx errors and 429 with exponential backoff; fail immediately on 4xx errors
- Publish a verification endpoint: Link each certificate's QR code to a public endpoint so recipients can prove authenticity
- Secure every layer: Manage API keys via a secrets manager, verify webhook signatures, and apply application-level rate limiting
Try your certificate template in the FUNBREW PDF Playground before writing integration code. The full API specification is available in the API docs.