2026/05/02

API証明書ジェネレーターの実装ガイド:再利用可能なPDF証明書SDK

証明書APISDKPDF生成開発者向け

PDF APIに直接リクエストするたびにAPIキー・オプション・テンプレートロジックが分散してしまいます。このガイドでは FUNBREW PDF をラップした薄い再利用可能なSDKの構築方法を解説します。

このガイドが扱う範囲は SDK設計(ラッパー実装・リトライ・改ざん防止ID) に特化しています。証明書パイプライン全体のアーキテクチャ(テンプレート設計・S3保存・メール配信)は証明書PDF自動化ガイドを参照してください。大量のCSVからイベント参加者への一括発行が目的の場合はイベント証明書一括発行ガイドが直接役立ちます。

完成すると以下が手に入ります。

  • CertificateGenerator クラス(Python)と createCertificateGenerator ファクトリ(Node.js)
  • コードとHTMLデザインを分離するテンプレート管理
  • 指数バックオフ付き自動リトライ
  • 改ざん防止証明書IDの生成パターン

LMSウェブフックと非同期バッチパイプラインは 証明書API連携ガイド を参照してください。100〜10,000件の一括発行は 証明書一括発行ガイド で解説しています。

FUNBREW PDF Playground でテンプレートの動作を確認しながら開発できます。

SDK の設計

SDKは3つの責務を持ちます。

  1. テンプレートレンダリング — 受講者データをHTMLテンプレートに差し込む
  2. PDF生成 — レンダリング済みHTMLをAPIに送信してバイト列を返す
  3. 信頼性 — 一時的なエラーでリトライし、クライアントエラーは即座に失敗させる
CertificateGenerator
  ├── templates/          # HTMLテンプレートファイル
  │   └── completion.html
  ├── generate(data)      # → bytes
  ├── generateToFile(data, path)
  └── generateToBase64(data) → string

Python SDK

インストール

pip install requests jinja2

SDKモジュール

# certificate_generator/sdk.py
import os
import time
import hashlib
import datetime
from pathlib import Path
from typing import Optional

import requests
from jinja2 import Environment, FileSystemLoader


class CertificateGenerator:
    """FUNBREW PDF APIを使った証明書PDF生成SDK"""

    DEFAULT_OPTIONS = {
        "format": "A4",
        "landscape": True,
        "printBackground": True,
        "margin": {"top": "0", "right": "0", "bottom": "0", "left": "0"},
    }

    def __init__(
        self,
        api_key: Optional[str] = None,
        api_url: str = "https://pdf.funbrew.cloud/api/v1/generate",
        template_dir: Optional[str] = None,
        max_retries: int = 3,
        timeout: int = 120,
    ):
        self.api_key = api_key or os.environ["FUNBREW_API_KEY"]
        self.api_url = api_url
        self.max_retries = max_retries
        self.timeout = timeout

        template_path = template_dir or str(Path(__file__).parent / "templates")
        self._jinja = Environment(
            loader=FileSystemLoader(template_path),
            autoescape=True,
        )

    def generate(self, template_name: str, data: dict, options: Optional[dict] = None) -> bytes:
        """テンプレートをレンダリングしてPDFバイト列を返す"""
        html = self._render(template_name, data)
        return self._post_with_retry(html, options or self.DEFAULT_OPTIONS)

    def generate_to_file(self, path: str, template_name: str, data: dict, options: Optional[dict] = None) -> None:
        """証明書を生成してファイルに保存する"""
        Path(path).write_bytes(self.generate(template_name, data, options))

    @staticmethod
    def make_certificate_id(recipient_name: str, course_name: str, issued_date: str, salt: Optional[str] = None) -> str:
        """改ざん防止の決定論的な証明書IDを生成する"""
        secret = salt or os.environ["CERTIFICATE_SALT"]
        payload = f"{recipient_name}|{course_name}|{issued_date}|{secret}"
        digest = hashlib.sha256(payload.encode()).hexdigest()[:16].upper()
        date_str = datetime.date.today().strftime("%Y%m%d")
        return f"CERT-{date_str}-{digest}"

    def _render(self, template_name: str, data: dict) -> str:
        return self._jinja.get_template(template_name).render(**data)

    def _post_with_retry(self, html: str, options: dict) -> bytes:
        for attempt in range(1, self.max_retries + 1):
            try:
                resp = requests.post(
                    self.api_url,
                    headers={"Authorization": f"Bearer {self.api_key}"},
                    json={"html": html, "options": options},
                    timeout=self.timeout,
                )
                if 400 <= resp.status_code < 500:
                    resp.raise_for_status()
                resp.raise_for_status()
                return resp.content
            except requests.HTTPError as exc:
                if attempt == self.max_retries:
                    raise
                if exc.response and 400 <= exc.response.status_code < 500:
                    raise
            except (requests.Timeout, requests.ConnectionError):
                if attempt == self.max_retries:
                    raise
            time.sleep(2 ** (attempt - 1))  # 1s → 2s → 4s

