May 10, 2026

Certificate PDF SDK Quickstart: Generate Your First PDF Fast

CertificatesSDKquickstartPDF generationDeveloper tools

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:

  1. First certificate: One API call with cURL, Node.js, Python, or PHP
  2. Templates: Separate HTML from data with Jinja2, Handlebars, or simple string substitution
  3. Response formats: Binary mode for direct download; URL mode to get a link for email or database storage
  4. Error handling: Retry with exponential backoff for transient errors (429, 503, timeout)
  5. Rate limits: Use a semaphore to cap concurrent requests; respect Retry-After headers
  6. Production tips: Embed images as Base64, pre-generate QR codes, validate output after bulk runs
  7. 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.

Powered by FUNBREW PDF