2026/04/29

イベント証明書を一括発行:EventbriteCSVからチケット別PDFを自動生成

イベント証明書一括発行EventbriteカンファレンスPDF API

参加者1,000名のカンファレンスが金曜に閉幕。月曜の朝までに、全参加者へ個別の参加証PDFをメール配信し、登壇者にはブランドロゴ入りの登壇証明PDFを別途送る——手作業では到底間に合いません。

このガイドでは、FUNBREW PDF APIを使ったイベント証明書の本番運用パイプラインを解説します。EventbriteやHopinから参加者リストを取得し、チケット種別ごとに証明書テンプレートを切り替え、QRコード検証付きで一括生成する一連のフローを、Node.jsとPythonの実装コードとともに紹介します。

このガイドが扱う範囲は イベント特化のCSV→PDF一括発行パイプライン です。証明書パイプライン全体(テンプレート設計・S3保存・QR検証・メール配信の設計)は証明書PDF自動化ガイドで解説しています。再利用可能なSDKラッパーを構築したい場合は証明書PDF生成SDKガイドを参照してください。幅広いユースケース(コーポレート研修・オンラインコース)でのバルク発行についてはバルク証明書生成ガイドを参照してください。

なぜイベントは専用パイプラインが必要なのか

イベント証明書のワークフローは一般的な認定証発行と次の3点で大きく異なります。

  1. 発行量が極端に偏る — 5,000人規模のカンファレンスは数ヶ月間ゼロ発行が続いた後、わずか4時間で5,000枚を捌く必要がある
  2. チケット種別が多様 — 一般参加・ワークショップ・登壇・VIPなど、同じイベント内でレイアウトが違う証明書を作り分ける必要がある
  3. 配信タイミングがシビア — 参加者はイベント終了後24〜48時間以内の配信を期待し、LinkedInでシェアできる体裁が前提となる

以下のパターンはこれら3つの制約を踏まえて設計されています。

イベント証明書の主な種類

種類 対象 発行タイミング テンプレートの特徴
参加証 チェックイン済みの全参加者 イベント終了 "○○に参加した"
修了証 ワークショップ・ハンズオン参加者 ワークショップ終了 "全課程を修了した"
登壇証明 確定済み登壇者 イベント翌日 "○○で登壇した"
CPD/CPEクレジット 専門職参加者 イベント終了後の検証完了時 クレジット番号と認定機関を含む
ボランティア・スタッフ証明 運営スタッフ イベント終了 "○○として従事した"
スポンサー証明 スポンサー企業 イベント終了 ブランドロゴと協賛ティア

一般的なカンファレンスでは、1つの参加者リストから3〜4種類の証明書を発行します。コツは ticket_type(または attendee_role)→ テンプレートのマッピングを1ヶ所に集約し、生成パイプラインを使い回せるようにすることです。

各イベントプラットフォームからのデータエクスポート

Eventbrite

EventbriteのダッシュボードからCSVをエクスポートすると、Order #First NameLast NameEmailTicket TypeAttendee Status などの列が取得できます。「Attendee Status」列が実参加判定の真値です。

Order #,First Name,Last Name,Email,Ticket Type,Attendee Status,Event Date
EB1234,Sarah,Connor,sarah@example.com,General Admission,Checked In,2026-04-26
EB1235,John,Doe,john@example.com,Workshop Pass,Checked In,2026-04-26
EB1236,Alice,Lee,alice@example.com,Speaker Pass,Attending,2026-04-26

プログラム連携が必要な場合はEventbrite APIの /v3/events/{event_id}/attendees/ エンドポイントから同じデータをJSONで取得できます。

Hopin/Bizzabo

両プラットフォームとも emailticket_typesession_attendance(セッションIDのリスト)、engagement_score を含む参加者エクスポートを提供しています。複数セッション制のカンファレンスでは session_attendance を活用すると、「参加者が実際に参加したセッション一覧」入りの証明書を生成できます。

Peatix/connpass

国内のイベントでよく使われるプラットフォームです。Peatixのデフォルト出力CSVはShift-JISエンコーディングのため、Pythonでは encoding="shift_jis" を指定して読み込みます。Node.jsでは iconv-lite でデコードしてから csv-parse に渡してください。connpassのAPIはUTF-8のJSONを返すため統合が容易です。

