April 20, 2026

PDF Certificate API Integration Guide

CertificatesAPI IntegrationPDF GenerationAutomationSecurity

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:

Summary

Key points for integrating a certificate generator API:

  1. Choose the right architecture: Synchronous for on-demand single certificates; async queue for batch runs; event-driven for LMS/HR integration
  2. Separate template from data: Use a template engine to fill placeholders — never hardcode recipient data into the template file
  3. Generate unforgeable certificate IDs: SHA-256 hash of (name + course + date + secret salt) creates a deterministic, tamper-proof serial number
  4. Implement retry logic: Retry 5xx errors and 429 with exponential backoff; fail immediately on 4xx errors
  5. Publish a verification endpoint: Link each certificate's QR code to a public endpoint so recipients can prove authenticity
  6. 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.

Powered by FUNBREW PDF