Certificate PDF SDK Quickstart: Generate Your First PDF Fast
Not sure where to start with certificate PDF generation? This guide takes you from zero to a working certificate in minutes using the FUNBREW PDF API.
You will see the direct API call first, then build toward a reusable template pattern — the foundation for any production certificate system.
For the full picture of certificate automation (template design, S3 archival, email delivery, QR verification), start with the Certificate PDF Automation Guide. When you are ready for SDK design details — retry logic, HMAC tamper-proof IDs, multi-template management — see the Certificate PDF SDK Guide.
Generate Your First Certificate
Get your API key from the FUNBREW PDF dashboard.
cURL (quick test)
curl -s -X POST https://pdf.funbrew.cloud/api/pdf/generate \
-H "Authorization: Bearer sk-your-api-key" \
-H "Content-Type: application/json" \
-d '{
"html": "<div style=\"font-family: sans-serif; text-align: center; padding: 80px;\"><h1>Certificate of Completion</h1><p style=\"font-size: 24px; font-weight: 700;\">Jane Smith</p><p>Advanced Web Development</p><p>May 10, 2026</p></div>",
"options": { "format": "A4", "landscape": true }
}' \
--output certificate.pdf
You get a PDF on disk in a few seconds — no template, no setup.
JavaScript (Node.js)
const fs = require("fs");
async function generateCertificate({ name, course, date }) {
const html = `
<div style="
font-family: Georgia, serif;
text-align: center;
padding: 80px 60px;
border: 8px double #b8860b;
">
<p style="font-size: 12px; letter-spacing: 0.3em; color: #9ca3af;">
CERTIFICATE OF COMPLETION
</p>
<h1 style="font-size: 32px; margin: 20px 0;">Certificate</h1>
<p style="font-size: 22px; font-weight: 700; margin: 28px 0;">${name}</p>
<p style="font-size: 14px; color: #6b7280;">
has successfully completed
</p>
<p style="font-size: 20px; font-weight: 600; margin: 20px 0 40px;">${course}</p>
<p style="font-size: 12px; color: #9ca3af;">${date}</p>
</div>
`;
const response = await fetch("https://pdf.funbrew.cloud/api/pdf/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: { format: "A4", landscape: true, printBackground: true },
}),
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
// Run it
generateCertificate({
name: "Jane Smith",
course: "Advanced Web Development",
date: "May 10, 2026",
}).then(pdf => {
fs.writeFileSync("certificate.pdf", pdf);
console.log("certificate.pdf saved");
});
Python
import os
import httpx
def generate_certificate(name: str, course: str, date: str) -> bytes:
html = f"""
<div style="
font-family: Georgia, serif;
text-align: center;
padding: 80px 60px;
border: 8px double #b8860b;
">
<p style="font-size: 12px; letter-spacing: 0.3em; color: #9ca3af;">
CERTIFICATE OF COMPLETION
</p>
<h1 style="font-size: 32px; margin: 20px 0;">Certificate</h1>
<p style="font-size: 22px; font-weight: 700; margin: 28px 0;">{name}</p>
<p style="font-size: 14px; color: #6b7280;">has successfully completed</p>
<p style="font-size: 20px; font-weight: 600; margin: 20px 0 40px;">{course}</p>
<p style="font-size: 12px; color: #9ca3af;">{date}</p>
</div>
"""
response = httpx.post(
"https://pdf.funbrew.cloud/api/pdf/generate",
headers={"Authorization": f"Bearer {os.environ['FUNBREW_PDF_API_KEY']}"},
json={
"html": html,
"options": {"format": "A4", "landscape": True, "printBackground": True},
},
)
response.raise_for_status()
return response.content
pdf = generate_certificate("Jane Smith", "Advanced Web Development", "May 10, 2026")
with open("certificate.pdf", "wb") as f:
f.write(pdf)
print("certificate.pdf saved")
Using an HTML Template File
Inline HTML strings work for a proof of concept. For production, store the template separately.
Template file (templates/certificate.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap" rel="stylesheet">
<style>
body { margin: 0; background: #fffdf5; }
.cert {
width: 277mm; height: 190mm;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
border: 10px double #b8860b;
padding: 48px;
box-sizing: border-box;
font-family: 'Playfair Display', Georgia, serif;
text-align: center;
}
.label { font-size: 11px; letter-spacing: 0.4em; color: #9ca3af; margin: 0; }
.title { font-size: 36px; margin: 16px 0; }
.name { font-size: 28px; font-weight: 700; margin: 24px 0 8px; }
.course { font-size: 18px; color: #374151; margin: 16px 0 32px; }
.date { font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="cert">
<p class="label">CERTIFICATE OF COMPLETION</p>
<h1 class="title">Certificate</h1>
<p class="name">{{name}}</p>
<p class="label">has successfully completed</p>
<p class="course">{{course}}</p>
<p class="date">{{date}}</p>
</div>
</body>
</html>
Node.js — load and render
const fs = require("fs");
async function renderCertificate(templatePath, data) {
let html = fs.readFileSync(templatePath, "utf-8");
// Simple {{key}} substitution — swap for Handlebars/Mustache for complex templates
for (const [key, value] of Object.entries(data)) {
html = html.replaceAll(`{{${key}}}`, value);
}
const response = await fetch("https://pdf.funbrew.cloud/api/pdf/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: { format: "A4", landscape: true, printBackground: true },
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
const pdf = await renderCertificate("templates/certificate.html", {
name: "Jane Smith",
course: "Advanced Web Development",
date: "May 10, 2026",
});
fs.writeFileSync("certificate.pdf", pdf);
Python — Jinja2 rendering
import os
import httpx
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("templates"))
def render_certificate(name: str, course: str, date: str) -> bytes:
template = env.get_template("certificate.html")
html = template.render(name=name, course=course, date=date)
response = httpx.post(
"https://pdf.funbrew.cloud/api/pdf/generate",
headers={"Authorization": f"Bearer {os.environ['FUNBREW_PDF_API_KEY']}"},
json={
"html": html,
"options": {"format": "A4", "landscape": True, "printBackground": True},
},
)
response.raise_for_status()
return response.content
API Response Formats
The API supports two output modes.
Binary response (direct download)
// Default mode: response body is the raw PDF bytes
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync("certificate.pdf", buffer);
JSON response (download URL)
// Set responseFormat: "url" to receive a download link instead
const body = JSON.stringify({
html,
options: { format: "A4", landscape: true, responseFormat: "url" },
});
const { data } = await response.json();
console.log("Download URL:", data.download_url);
// → https://pdf.funbrew.cloud/dl/abc123... (expires after 24 h)
Use the URL mode when you want to store the link in a database or send it in an email rather than handling the bytes directly.
What to Do Next
Generate certificates in bulk
For 100–10,000 recipients from a CSV file, with parallel API calls and ZIP packaging, see the Bulk Certificate Generator Guide.
Add retry, tamper-proof IDs, and multi-template management
To build a production-grade SDK wrapper with exponential backoff and HMAC-signed certificate IDs, see the Certificate PDF SDK Guide.
Integrate with an LMS or HR system
For webhook-driven pipelines that trigger certificate generation on course completion events, see the Certificate API Integration Guide.
Choose a ready-made template
Five copy-paste HTML certificate templates (completion, award, diploma, attendance, qualification) are available in the HTML Certificate Templates guide.
Try your certificate template right now in the FUNBREW PDF Playground. Full API reference is available in the API docs.
PHP Certificate Generation
If your stack is PHP (Laravel, Symfony, or plain PHP), the same pattern applies.
PHP (single certificate)
<?php
function generateCertificate(string $name, string $course, string $date): string
{
$html = <<<HTML
<div style="
font-family: Georgia, serif;
text-align: center;
padding: 80px 60px;
border: 8px double #b8860b;
">
<p style="font-size: 11px; letter-spacing: 0.3em; color: #9ca3af;">CERTIFICATE OF COMPLETION</p>
<h1 style="font-size: 30px; margin: 20px 0;">Certificate</h1>
<p style="font-size: 22px; font-weight: 700; margin: 28px 0;">{$name}</p>
<p style="font-size: 14px; color: #6b7280;">has successfully completed</p>
<p style="font-size: 18px; font-weight: 600; margin: 20px 0 40px;">{$course}</p>
<p style="font-size: 12px; color: #9ca3af;">Issued on {$date}</p>
</div>
HTML;
$response = \Illuminate\Support\Facades\Http::withToken(env('FUNBREW_PDF_API_KEY'))
->post('https://pdf.funbrew.cloud/api/pdf/generate', [
'html' => $html,
'options' => [
'format' => 'A4',
'landscape' => true,
'printBackground' => true,
],
]);
if ($response->failed()) {
throw new \RuntimeException("PDF generation failed: HTTP {$response->status()}");
}
return $response->body(); // raw PDF bytes
}
// Usage
$pdfBytes = generateCertificate('Jane Smith', 'Advanced Web Development', 'May 15, 2026');
file_put_contents('certificate.pdf', $pdfBytes);
echo "certificate.pdf saved\n";
PHP (Laravel Artisan command for batch issuance)
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class GenerateCertificates extends Command
{
protected $signature = 'certificates:generate {--csv= : Path to recipients CSV}';
protected $description = 'Bulk-generate certificate PDFs from a CSV file';
public function handle(): int
{
$csvPath = $this->option('csv') ?? storage_path('app/recipients.csv');
$rows = array_map('str_getcsv', file($csvPath));
$headers = array_shift($rows); // first row is header
$bar = $this->output->createProgressBar(count($rows));
$bar->start();
foreach ($rows as $row) {
$data = array_combine($headers, $row);
$this->generateAndSave(
name: $data['name'],
course: $data['course'],
date: $data['date'],
certId: $data['cert_id'],
);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info(count($rows) . ' certificates generated.');
return Command::SUCCESS;
}
private function generateAndSave(string $name, string $course, string $date, string $certId): void
{
$html = view('certificates.template', compact('name', 'course', 'date', 'certId'))->render();
$response = Http::withToken(config('services.funbrew_pdf.api_key'))
->timeout(120)
->post('https://pdf.funbrew.cloud/api/pdf/generate', [
'html' => $html,
'options' => ['format' => 'A4', 'landscape' => true, 'printBackground' => true],
]);
$response->throw();
$path = storage_path("app/certificates/cert-{$certId}.pdf");
file_put_contents($path, $response->body());
}
}
Error Handling and Retry
In production, transient errors — network timeouts, rate limit responses, and occasional 503s — are unavoidable. Implement retry logic so that a temporary blip does not cause you to miss issuing certificates.
Node.js (exponential backoff retry)
/**
* Retry with exponential backoff and jitter.
* @param {Function} fn - Async function to execute
* @param {number} retries - Maximum number of retries (default 3)
* @param {number} delay - Initial delay in ms (default 1000)
*/
async function withRetry(fn, retries = 3, delay = 1000) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
const isLast = attempt === retries;
const isRetryable = err.message.includes("429")
|| err.message.includes("503")
|| err.message.includes("timeout");
if (isLast || !isRetryable) throw err;
const wait = delay * Math.pow(2, attempt) + Math.random() * 500; // jitter
console.warn(`[Retry ${attempt + 1}/${retries}] Waiting ${wait.toFixed(0)}ms...`);
await new Promise((r) => setTimeout(r, wait));
}
}
}
async function generateCertificateWithRetry({ name, course, date, certId }, apiKey) {
return withRetry(async () => {
const html = buildCertificateHtml({ name, course, date, certId });
const response = await fetch("https://pdf.funbrew.cloud/api/pdf/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
options: { format: "A4", landscape: true, printBackground: true },
}),
signal: AbortSignal.timeout(120_000),
});
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") || "60";
throw new Error(`429 Too Many Requests — Retry-After: ${retryAfter}s`);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return Buffer.from(await response.arrayBuffer());
});
}
function buildCertificateHtml({ name, course, date, certId }) {
return `
<div style="font-family: Georgia, serif; text-align: center; padding: 80px 60px; border: 8px double #b8860b;">
<h1 style="font-size: 28px; margin: 20px 0;">Certificate of Completion</h1>
<p style="font-size: 22px; font-weight: 700; margin: 28px 0;">${name}</p>
<p style="font-size: 18px; margin: 20px 0;">${course}</p>
<p style="font-size: 12px; color: #9ca3af;">${date}</p>
<p style="font-size: 9px; color: #d1d5db; margin-top: 30px;">Certificate ID: ${certId}</p>
</div>
`;
}
Python (retry with tenacity)
import os
import httpx
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type,
before_sleep_log,
)
import logging
logger = logging.getLogger(__name__)
class RateLimitError(Exception):
pass
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=30),
retry=retry_if_exception_type((httpx.TransportError, RateLimitError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
)
def generate_certificate_with_retry(name: str, course: str, date: str, cert_id: str) -> bytes:
"""Generate a single certificate PDF with automatic retry."""
html = f"""
<div style="font-family: Georgia, serif; text-align: center; padding: 80px 60px; border: 8px double #b8860b;">
<h1 style="font-size: 28px; margin: 20px 0;">Certificate of Completion</h1>
<p style="font-size: 22px; font-weight: 700; margin: 28px 0;">{name}</p>
<p style="font-size: 18px; margin: 20px 0;">{course}</p>
<p style="font-size: 12px; color: #9ca3af;">{date}</p>
<p style="font-size: 9px; color: #d1d5db;">Certificate ID: {cert_id}</p>
</div>
"""
response = httpx.post(
"https://pdf.funbrew.cloud/api/pdf/generate",
headers={"Authorization": f"Bearer {os.environ['FUNBREW_PDF_API_KEY']}"},
json={
"html": html,
"options": {"format": "A4", "landscape": True, "printBackground": True},
},
timeout=120,
)
if response.status_code == 429:
raise RateLimitError(f"Rate limited — Retry-After: {response.headers.get('Retry-After', '?')}s")
response.raise_for_status()
return response.content
Rate Limit Handling
FUNBREW PDF enforces per-plan rate limits. In bulk certificate jobs, you must stay within those limits to avoid 429 errors.
| Plan | Limit | Notes |
|---|---|---|
| Starter | 20 req/min | Short bursts allowed |
| Pro | 60 req/min | — |
| Enterprise | Custom | — |
When you receive a 429 response, read the Retry-After header and wait that many seconds before retrying. For details, see the PDF API Rate Limiting Guide.
Concurrency control with a semaphore (Node.js)
function createSemaphore(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 fs = require("fs");
const path = require("path");
fs.mkdirSync(outputDir, { recursive: true });
const sem = createSemaphore(5); // 5 concurrent max (Pro plan: safe up to 10)
const results = { success: [], failed: [] };
await Promise.allSettled(
recipients.map(async (r) => {
await sem.acquire();
try {
const pdf = await generateCertificateWithRetry(r, apiKey);
const filePath = path.join(outputDir, `cert-${r.certId}.pdf`);
fs.writeFileSync(filePath, pdf);
results.success.push(r.certId);
console.log(`[OK] ${r.certId}`);
} catch (err) {
results.failed.push({ certId: r.certId, error: err.message });
console.error(`[NG] ${r.certId}: ${err.message}`);
} finally {
sem.release();
}
})
);
console.log(`\nDone: ${results.success.length} succeeded / ${results.failed.length} failed`);
if (results.failed.length > 0) {
fs.writeFileSync("failed-certs.json", JSON.stringify(results.failed, null, 2));
console.log("Saved failed IDs to failed-certs.json — re-run to retry.");
}
return results;
}
Production Tips
Embed signature images as Base64
References to external URLs can time out during PDF rendering. Embed signature images and logos as Base64 data URIs to guarantee they are always available.
import base64
def embed_image(image_path: str) -> str:
"""Convert an image file to a Base64 data URI."""
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
ext = image_path.rsplit(".", 1)[-1]
return f"data:image/{ext};base64,{b64}"
signature_uri = embed_image("signatures/director.png")
html = template.render(
name=recipient_name,
course=course_name,
date=completion_date,
cert_id=cert_id,
signature_uri=signature_uri,
)
<!-- In your template -->
<img src="{{signature_uri}}" alt="Authorized Signature" width="120" height="50"
style="display: block; margin: 10mm auto 0;">
Pre-generate QR codes as Base64
import qrcode, io, base64
def generate_qr_data_uri(cert_id: str, verify_base_url: str) -> str:
"""Return a Base64 data URI for a QR code pointing to the verification URL."""
url = f"{verify_base_url}/verify/{cert_id}"
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
qr_uri = generate_qr_data_uri("CERT-2026-001234", "https://example.com")
Post-generation validation
After a bulk run, validate that every PDF was generated correctly before treating the job as complete.
import os
def validate_generated_pdfs(cert_dir: str, expected_ids: list[str]) -> dict:
"""Verify that all expected certificates are present and valid."""
results = {"ok": [], "missing": [], "corrupted": []}
for cert_id in expected_ids:
path = os.path.join(cert_dir, f"cert-{cert_id}.pdf")
if not os.path.exists(path):
results["missing"].append(cert_id)
continue
with open(path, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
results["corrupted"].append(cert_id)
elif os.path.getsize(path) < 1024: # less than 1 KB = likely empty
results["corrupted"].append(cert_id)
else:
results["ok"].append(cert_id)
print(f"OK: {len(results['ok'])} / Missing: {len(results['missing'])} / Corrupted: {len(results['corrupted'])}")
return results
Troubleshooting
PDF is empty or not generated
The most common cause is a missing or invalid API key. Test with a verbose curl call:
curl -v -X POST https://pdf.funbrew.cloud/api/pdf/generate \
-H "Authorization: Bearer $FUNBREW_PDF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html": "<h1>Test</h1>", "options": {"format": "A4"}}' \
-o test.pdf
A 401 Unauthorized response means the key is wrong or missing. A 200 OK with a zero-byte test.pdf means the --output flag is missing from the call.
Non-ASCII characters (CJK, accents) are garbled
Ensure the HTML document has <meta charset="UTF-8"> and use a font that supports the characters you need. FUNBREW PDF pre-installs Noto Sans JP for Japanese.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Noto Sans JP', Georgia, serif; }
</style>
</head>
Some batch items fail intermittently
Intermittent failures in batch jobs are almost always transient network errors or rate limit responses.
- Keep concurrent requests at 5 or below (use the semaphore pattern above)
- Implement retry with exponential backoff for 429 and 503 errors
- Set a 120-second timeout — complex HTML can take time to render
- Save failed IDs to a JSON file and re-run only those on the next pass
Certificate layout shifts between runs
Use explicit @page rules and CSS custom properties to lock down the layout:
<style>
@page {
size: A4 landscape;
margin: 0;
}
* { box-sizing: border-box; }
body {
width: 297mm;
height: 210mm;
margin: 0;
padding: 0;
}
</style>
For a deep dive into print CSS that translates faithfully to PDF, see PDF CSS Layout Tips.
How to generate unique certificate IDs reliably
Combine a hash of recipient data with a secret salt, or use UUID:
import hashlib, uuid
from datetime import date
def make_cert_id_hash(recipient_id: int, course_id: int) -> str:
"""Deterministic, unforgeable certificate ID."""
today = date.today().strftime("%Y%m%d")
raw = f"{recipient_id}-{course_id}-{today}-{SECRET_SALT}"
digest = hashlib.sha256(raw.encode()).hexdigest()[:10].upper()
return f"CERT-{today}-{digest}"
def make_cert_id_uuid() -> str:
"""Fully random UUID-based certificate ID."""
return f"CERT-{uuid.uuid4().hex[:12].upper()}"
For HMAC-signed tamper-proof IDs (suitable for compliance use cases), see the Certificate PDF SDK Guide.
Summary
Key concepts covered in this guide:
- First certificate: One API call with cURL, Node.js, Python, or PHP
- Templates: Separate HTML from data with Jinja2, Handlebars, or simple string substitution
- Response formats: Binary mode for direct download; URL mode to get a link for email or database storage
- Error handling: Retry with exponential backoff for transient errors (429, 503, timeout)
- Rate limits: Use a semaphore to cap concurrent requests; respect
Retry-Afterheaders - Production tips: Embed images as Base64, pre-generate QR codes, validate output after bulk runs
- Troubleshooting: Empty files, garbled characters, layout shifts, and intermittent batch failures
Use the FUNBREW PDF Playground to test your certificate template before writing integration code. Full API docs at the API Reference. For high-volume batch architecture, see the PDF Batch Processing Guide. For event-specific bulk issuance pipelines, see the Bulk Certificate Generator for Events Guide.