チケット種別をテンプレートにマッピング

ルーティングを1つの辞書に集約し、それ以外のパイプラインは汎用に保つのがコツです。

// template-router.js
const fs = require('fs');
const path = require('path');

const TEMPLATE_MAP = {
  'General Admission': 'attendance.html',
  'Workshop Pass': 'completion.html',
  'Speaker Pass': 'speaker.html',
  'VIP': 'attendance-vip.html',
  'Volunteer': 'volunteer.html',
  'Sponsor': 'sponsor.html',
};

const templateCache = {};

function loadTemplate(name) {
  if (!templateCache[name]) {
    templateCache[name] = fs.readFileSync(path.join('templates', name), 'utf-8');
  }
  return templateCache[name];
}

function pickTemplate(attendee) {
  const templateFile = TEMPLATE_MAP[attendee.ticket_type] || 'attendance.html';
  return loadTemplate(templateFile);
}

module.exports = { pickTemplate };

新しいチケット種別が増えたら TEMPLATE_MAP に1行追加するだけ。パイプライン本体には触らずに済みます。

バルク生成パイプライン(Node.js)

npm install csv-parse handlebars axios p-limit qrcode
// event-certificate-generator.js
const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const Handlebars = require('handlebars');
const axios = require('axios');
const pLimit = require('p-limit');
const QRCode = require('qrcode');
const crypto = require('crypto');

const { pickTemplate } = require('./template-router');

const API_KEY = process.env.FUNBREW_API_KEY;
const API_URL = 'https://pdf.funbrew.cloud/api/v1/generate';
const VERIFY_BASE = 'https://example.com/verify';
const HMAC_SECRET = process.env.CERT_HMAC_SECRET;
const CONCURRENCY = 8;

function loadAttendees(csvPath) {
  const content = fs.readFileSync(csvPath, 'utf-8');
  const rows = parse(content, { columns: true, skip_empty_lines: true });
  // Eventbriteの "Checked In" / "Attending" — 実際にチェックインした参加者だけが対象
  return rows.filter((r) => r['Attendee Status'] === 'Checked In');
}

function signCertificateId(attendeeId, eventId) {
  const payload = `${eventId}:${attendeeId}`;
  const sig = crypto.createHmac('sha256', HMAC_SECRET).update(payload).digest('hex').slice(0, 12);
  return `${eventId}-${attendeeId}-${sig}`;
}

async function buildQrDataUrl(certId) {
  const url = `${VERIFY_BASE}/${certId}`;
  return await QRCode.toDataURL(url, { width: 200, margin: 1 });
}

async function generateOne(attendee, eventMeta, limit) {
  return limit(async () => {
    const templateHtml = pickTemplate(attendee);
    const template = Handlebars.compile(templateHtml);
    const certId = signCertificateId(attendee['Order #'], eventMeta.id);
    const qrDataUrl = await buildQrDataUrl(certId);

    const html = template({
      name: `${attendee['First Name']} ${attendee['Last Name']}`,
      ticket_type: attendee['Ticket Type'],
      event_name: eventMeta.name,
      event_date: attendee['Event Date'],
      cert_id: certId,
      qr_data_url: qrDataUrl,
    });

    try {
      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: 60000,
        }
      );
      return {
        certId,
        attendeeId: attendee['Order #'],
        email: attendee['Email'],
        buffer: Buffer.from(response.data),
        status: 'success',
      };
    } catch (err) {
      return {
        certId,
        attendeeId: attendee['Order #'],
        email: attendee['Email'],
        error: err.message,
        status: 'failed',
      };
    }
  });
}

