2026/04/20

証明書API連携ガイド|PDF証明書発行システムの設計と実装

証明書API連携PDF生成自動化セキュリティ

修了証・資格証明書・参加証を自動発行したい――しかし「証明書APIをどのように既存システムに組み込めばいいか」という設計の全体像が見えにくいことがあります。

この記事では、証明書APIとシステムを連携させる際の設計パターンから実装手順まで、FUNBREW PDF APIを使った実践的なガイドを提供します。LMSやHRシステムとの連携、API認証、エンドポイント設計、エラーハンドリングまで、証明書発行システムの構築に必要な知識を網羅します。

HTMLテンプレートの設計についてはHTML証明書テンプレート集を参照してください。100件以上の大量発行については証明書一括発行ガイドで詳しく解説しています。証明書発行全体のワークフロー(署名・メール配信・S3保存)は証明書PDF自動生成ガイドでカバーしています。

証明書APIとは

証明書API(Certificate Generator API)は、プログラムからHTTPリクエストを送ることで証明書PDFを自動生成するサービスです。

[あなたのシステム]
      |
      | POST /api/v1/generate
      | { html: "<証明書HTML>", options: { format: "A4" } }
      |
      v
[FUNBREW PDF API]
      |
      | ← PDF バイナリ
      v
[あなたのシステム]
      |
      +-- DBに保存
      +-- S3にアーカイブ
      +-- メール送信

FUNBREW PDF APIは、レンダリング済みHTMLを受け取り、高品質なPDFを返します。テンプレートエンジン(Jinja2・Handlebars等)でHTMLを組み立てる部分はあなたのシステム側で行います。

連携アーキテクチャの設計

パターン1: 同期型(即時発行)

コース修了後すぐに証明書を生成して返すシンプルなアーキテクチャです。

ユーザー → システム → PDF API → PDF生成 → ユーザーに返す

適したケース: 1〜10件の単発発行、Webアプリからの即時ダウンロード

実装: HTTPリクエストを同期的に送り、レスポンスのPDFバイナリを直接返す

パターン2: 非同期型(キューイング)

大量発行や時間のかかる処理をバックグラウンドで実行するアーキテクチャです。

ユーザー → システム → ジョブキュー → ワーカー → PDF API → S3保存 → メール通知

適したケース: 100件以上のバッチ発行、研修修了者の一括発行

実装: ジョブキュー(Redis Queue・BullMQ・Celery等)を使った非同期処理

パターン3: イベント駆動型(Webhook)

LMSやHRシステムからのイベントをトリガーに証明書を自動発行するアーキテクチャです。

LMS修了イベント → Webhook → 証明書発行サービス → PDF API → 保存・配信

適したケース: LMS・HRシステムとの完全自動連携

基本実装:APIを直接呼び出す

curl(テスト・検証用)