テンプレート例

<!-- certificate_generator/templates/completion.html -->
<!DOCTYPE html>
<html lang="ja">
<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: 'Noto Sans JP', 'Hiragino Kaku Gothic ProN', sans-serif;
      display: flex; align-items: center; justify-content: center;
      background: #fff;
    }
    .cert {
      width: 270mm; height: 185mm;
      border: 4px double #2c5282;
      padding: 18mm 22mm;
      text-align: center;
      position: relative;
    }
    .title   { font-size: 34pt; font-weight: bold; color: #2c5282; margin: 6mm 0; letter-spacing: 0.1em; }
    .name    { font-size: 26pt; border-bottom: 2px solid #2c5282; display: inline-block; min-width: 140mm; padding-bottom: 2mm; }
    .course  { font-size: 14pt; color: #4a5568; margin: 5mm 0; }
    .date    { font-size: 11pt; color: #718096; margin-top: 6mm; }
    .cert-id { position: absolute; bottom: 6mm; right: 10mm; font-size: 7pt; color: #a0aec0; }
  </style>
</head>
<body>
  <div class="cert">
    <div class="title">修 了 証</div>
    <p style="font-size:12pt; color:#4a5568; margin-bottom:4mm;">以下の方が所定のカリキュラムを修了されましたことを証します。</p>
    <div class="name">{{ recipient_name }} 様</div>
    <div class="course">{{ course_name }}</div>
    <div class="date">修了日: {{ completion_date }}</div>
    <div class="cert-id">証明書番号: {{ certificate_id }}</div>
  </div>
</body>
</html>

使用例

from certificate_generator.sdk import CertificateGenerator

gen = CertificateGenerator()

cert_id = CertificateGenerator.make_certificate_id(
    recipient_name="田中 太郎",
    course_name="Python実践講座",
    issued_date="2026-05-02",
)

gen.generate_to_file(
    path=f"output/{cert_id}.pdf",
    template_name="completion.html",
    data={
        "recipient_name": "田中 太郎",
        "course_name": "Python実践講座",
        "completion_date": "2026年5月2日",
        "certificate_id": cert_id,
    },
)
print(f"保存完了: output/{cert_id}.pdf")

Node.js SDK

インストール

npm install axios handlebars

SDKモジュール

// certificate-generator/sdk.js
const axios = require('axios');
const Handlebars = require('handlebars');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');

const DEFAULT_OPTIONS = {
  format: 'A4',
  landscape: true,
  printBackground: true,
  margin: { top: '0', right: '0', bottom: '0', left: '0' },
};

function createCertificateGenerator({
  apiKey = process.env.FUNBREW_API_KEY,
  apiUrl = 'https://pdf.funbrew.cloud/api/v1/generate',
  templateDir = path.join(__dirname, 'templates'),
  maxRetries = 3,
  timeoutMs = 120_000,
} = {}) {
  const templateCache = new Map();

  function loadTemplate(templateName) {
    if (!templateCache.has(templateName)) {
      const src = fs.readFileSync(path.join(templateDir, templateName), 'utf-8');
      templateCache.set(templateName, Handlebars.compile(src));
    }
    return templateCache.get(templateName);
  }

  async function postWithRetry(html, options) {
    let lastError;
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const response = await axios.post(
          apiUrl,
          { html, options },
          { headers: { Authorization: `Bearer ${apiKey}` }, responseType: 'arraybuffer', timeout: timeoutMs }
        );
        return Buffer.from(response.data);
      } catch (err) {
        lastError = err;
        const status = err.response?.status;
        if (status >= 400 && status < 500) throw err; // 4xxはリトライしない
        if (attempt < maxRetries) {
          await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
        }
      }
    }
    throw lastError;
  }

  async function generate(templateName, data, options = DEFAULT_OPTIONS) {
    const html = loadTemplate(templateName)(data);
    return postWithRetry(html, options);
  }

  async function generateToFile(filePath, templateName, data, options) {
    const buf = await generate(templateName, data, options);
    fs.writeFileSync(filePath, buf);
  }

  return { generate, generateToFile };
}

function makeCertificateId(recipientName, courseName, issuedDate, salt) {
  const secret = salt || process.env.CERTIFICATE_SALT;
  const payload = `${recipientName}|${courseName}|${issuedDate}|${secret}`;
  const digest = crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16).toUpperCase();
  const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  return `CERT-${datePart}-${digest}`;
}

module.exports = { createCertificateGenerator, makeCertificateId };

使用例

const { createCertificateGenerator, makeCertificateId } = require('./certificate-generator/sdk');

const gen = createCertificateGenerator();

async function issueOneCertificate() {
  const certId = makeCertificateId('田中 太郎', 'Node.js実践講座', '2026-05-02');

  await gen.generateToFile(
    `output/${certId}.pdf`,
    'completion.html',
    {
      recipientName: '田中 太郎',
      courseName: 'Node.js実践講座',
      completionDate: '2026年5月2日',
      certificateId: certId,
    },
  );
  console.log(`保存完了: output/${certId}.pdf`);
}

issueOneCertificate();

複数テンプレートの管理

修了証・賞状・参加証など証明書の種類が増えても、テンプレートファイルを追加するだけでSDKのコードは変更不要です。

templates/
├── completion.html       # 修了証
├── achievement.html      # 成績優秀・表彰
├── participation.html    # 参加証
└── qualification.html    # 資格証明書

呼び出し側でテンプレート名を切り替えるだけで対応できます。

# 表彰状テンプレートを使う
gen.generate_to_file("output/cert.pdf", "achievement.html", data)

一括発行との組み合わせ

研修修了者が100名いる場合、SDKの generate() をそのまま並列呼び出しすれば一括発行できます。セマフォで同時リクエスト数を制限することでレート制限内に収まります。

import asyncio, csv, httpx

async def bulk_generate(csv_path: str, concurrency: int = 5):
    gen = CertificateGenerator()
    sem = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient() as client:
        with open(csv_path) as f:
            rows = list(csv.DictReader(f))
        tasks = [generate_async(client, sem, gen, row) for row in rows]
        await asyncio.gather(*tasks)

asyncio.run(bulk_generate("recipients.csv"))

100〜10,000件の完全な実装は 証明書一括発行ガイド を参照してください。

並列5で1,000件処理すると5〜10分が目安です。100〜10,000件の完全な実装は 証明書一括発行ガイド で解説しています。

エラー処理リファレンス

SDKは4xx(クライアントエラー)と5xx/429(一時エラー)を区別して処理します。4xxはリクエスト内容の問題なのでリトライしません。5xxと429は指数バックオフ(1秒→2秒→4秒)でリトライします。

HTTPステータス 原因 SDKの挙動
400 Bad Request 不正なHTMLまたはオプション 即座に失敗(リトライしない)
401 Unauthorized 無効なAPIキー 即座に失敗(環境変数を確認)
429 Too Many Requests レート制限超過 指数バックオフでリトライ
500 Internal Server Error サーバーサイドエラー 指数バックオフでリトライ
Timeout 生成タイムアウト 指数バックオフでリトライ

関連ガイド

このガイドはSDK設計ステージに特化しています。証明書の全体フローを把握したい場合は、シリーズのハブガイドである証明書PDF自動化ガイドから始めてください。

シリーズの他のガイド:

まとめ

PDF APIをラップした薄いSDKを用意すると以下が得られます。

  1. テンプレート分離 — HTMLデザインはファイルに置き、コードにstring literalを書かない
  2. 決定論的ID — HMACベースの証明書IDで改ざん防止と追跡性を確保
  3. 組み込みリトライ — 一時的なエラーに指数バックオフで自動対応
  4. 一括スケーリングgenerate() を並列数制御付きで呼び出すだけで大量発行に対応

FUNBREW PDF Playground でテンプレートの動作を確認してから実装を進めてください。

Powered by FUNBREW PDF