async function generateEventCertificates(csvPath, eventMeta, outputDir = './output') {
  fs.mkdirSync(outputDir, { recursive: true });
  const attendees = loadAttendees(csvPath);
  console.log(`「${eventMeta.name}」向けに${attendees.length}件の証明書を発行します`);

  const limit = pLimit(CONCURRENCY);
  const results = await Promise.all(attendees.map((a) => generateOne(a, eventMeta, limit)));

  const succeeded = results.filter((r) => r.status === 'success');
  const failed = results.filter((r) => r.status === 'failed');

  for (const r of succeeded) {
    fs.writeFileSync(path.join(outputDir, `${r.certId}.pdf`), r.buffer);
  }

  if (failed.length) {
    fs.writeFileSync(
      path.join(outputDir, 'failed.json'),
      JSON.stringify(failed.map(({ buffer, ...rest }) => rest), null, 2)
    );
  }

  console.log(`完了: 成功${succeeded.length}件 / 失敗${failed.length}件`);
  return { succeeded, failed };
}

generateEventCertificates(
  './attendees-eventbrite.csv',
  { id: 'devconf-2026', name: 'DevConf 2026' }
).catch(console.error);

パイプラインを loadAttendeesgenerateOne → オーケストレータの3層に意図的に分けています。EventbriteからHopinに切り替えるときは loadAttendees だけ書き換えれば済みますし、PDF保存からメール配信に変更するときはオーケストレータ末尾だけ差し替えるだけです。

バルク生成パイプライン(Python)

pip install aiohttp jinja2 qrcode pillow
# event_certificate_generator.py
import asyncio
import base64
import csv
import hashlib
import hmac
import io
import json
import os
from pathlib import Path

import aiohttp
import qrcode
from jinja2 import Template

API_KEY = os.environ["FUNBREW_API_KEY"]
API_URL = "https://pdf.funbrew.cloud/api/v1/generate"
VERIFY_BASE = "https://example.com/verify"
HMAC_SECRET = os.environ["CERT_HMAC_SECRET"].encode()
CONCURRENCY = 8

TEMPLATE_MAP = {
    "General Admission": "attendance.html",
    "Workshop Pass": "completion.html",
    "Speaker Pass": "speaker.html",
    "VIP": "attendance-vip.html",
    "Volunteer": "volunteer.html",
    "Sponsor": "sponsor.html",
}
_template_cache: dict[str, Template] = {}


def pick_template(ticket_type: str) -> Template:
    name = TEMPLATE_MAP.get(ticket_type, "attendance.html")
    if name not in _template_cache:
        path = Path("templates") / name
        # PeatixのCSVはShift-JISだが、テンプレートファイル自体はUTF-8で保存する
        _template_cache[name] = Template(path.read_text(encoding="utf-8"))
    return _template_cache[name]


def load_attendees(csv_path: str) -> list[dict]:
    with open(csv_path, encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    return [r for r in rows if r["Attendee Status"] == "Checked In"]


def sign_certificate_id(attendee_id: str, event_id: str) -> str:
    payload = f"{event_id}:{attendee_id}".encode()
    sig = hmac.new(HMAC_SECRET, payload, hashlib.sha256).hexdigest()[:12]
    return f"{event_id}-{attendee_id}-{sig}"


def build_qr_data_url(cert_id: str) -> str:
    url = f"{VERIFY_BASE}/{cert_id}"
    qr = qrcode.QRCode(box_size=4, border=1)
    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}"


async def generate_one(
    session: aiohttp.ClientSession,
    attendee: dict,
    event_meta: dict,
    semaphore: asyncio.Semaphore,
) -> dict:
    async with semaphore:
        template = pick_template(attendee["Ticket Type"])
        cert_id = sign_certificate_id(attendee["Order #"], event_meta["id"])
        qr_data_url = build_qr_data_url(cert_id)

        html = template.render(
            name=f"{attendee['First Name']} {attendee['Last Name']}",
            ticket_type=attendee["Ticket Type"],
            event_name=event_meta["name"],
            event_date=attendee["Event Date"],
            cert_id=cert_id,
            qr_data_url=qr_data_url,
        )

        try:
            async with session.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=aiohttp.ClientTimeout(total=60),
            ) as resp:
                if resp.status == 200:
                    pdf = await resp.read()
                    return {
                        "cert_id": cert_id,
                        "attendee_id": attendee["Order #"],
                        "email": attendee["Email"],
                        "bytes": pdf,
                        "status": "success",
                    }
                error = f"HTTP {resp.status}"
        except Exception as e:
            error = str(e)

        return {
            "cert_id": cert_id,
            "attendee_id": attendee["Order #"],
            "email": attendee["Email"],
            "error": error,
            "status": "failed",
        }