# テンプレートHTMLを変数に格納
CERT_HTML='<!DOCTYPE html>
<html lang="ja">
<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: "Noto Sans JP", sans-serif; }
    .cert { width: 270mm; height: 185mm; border: 4px double #2c5282; padding: 20mm 25mm; text-align: center; }
    .title { font-size: 36pt; color: #2c5282; }
    .name { font-size: 28pt; border-bottom: 2px solid #2c5282; display: inline-block; min-width: 150mm; }
  </style>
</head>
<body>
  <div class="cert">
    <div class="title">修了証</div>
    <div class="name">田中 太郎 様</div>
    <p>Python実践講座を修了されました。</p>
    <p>2026年4月20日</p>
  </div>
</body>
</html>'

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}}" \
  --output "certificate.pdf"

echo "生成完了: $(wc -c < certificate.pdf) bytes"

Python: シンプルな証明書生成関数

import os
import requests
from jinja2 import Template

FUNBREW_API_URL = "https://pdf.funbrew.cloud/api/v1/generate"
API_KEY = os.environ["FUNBREW_API_KEY"]

CERTIFICATE_TEMPLATE = """<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4 landscape; margin: 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;
    }
    .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">修了証</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;">
      あなたは上記のコースを修了されましたので、<br>ここに証明いたします。
    </p>
    <div class="date">修了日: {{ completion_date }}</div>
    <div class="cert-id">証明書番号: {{ certificate_id }}</div>
  </div>
</body>
</html>"""


def generate_certificate_pdf(
    recipient_name: str,
    course_name: str,
    completion_date: str,
    certificate_id: str,
) -> bytes:
    """証明書PDFを生成してバイナリを返す"""
    html = Template(CERTIFICATE_TEMPLATE).render(
        recipient_name=recipient_name,
        course_name=course_name,
        completion_date=completion_date,
        certificate_id=certificate_id,
    )

    response = requests.post(
        FUNBREW_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


# 使用例
pdf = generate_certificate_pdf(
    recipient_name="田中 太郎",
    course_name="Python実践講座",
    completion_date="2026年4月20日",
    certificate_id="CERT-20260420-A3F7B2C1",
)
with open("certificate.pdf", "wb") as f:
    f.write(pdf)
print(f"生成完了: {len(pdf):,} bytes")

Node.js: 証明書APIクライアント

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="ja">
<head>
  <meta charset="UTF-8">
  <style>
    @page { size: A4 landscape; margin: 0; }
    body { width: 297mm; height: 210mm; font-family: 'Noto Sans JP', sans-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">修了証</div>
    <div class="course">{{courseName}}</div>
    <div class="name">{{recipientName}} 様</div>
    <p style="font-size:13pt; margin-top:6mm; line-height:1.8;">
      あなたは上記のコースを修了されましたので、<br>ここに証明いたします。
    </p>
    <div class="date">修了日: {{completionDate}}</div>
    <div class="cert-id">証明書番号: {{certificateId}}</div>
  </div>
</body>
</html>`);

/**
 * 証明書PDFを生成して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);
}

// 使用例
(async () => {
  const pdf = await generateCertificatePdf({
    recipientName: '田中 太郎',
    courseName: 'Node.js実践講座',
    completionDate: '2026年4月20日',
    certificateId: 'CERT-20260420-B4G8C3D2',
  });
  require('fs').writeFileSync('certificate.pdf', pdf);
  console.log(`生成完了: ${pdf.length.toLocaleString()} bytes`);
})();

LMS・HRシステムとの連携

Moodle連携(Webhookトリガー)

Moodleのコース修了イベントを受け取り、自動的に証明書を発行します。

# Flask APIエンドポイント(Webhookハンドラ)
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(request) -> bool:
    """Webhookリクエストの署名を検証する"""
    signature = request.headers.get("X-Moodle-Signature", "")
    payload = request.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():
    """Moodleのコース修了Webhookを受け取り証明書を発行する"""
    if not verify_webhook(request):
        return jsonify({"error": "Invalid signature"}), 401

    data = request.json
    event = data.get("event", "")

    if event != "course_completed":
        return jsonify({"status": "ignored"}), 200

    # 受講者データの取得
    user = data["user"]
    course = data["course"]
    completion_date = datetime.datetime.fromisoformat(data["completiondate"]).strftime("%Y年%-m月%-d日")

    # 証明書IDの生成(ハッシュベース)
    certificate_id = generate_certificate_id(
        recipient_name=f"{user['lastname']} {user['firstname']}",
        course_name=course["fullname"],
        date=completion_date,
    )

    # PDF生成
    pdf_bytes = generate_certificate_pdf(
        recipient_name=f"{user['lastname']} {user['firstname']}",
        course_name=course["fullname"],
        completion_date=completion_date,
        certificate_id=certificate_id,
    )

    # S3にアーカイブ
    s3_url = save_to_s3(pdf_bytes, certificate_id)

    # メール送信
    send_certificate_email(
        recipient_email=user["email"],
        recipient_name=f"{user['lastname']} {user['firstname']}",
        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:
    """決定論的な証明書IDを生成する"""
    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}"

汎用Webhookレシーバー(Node.js)

// 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' });
  }

  // 即座に200を返してから非同期で処理(タイムアウト防止)
  res.status(200).json({ status: 'accepted' });

  try {
    const certificateId = generateCertificateId(data);
    const pdfBuffer = await generateCertificatePdf({
      recipientName: `${data.user.lastName} ${data.user.firstName}`,
      courseName: data.course.name,
      completionDate: new Date(data.completionDate).toLocaleDateString('ja-JP'),
      certificateId,
    });

    await saveToS3(pdfBuffer, certificateId);
    await sendCertificateEmail({
      email: data.user.email,
      name: `${data.user.lastName} ${data.user.firstName}`,
      pdfBuffer,
      certificateId,
    });
  } catch (error) {
    console.error('Certificate generation failed:', error);
    // アラート送信(Slackやメール)
  }
});

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;

エラーハンドリングとリトライ

証明書APIの呼び出しは必ず失敗することがあります。適切なリトライ戦略を実装してください。

Pythonのリトライ実装

import time
import requests

def generate_certificate_with_retry(
    html: str,
    options: dict,
    max_retries: int = 3,
    base_delay: float = 1.0,
) -> bytes:
    """指数バックオフ付きリトライで証明書PDFを生成する"""
    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 エラーはリトライ不要(入力データの問題)
            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} 失敗({e})。{delay}秒後にリトライ...")
            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} 接続エラー({e})。{delay}秒後にリトライ...")
            time.sleep(delay)

エラー別の対処方針

HTTPステータス 原因 対処
400 Bad Request HTMLまたはオプションが不正 入力データを修正(リトライ不要)
401 Unauthorized APIキーが無効または未設定 環境変数を確認(リトライ不要)
429 Too Many Requests レート制限超過 指数バックオフでリトライ
500 Internal Server Error サーバー側エラー 指数バックオフでリトライ
503 Service Unavailable メンテナンス中 時間をおいてリトライ
タイムアウト 生成に時間がかかっている タイムアウト値を延ばしてリトライ

証明書データの管理

データベーススキーマ例

-- 証明書の発行記録テーブル
CREATE TABLE certificate_records (
    id               BIGSERIAL PRIMARY KEY,
    certificate_id   VARCHAR(64)  NOT NULL UNIQUE, -- CERT-YYYYMMDD-XXXXXXXX
    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 = 有効期限なし
    s3_url           TEXT,                         -- アーカイブURL
    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);

証明書の検証エンドポイント

受信者が証明書の真正性を確認できる公開エンドポイントを実装します。

# Flask: 証明書検証エンドポイント
@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": "この証明書は無効または存在しません。",
        }), 404

    # 有効期限チェック
    if record["expires_at"] and record["expires_at"] < datetime.datetime.now(datetime.timezone.utc):
        return jsonify({
            "valid": False,
            "message": "この証明書は有効期限が切れています。",
        }), 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,
    })

セキュリティのベストプラクティス

証明書APIを本番環境で運用する際は、以下のセキュリティ原則を必ず守ってください。

1. APIキーのローテーション

# 定期的にAPIキーをローテーションする(推奨: 90日ごと)
# 1. 新しいキーを発行
# 2. 新しいキーをシークレット管理サービスにセット
# 3. デプロイして切り替え確認
# 4. 旧キーを無効化

# AWS Secrets Managerを使う場合(推奨)
aws secretsmanager put-secret-value \
  --secret-id prod/funbrew-pdf-api-key \
  --secret-string '{"api_key":"sk-prod-new-key-xxxx"}'

2. 最小権限の原則

証明書生成専用のAPIキーを発行し、他の用途のキーと分離してください。本番・ステージング・開発環境でそれぞれ別のキーを使用します。

3. レート制限の実装

APIキーが漏洩してもリスクを最小化するため、アプリケーション側でもレート制限を実装してください。

# Redis を使ったシンプルなレート制限
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):
    """IPまたはユーザーIDに基づくレート制限デコレータ"""
    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"レート制限超過: {window_seconds}秒間に{max_requests}件まで")
            return func(*args, user_id=user_id, **kwargs)
        return wrapper
    return decorator

# 1ユーザーあたり1時間に10件まで
@rate_limit("cert_generation", max_requests=10, window_seconds=3600)
def issue_certificate(user_id: str, data: dict) -> bytes:
    return generate_certificate_pdf(**data)

関連ガイド

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

シリーズの他のガイド:

まとめ

証明書APIとシステムを連携させるポイントをまとめます。

  1. アーキテクチャを選ぶ: 同期型(少量・即時)、非同期型(大量・バッチ)、イベント駆動型(LMS連携)から要件に合わせて選択する
  2. テンプレートを分離する: HTMLテンプレートとデータを分離し、変数置換だけで全証明書に対応できる設計にする
  3. 証明書IDをハッシュで生成する: 受信者情報+秘密ソルトのSHA-256で偽造不能なIDを採番する
  4. リトライを実装する: 5xx系エラーは指数バックオフでリトライし、4xx系は即座に失敗として記録する
  5. 検証エンドポイントを公開する: QRコードと組み合わせて、受信者が証明書の真正性をオンラインで確認できるようにする
  6. セキュリティを多層化する: APIキーは環境変数で管理し、Webhook署名検証とアプリ側レート制限を実装する

FUNBREW PDFのプレイグラウンドでテンプレートを確認してから実装を進めることを推奨します。APIの詳細仕様はドキュメントを参照してください。

Powered by FUNBREW PDF