async def generate_event_certificates(
    csv_path: str, event_meta: dict, output_dir: str = "./output"
) -> dict:
    out = Path(output_dir)
    out.mkdir(exist_ok=True)
    attendees = load_attendees(csv_path)
    print(f"「{event_meta['name']}」向けに{len(attendees)}件の証明書を発行します")

    semaphore = asyncio.Semaphore(CONCURRENCY)
    async with aiohttp.ClientSession() as session:
        tasks = [generate_one(session, a, event_meta, semaphore) for a in attendees]
        results = await asyncio.gather(*tasks)

    succeeded = [r for r in results if r["status"] == "success"]
    failed = [r for r in results if r["status"] == "failed"]

    for r in succeeded:
        (out / f"{r['cert_id']}.pdf").write_bytes(r["bytes"])

    if failed:
        (out / "failed.json").write_text(
            json.dumps(
                [{k: v for k, v in r.items() if k != "bytes"} for r in failed],
                indent=2,
                ensure_ascii=False,
            )
        )

    print(f"完了: 成功{len(succeeded)}件 / 失敗{len(failed)}件")
    return {"succeeded": succeeded, "failed": failed}


if __name__ == "__main__":
    asyncio.run(
        generate_event_certificates(
            "attendees-eventbrite.csv",
            {"id": "devconf-2026", "name": "DevConf 2026"},
        )
    )

当日その場での即時発行パターン

カンファレンスやワークショップによっては、参加者がチェックインまたはセッション完了した瞬間に会場の小型プリンターで証明書を印刷したい、というケースがあります。基本パターンは次の通りです。

QR受付スキャン → Webhook → 証明書API → プリンタキュー

PDF生成は1〜2秒で完了するため、参加者が受付を離れる前に証明書を渡せます。

// 受付Webhookハンドラの最小例
const express = require('express');
const { generateOne } = require('./event-certificate-generator');
const { printToKiosk } = require('./kiosk-printer');

const app = express();
app.use(express.json());

app.post('/checkin', async (req, res) => {
  const { attendee, event } = req.body;
  const result = await generateOne(attendee, event, (fn) => fn());
  if (result.status === 'success') {
    await printToKiosk(result.buffer);
    return res.json({ printed: true, certId: result.certId });
  }
  res.status(500).json({ error: result.error });
});

app.listen(3000);

毎分50件以上のチェックインが発生する大規模会場では、HTTPS接続のプール化と起動時のHandlebarsプリコンパイルでスループットを底上げすると安定します。

イベント終了後のメール配信

最も一般的なパターンは「イベント終了後の夜間にバッチ生成 → 翌朝に各参加者へメール添付」です。

# delivery.py
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path

SMTP_HOST = os.environ["SMTP_HOST"]
SMTP_USER = os.environ["SMTP_USER"]
SMTP_PASS = os.environ["SMTP_PASS"]
FROM_ADDR = "certificates@example.com"


def send_certificate_email(recipient: dict, pdf_path: Path, event_name: str) -> None:
    msg = EmailMessage()
    msg["From"] = FROM_ADDR
    msg["To"] = recipient["email"]
    msg["Subject"] = f"【{event_name}】参加証明書のご送付"
    msg.set_content(
        f"{recipient['name']} 様\n\n"
        f"このたびは「{event_name}」にご参加いただきありがとうございました。\n"
        "添付の参加証明書PDFをご確認ください。LinkedInやSNSでぜひご活用ください。\n\n"
        f"検証URL: https://example.com/verify/{recipient['cert_id']}\n"
    )
    msg.add_attachment(
        pdf_path.read_bytes(),
        maintype="application",
        subtype="pdf",
        filename=pdf_path.name,
    )

    with smtplib.SMTP_SSL(SMTP_HOST, 465) as s:
        s.login(SMTP_USER, SMTP_PASS)
        s.send_message(msg)

1,000件を超えるような大量配信では、生のSMTPではなくSendGrid・Postmark・AWS SESなどのトランザクションメール基盤を使うべきです。バウンス・苦情・秒間スロットルを自動でハンドリングしてくれます。

CPD/CPEクレジット番号の管理

医療系(CPD)、会計系(CPE)、PM(PDU)など、専門職向けの証明書では検証可能なクレジット番号が必須です。番号は次の3条件を満たす必要があります。

  • 発行元組織内でシーケンシャルにユニーク
  • 全証明書をまたいでもユニーク
  • 数年後でも監査可能

PostgreSQLのSEQUENCEを使うのが最もシンプルです。

CREATE SEQUENCE cpd_credit_seq START 1000001;

INSERT INTO certificates (event_id, attendee_id, credit_number, issued_at)
VALUES ($1, $2, nextval('cpd_credit_seq'), NOW())
RETURNING credit_number;

テンプレートには CPD-{{credit_number}} として描画します。アトミックな番号採番により、8並列のバッチ処理でも重複は起こりません。

国際カンファレンスの多言語対応

CSVに language 列を追加し、対応するテンプレートに振り分けます。

LOCALIZED_TEMPLATES = {
    ("General Admission", "en"): "attendance-en.html",
    ("General Admission", "ja"): "attendance-ja.html",
    ("General Admission", "fr"): "attendance-fr.html",
    ("Workshop Pass", "en"): "completion-en.html",
    ("Workshop Pass", "ja"): "completion-ja.html",
}

def pick_template(ticket_type: str, language: str) -> Template:
    key = (ticket_type, language)
    name = LOCALIZED_TEMPLATES.get(key) or LOCALIZED_TEMPLATES[(ticket_type, "en")]
    ...

FUNBREW PDF APIにはNoto SansおよびNoto Sans CJKがプリインストールされているため、日本語・中国語・韓国語のテキストもフォント設定なしで正しく描画されます。フォントスタックの詳細はHTML to PDF 日本語フォントガイドを参照してください。

検証エンドポイントの実装

各証明書に埋め込んだQRコードのリンク先で、HMAC署名を検証して証明書の真正性を返します。

// verify.js
const express = require('express');
const crypto = require('crypto');

const HMAC_SECRET = process.env.CERT_HMAC_SECRET;
const app = express();

app.get('/verify/:certId', async (req, res) => {
  const { certId } = req.params;
  const parts = certId.split('-');
  if (parts.length < 3) return res.status(400).send('証明書IDの形式が正しくありません');

  const sig = parts.pop();
  const eventId = parts[0];
  const attendeeId = parts.slice(1).join('-');
  const payload = `${eventId}:${attendeeId}`;
  const expected = crypto.createHmac('sha256', HMAC_SECRET).update(payload).digest('hex').slice(0, 12);

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(404).send('証明書が見つからないか、改ざんされています');
  }

  // 必要に応じてDB参照で発行者名やイベント詳細を表示
  res.send(`証明書 ${certId} は有効です。`);
});

app.listen(4000);

HMACだけでも「自社システムが発行したものか」は証明できます。検証ページに発行者名やイベント情報も表示したい場合は、追加でデータベース参照を組み込んでください。

運用チェックリスト

イベント前:

  • チケットティアごとにどのテンプレートを発行するか確定する
  • 5名分のテストバッチをエンドツーエンドで実行する
  • 送信元ドメインのSPF/DKIM/DMARCがpassすることを確認する
  • APIエンドポイントへのDNS解決をプリウォームする

イベント中:

  • チェックイン状況をモニターし、終了後バッチの規模を見積もる
  • 登壇者・スポンサー名簿を別バッチ用に確保する

イベント後:

  • 24時間以内にバルクバッチを実行する
  • 大量受信者には、PDF添付ではなくトークン付きDLリンクを送る
  • failed.json を長期ストレージに保管し、再発行リクエストに備える

Playgroundで動作確認

バルクパイプラインへの組み込み前に、1名分の証明書テンプレートをFUNBREW PDF Playgroundに貼り付けてA4横でのレイアウトを確認してください。QRコードの配置やフォントフォールバックの検証で、最も高速なフィードバックループになります。

関連リンク

このガイドは証明書シリーズのイベント特化スポークです。テンプレート設計から配信まで全体像を把握したい場合は、シリーズのハブガイドである証明書PDF自動化ガイドから始めてください。

シリーズの他のガイド:

Powered by FUNBREW PDF