NaN/NaN/NaN

PDF生成が完了したことをどうやって検知しますか?ポーリング(定期的にAPIを叩いて確認)は非効率です。Webhookを使えば、PDF生成完了時に指定したURLへ自動で通知が届きます。

この記事では、FUNBREW PDFのWebhook機能を使って、Slack通知、メール通知、社内システム連携を実装する方法を解説します。

Webhookとは

Webhookは「逆方向のAPI呼び出し」です。通常はこちらからAPIを呼びますが、Webhookではイベント発生時にAPI側からこちらのエンドポイントを呼び出します。

通常のAPI:    あなた → PDF API(リクエスト)
Webhook:      PDF API → あなた(イベント通知)

ポーリング vs Webhook

方式 仕組み メリット デメリット
ポーリング 定期的にAPIを叩いて確認 実装が簡単 無駄なリクエスト、遅延あり
Webhook イベント時にAPI側から通知 リアルタイム、効率的 エンドポイントの公開が必要

バッチ処理のように大量のPDFを生成する場合、Webhookの効率性は特に重要です。

Webhookの設定

ダッシュボードで設定

FUNBREW PDFのダッシュボードから「Webhook」セクションで設定します。

  1. URL: 通知を受け取るエンドポイントのURL
  2. イベント: 通知するイベントの種類を選択
  3. シークレットキー: リクエストの検証に使うシークレット

通知されるイベント

イベント 説明
pdf.generated PDF生成完了
pdf.failed PDF生成失敗
pdf.emailed メール送信完了
batch.completed バッチ処理完了

Webhookペイロードの構造

{
  "event": "pdf.generated",
  "timestamp": "2026-03-30T10:00:00Z",
  "data": {
    "id": "pdf_abc123",
    "filename": "invoice-42.pdf",
    "file_size": 48210,
    "download_url": "https://api.pdf.funbrew.cloud/dl/abc123?expires=...",
    "engine": "quality",
    "generation_time_ms": 1250
  }
}

Webhookエンドポイントの実装

Node.js (Express)

const express = require('express');
const crypto = require('crypto');
const app = express();

app.use(express.json());

app.post('/webhooks/funbrew-pdf', (req, res) => {
  // 1. シグネチャの検証
  const signature = req.headers['x-funbrew-signature'];
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // 2. イベントの処理
  const { event, data } = req.body;

  switch (event) {
    case 'pdf.generated':
      console.log(`PDF generated: ${data.filename} (${data.file_size} bytes)`);
      // ここでDBの更新やSlack通知を行う
      break;
    case 'pdf.failed':
      console.error(`PDF failed: ${data.filename} - ${data.error}`);
      break;
    case 'batch.completed':
      console.log(`Batch done: ${data.succeeded}/${data.total} succeeded`);
      break;
  }

  // 3. 200を返す(Webhook受信の確認)
  res.status(200).json({ received: true });
});

app.listen(3000);

Python (FastAPI)

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.post("/webhooks/funbrew-pdf")
async def handle_webhook(request: Request):
    # 1. シグネチャの検証
    body = await request.body()
    signature = request.headers.get("x-funbrew-signature", "")
    expected = hmac.new(
        os.environ["WEBHOOK_SECRET"].encode(),
        body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise HTTPException(status_code=401, detail="Invalid signature")

    # 2. イベントの処理
    payload = await request.json()
    event = payload["event"]
    data = payload["data"]

    if event == "pdf.generated":
        print(f"PDF generated: {data['filename']}")
        # DB更新やSlack通知
    elif event == "pdf.failed":
        print(f"PDF failed: {data['filename']}")

    return {"received": True}

PHP (Laravel)

// routes/api.php
Route::post('/webhooks/funbrew-pdf', [WebhookController::class, 'handle']);

// WebhookController.php
class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        // 1. シグネチャの検証
        $signature = $request->header('X-Funbrew-Signature');
        $expected = hash_hmac('sha256', $request->getContent(), config('services.funbrew.webhook_secret'));

        if (!hash_equals($expected, $signature)) {
            abort(401, 'Invalid signature');
        }

        // 2. イベントの処理
        $event = $request->input('event');
        $data = $request->input('data');

        match ($event) {
            'pdf.generated' => $this->handleGenerated($data),
            'pdf.failed' => $this->handleFailed($data),
            'batch.completed' => $this->handleBatchCompleted($data),
            default => null,
        };

        return response()->json(['received' => true]);
    }

    private function handleGenerated(array $data): void
    {
        Log::info("PDF generated: {$data['filename']}");
        // DB更新やSlack通知
    }
}

Slack通知の実装

WebhookイベントをSlackに転送する例です。Webhookによるイベント受信だけでなく、Slack BotのスラッシュコマンドでオンデマンドにPDFを生成したい場合は、Slack Bot実装ガイドを参照してください。

async function notifySlack(event, data) {
  const messages = {
    'pdf.generated': `✅ PDF生成完了: ${data.filename} (${(data.file_size / 1024).toFixed(1)}KB)`,
    'pdf.failed': `❌ PDF生成失敗: ${data.filename} - ${data.error}`,
    'batch.completed': `📦 バッチ完了: ${data.succeeded}/${data.total}件成功`,
  };

  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: messages[event] || `PDF event: ${event}`,
      channel: '#pdf-notifications',
    }),
  });
}

// Webhookハンドラ内で呼び出し
app.post('/webhooks/funbrew-pdf', async (req, res) => {
  // シグネチャ検証後...
  await notifySlack(req.body.event, req.body.data);
  res.status(200).json({ received: true });
});

メール通知の実装

Webhookイベントをメール通知に変換すれば、Slackを使っていないメンバーにもPDF生成状況を共有できます。

Nodemailerでの実装

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: 'smtp.example.com',
  port: 587,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

async function sendEmailNotification(event, data) {
  const templates = {
    'pdf.generated': {
      subject: `PDF生成完了: ${data.filename}`,
      html: `
        <h2>PDF生成が完了しました</h2>
        <p><strong>ファイル名:</strong> ${data.filename}</p>
        <p><strong>サイズ:</strong> ${(data.file_size / 1024).toFixed(1)} KB</p>
        <p><strong>生成時間:</strong> ${data.generation_time_ms} ms</p>
        <p><a href="${data.download_url}">PDFをダウンロード</a></p>
      `,
    },
    'pdf.failed': {
      subject: `[要対応] PDF生成失敗: ${data.filename}`,
      html: `
        <h2>PDF生成に失敗しました</h2>
        <p><strong>ファイル名:</strong> ${data.filename}</p>
        <p><strong>エラー:</strong> ${data.error}</p>
        <p>ダッシュボードでエラー詳細を確認してください。</p>
      `,
    },
    'batch.completed': {
      subject: `バッチ処理完了: ${data.succeeded}/${data.total}件成功`,
      html: `
        <h2>バッチ処理が完了しました</h2>
        <p><strong>成功:</strong> ${data.succeeded}件</p>
        <p><strong>失敗:</strong> ${data.total - data.succeeded}件</p>
      `,
    },
  };

  const template = templates[event];
  if (!template) return;

  await transporter.sendMail({
    from: '"PDF通知" <noreply@example.com>',
    to: process.env.NOTIFICATION_EMAIL,
    ...template,
  });
}

SendGridでの実装

大量の通知メールを送る場合は、SendGridのようなメール配信サービスが適しています。

const sgMail = require('@sendgrid/mail');
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

async function sendViaSendGrid(event, data) {
  // 動的テンプレートを使えばデザイン変更もノーコードで対応可能
  await sgMail.send({
    to: process.env.NOTIFICATION_EMAIL,
    from: 'pdf-notify@example.com',
    templateId: 'd-xxxxxxxxxxxxx',  // SendGridのテンプレートID
    dynamicTemplateData: {
      event_type: event,
      filename: data.filename,
      file_size_kb: (data.file_size / 1024).toFixed(1),
      download_url: data.download_url,
      error: data.error || null,
      generated_at: new Date().toLocaleString('ja-JP'),
    },
  });
}

Next.jsやNuxt.jsでフロントエンドを構築している場合、ダッシュボード画面にリアルタイムで通知バッジを表示する連携も可能です。

社内システム連携の実践例

Webhookの真価は、社内システムと連携して業務フローを自動化できる点にあります。ここでは、DB更新からダッシュボード反映までの一連のフローを実装します。

データベーススキーマ

まず、PDF生成ジョブを管理するテーブルを用意します。

CREATE TABLE pdf_jobs (
  id VARCHAR(255) PRIMARY KEY,
  filename VARCHAR(255) NOT NULL,
  status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
  file_size INT,
  download_url TEXT,
  error_message TEXT,
  requested_by INT REFERENCES users(id),
  requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  completed_at TIMESTAMP NULL,
  retry_count INT DEFAULT 0
);

CREATE INDEX idx_pdf_jobs_status ON pdf_jobs(status);
CREATE INDEX idx_pdf_jobs_requested_by ON pdf_jobs(requested_by);

Webhookハンドラでのステータス管理

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function handleWebhookEvent(event, data) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    switch (event) {
      case 'pdf.generated':
        await client.query(
          `UPDATE pdf_jobs
           SET status = 'completed',
               file_size = $1,
               download_url = $2,
               completed_at = NOW()
           WHERE id = $3`,
          [data.file_size, data.download_url, data.id]
        );

        // 関連する業務フローをトリガー
        await triggerDownstreamWorkflow(client, data);
        break;

      case 'pdf.failed':
        const job = await client.query(
          'SELECT retry_count FROM pdf_jobs WHERE id = $1',
          [data.id]
        );

        if (job.rows[0]?.retry_count < 3) {
          // リトライ可能なら再試行をスケジュール
          await client.query(
            `UPDATE pdf_jobs
             SET status = 'pending',
                 retry_count = retry_count + 1,
                 error_message = $1
             WHERE id = $2`,
            [data.error, data.id]
          );
        } else {
          // リトライ上限に達したら失敗確定
          await client.query(
            `UPDATE pdf_jobs
             SET status = 'failed',
                 error_message = $1,
                 completed_at = NOW()
             WHERE id = $2`,
            [data.error, data.id]
          );
        }
        break;
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

async function triggerDownstreamWorkflow(client, data) {
  // 例: 請求書PDFなら会計システムへ連携
  const job = await client.query(
    'SELECT requested_by FROM pdf_jobs WHERE id = $1',
    [data.id]
  );
  // WebSocketやServer-Sent Eventsでフロントに通知
  broadcastToUser(job.rows[0].requested_by, {
    type: 'pdf_ready',
    filename: data.filename,
    download_url: data.download_url,
  });
}

請求書PDF自動生成と組み合わせれば、請求書の生成完了を起点に会計システムへの自動連携が実現できます。

ダッシュボードへのリアルタイム反映

Server-Sent Events (SSE) を使えば、ユーザーのダッシュボードにリアルタイムでステータス変更を反映できます。

// SSEエンドポイント
app.get('/api/pdf-jobs/stream', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  const userId = req.user.id;
  const listener = (data) => {
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  // ユーザーごとのイベントリスナーを登録
  eventEmitter.on(`pdf-update:${userId}`, listener);

  req.on('close', () => {
    eventEmitter.off(`pdf-update:${userId}`, listener);
  });
});

AWS SNS/SQSとの連携

大規模な本番環境では、Webhookを直接処理するのではなく、メッセージキューを挟む構成が推奨されます。これにより、処理の信頼性とスケーラビリティが大幅に向上します。

アーキテクチャ

FUNBREW PDF → Webhook → 受信サーバー → SNS → SQS → ワーカー群
                                         ↓
                                     SQS (DLQ)

Webhook → SNSへの転送

const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns');

const sns = new SNSClient({ region: 'ap-northeast-1' });
const TOPIC_ARN = process.env.SNS_TOPIC_ARN;

app.post('/webhooks/funbrew-pdf', async (req, res) => {
  // シグネチャ検証後...

  try {
    await sns.send(new PublishCommand({
      TopicArn: TOPIC_ARN,
      Message: JSON.stringify(req.body),
      MessageAttributes: {
        event_type: {
          DataType: 'String',
          StringValue: req.body.event,
        },
      },
    }));

    // 即座に200を返す(処理はSQS側で非同期に行う)
    res.status(200).json({ received: true });
  } catch (err) {
    console.error('SNS publish failed:', err);
    // SNSへの送信が失敗しても200を返す
    // ローカルキューにフォールバック
    await localQueue.add(req.body);
    res.status(200).json({ received: true, queued: true });
  }
});

SQSワーカーの実装

const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require('@aws-sdk/client-sqs');

const sqs = new SQSClient({ region: 'ap-northeast-1' });
const QUEUE_URL = process.env.SQS_QUEUE_URL;

async function pollMessages() {
  while (true) {
    const response = await sqs.send(new ReceiveMessageCommand({
      QueueUrl: QUEUE_URL,
      MaxNumberOfMessages: 10,
      WaitTimeSeconds: 20,  // ロングポーリング
    }));

    for (const message of response.Messages || []) {
      try {
        const webhook = JSON.parse(message.Body);
        const payload = JSON.parse(webhook.Message);

        await processWebhookEvent(payload.event, payload.data);

        // 処理成功したらメッセージを削除
        await sqs.send(new DeleteMessageCommand({
          QueueUrl: QUEUE_URL,
          ReceiptHandle: message.ReceiptHandle,
        }));
      } catch (err) {
        console.error('Message processing failed:', err);
        // 削除しないことで自動リトライされる
      }
    }
  }
}

デッドレターキュー (DLQ) の設定

処理に繰り返し失敗したメッセージは、デッドレターキュー (DLQ) に退避します。

{
  "QueueName": "funbrew-pdf-webhook-dlq",
  "RedrivePolicy": {
    "deadLetterTargetArn": "arn:aws:sqs:ap-northeast-1:xxx:funbrew-pdf-webhook-dlq",
    "maxReceiveCount": 3
  }
}

サーバーレス環境で運用する場合は、Lambda関数をSQSトリガーに設定することで、インフラ管理なしにスケーラブルなWebhook処理を実現できます。

エラー通知のエスカレーション

PDF生成失敗が連続した場合、単発のエラー通知だけでは見落とされがちです。失敗回数に応じてアラートレベルを切り替えるエスカレーション機能を実装しましょう。

エスカレーションレベルの定義

const ESCALATION_LEVELS = {
  // レベル1: 通常のエラー通知(Slackのみ)
  normal: {
    threshold: 1,
    channels: ['slack'],
    priority: 'low',
  },
  // レベル2: 繰り返し失敗(Slack + メール)
  warning: {
    threshold: 3,
    channels: ['slack', 'email'],
    priority: 'medium',
  },
  // レベル3: 重大障害(Slack + メール + PagerDuty)
  critical: {
    threshold: 10,
    channels: ['slack', 'email', 'pagerduty'],
    priority: 'high',
  },
};

失敗カウンターの実装

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function trackFailure(data) {
  const key = 'pdf:failure_count';
  const windowKey = 'pdf:failure_window';

  // 1時間のスライディングウィンドウで失敗回数をカウント
  const now = Date.now();
  await redis.zadd(windowKey, now, `${data.id}:${now}`);
  await redis.zremrangebyscore(windowKey, 0, now - 3600000); // 1時間前より古いものを削除

  const failureCount = await redis.zcard(windowKey);

  // エスカレーションレベルを判定
  let level = ESCALATION_LEVELS.normal;
  if (failureCount >= ESCALATION_LEVELS.critical.threshold) {
    level = ESCALATION_LEVELS.critical;
  } else if (failureCount >= ESCALATION_LEVELS.warning.threshold) {
    level = ESCALATION_LEVELS.warning;
  }

  // 各チャネルに通知
  for (const channel of level.channels) {
    await sendAlert(channel, {
      message: `PDF生成失敗 (直近1時間で${failureCount}件)`,
      filename: data.filename,
      error: data.error,
      priority: level.priority,
      failure_count: failureCount,
    });
  }
}

async function sendAlert(channel, alert) {
  switch (channel) {
    case 'slack':
      const emoji = alert.priority === 'high' ? '🚨' : alert.priority === 'medium' ? '⚠️' : '❌';
      await notifySlack(`${emoji} ${alert.message}: ${alert.filename}`);
      break;
    case 'email':
      await sendEmailNotification('pdf.failed', alert);
      break;
    case 'pagerduty':
      await triggerPagerDuty(alert);
      break;
  }
}

async function triggerPagerDuty(alert) {
  await fetch('https://events.pagerduty.com/v2/enqueue', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      routing_key: process.env.PAGERDUTY_ROUTING_KEY,
      event_action: 'trigger',
      payload: {
        summary: alert.message,
        severity: alert.priority === 'high' ? 'critical' : 'warning',
        source: 'funbrew-pdf-webhook',
      },
    }),
  });
}

Webhookのモニタリング

Webhookが確実に受信・処理されているかを監視することは、本番運用において欠かせません。

受信ログの記録

すべてのWebhookリクエストをログに記録し、後から調査できるようにします。

async function logWebhookRequest(req, processingResult) {
  const logEntry = {
    received_at: new Date().toISOString(),
    event: req.body.event,
    event_id: req.body.data?.id,
    source_ip: req.ip,
    headers: {
      'x-funbrew-signature': req.headers['x-funbrew-signature'] ? '***' : 'missing',
      'content-type': req.headers['content-type'],
    },
    response_time_ms: processingResult.duration,
    status: processingResult.success ? 'success' : 'failed',
    error: processingResult.error || null,
  };

  // 構造化ログとして出力(CloudWatch、Datadog等で検索可能)
  console.log(JSON.stringify({ type: 'webhook_log', ...logEntry }));

  // DBにも保存(モニタリングダッシュボード用)
  await pool.query(
    `INSERT INTO webhook_logs (event, event_id, response_time_ms, status, error, received_at)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [logEntry.event, logEntry.event_id, logEntry.response_time_ms, logEntry.status, logEntry.error, logEntry.received_at]
  );
}

メトリクスの収集

Prometheus形式でメトリクスを公開し、Grafanaでダッシュボードを構築する例です。

const promClient = require('prom-client');

// Webhook受信カウンター
const webhookCounter = new promClient.Counter({
  name: 'funbrew_webhook_received_total',
  help: 'Total number of webhook events received',
  labelNames: ['event', 'status'],
});

// 応答時間のヒストグラム
const webhookDuration = new promClient.Histogram({
  name: 'funbrew_webhook_processing_seconds',
  help: 'Webhook processing duration in seconds',
  labelNames: ['event'],
  buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});

// 失敗率のゲージ
const failureRate = new promClient.Gauge({
  name: 'funbrew_webhook_failure_rate',
  help: 'Webhook processing failure rate (last 1 hour)',
});

// Webhookハンドラにメトリクスを組み込む
app.post('/webhooks/funbrew-pdf', async (req, res) => {
  const timer = webhookDuration.startTimer({ event: req.body.event });

  try {
    // シグネチャ検証・イベント処理...
    await processEvent(req.body);

    webhookCounter.inc({ event: req.body.event, status: 'success' });
    res.status(200).json({ received: true });
  } catch (err) {
    webhookCounter.inc({ event: req.body.event, status: 'error' });
    res.status(500).json({ error: 'Processing failed' });
  } finally {
    timer();
  }
});

// メトリクスエンドポイント
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', promClient.register.contentType);
  res.end(await promClient.register.metrics());
});

アラートルールの例(Prometheus)

groups:
  - name: webhook-alerts
    rules:
      - alert: WebhookHighFailureRate
        expr: rate(funbrew_webhook_received_total{status="error"}[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Webhook failure rate is above 10%"

      - alert: WebhookProcessingSlow
        expr: histogram_quantile(0.95, rate(funbrew_webhook_processing_seconds_bucket[5m])) > 5
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Webhook processing p95 latency exceeds 5 seconds"

      - alert: WebhookNoEventsReceived
        expr: absent(increase(funbrew_webhook_received_total[30m]))
        for: 30m
        labels:
          severity: critical
        annotations:
          summary: "No webhook events received in 30 minutes"

セキュリティ対策

Webhookエンドポイントはインターネットに公開されるため、セキュリティ対策が必須です。

必ず実装すべき対策

  1. シグネチャ検証: HMAC-SHA256でリクエストの正当性を検証(上記コード例を参照)
  2. HTTPS: Webhookエンドポイントは必ずHTTPSで公開
  3. タイムスタンプ検証: 古いリクエストの再送(リプレイ攻撃)を防ぐ
// タイムスタンプが5分以内か確認
const timestamp = new Date(req.body.timestamp);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;

if (Math.abs(now - timestamp) > fiveMinutes) {
  return res.status(401).json({ error: 'Request too old' });
}

冪等性の確保

Webhookは再送されることがあります。同じイベントを2回処理しないよう、イベントIDでの重複チェックを実装しましょう。

const processedEvents = new Set();

app.post('/webhooks/funbrew-pdf', (req, res) => {
  const eventId = req.body.data.id;
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ received: true, duplicate: true });
  }
  processedEvents.add(eventId);
  // イベント処理...
});

注意: 上記の Set はメモリ上にしか存在しないため、サーバー再起動で失われます。本番環境ではRedisやデータベースを使いましょう(後述の「本番環境のベストプラクティス」を参照)。

本番環境のベストプラクティス

開発環境では問題なく動いたWebhookも、本番環境では考慮すべきポイントが増えます。

タイムアウト設定

Webhookの受信側は5秒以内に200レスポンスを返すのが鉄則です。重い処理はキューに積んで非同期で実行しましょう。

app.post('/webhooks/funbrew-pdf', async (req, res) => {
  // シグネチャ検証
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // すぐに200を返す
  res.status(200).json({ received: true });

  // 重い処理はバックグラウンドで(Expressのレスポンス送信後に実行)
  setImmediate(async () => {
    try {
      await processEvent(req.body);
    } catch (err) {
      console.error('Background processing failed:', err);
    }
  });
});

キュー処理の活用

BullMQを使ったジョブキューの実装例です。

const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis(process.env.REDIS_URL);
const webhookQueue = new Queue('webhook-processing', { connection });

// Webhookハンドラ: キューに追加して即座にレスポンス
app.post('/webhooks/funbrew-pdf', async (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  await webhookQueue.add(req.body.event, req.body, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
    removeOnComplete: 1000,
    removeOnFail: 5000,
  });

  res.status(200).json({ received: true });
});

// ワーカー: キューからジョブを取り出して処理
const worker = new Worker('webhook-processing', async (job) => {
  const { event, data } = job.data;

  switch (event) {
    case 'pdf.generated':
      await handleGenerated(data);
      break;
    case 'pdf.failed':
      await trackFailure(data);
      break;
    case 'batch.completed':
      await handleBatchCompleted(data);
      break;
  }
}, {
  connection,
  concurrency: 5,  // 同時処理数
});

worker.on('failed', (job, err) => {
  console.error(`Job ${job.id} failed:`, err);
});

本番向け冪等性の実装

メモリ上の Set ではなく、Redisを使った冪等性チェックを実装します。TTL(有効期限)を設けることで、メモリを際限なく消費する問題も回避できます。

async function isProcessed(eventId) {
  const key = `webhook:processed:${eventId}`;
  // NX: キーが存在しない場合のみセット
  // EX: 24時間で自動削除
  const result = await redis.set(key, '1', 'NX', 'EX', 86400);
  return result === null; // nullなら既に処理済み
}

app.post('/webhooks/funbrew-pdf', async (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const eventId = req.body.data.id;
  if (await isProcessed(eventId)) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  await webhookQueue.add(req.body.event, req.body);
  res.status(200).json({ received: true });
});

ヘルスチェックエンドポイント

Webhook受信サーバーの死活監視に使えるヘルスチェックです。

app.get('/health', async (req, res) => {
  const checks = {
    redis: false,
    database: false,
    queue: false,
  };

  try {
    await redis.ping();
    checks.redis = true;
  } catch (e) {}

  try {
    await pool.query('SELECT 1');
    checks.database = true;
  } catch (e) {}

  try {
    const counts = await webhookQueue.getJobCounts();
    checks.queue = true;
    checks.queueSize = counts;
  } catch (e) {}

  const healthy = Object.values(checks).every(v => v !== false);
  res.status(healthy ? 200 : 503).json({ status: healthy ? 'ok' : 'degraded', checks });
});

料金プランの比較で、本番向けのWebhook配信回数やリトライ回数の上限を確認してください。

ローカル開発でのテスト

ローカル環境ではWebhookを受信できません。以下のツールを使ってテストします。

# ngrokでローカルサーバーを公開
ngrok http 3000
# → https://abc123.ngrok.io が発行される
# この URL をダッシュボードのWebhook設定に登録

PlaygroundからテストPDFを生成すると、ローカルのWebhookエンドポイントにイベントが届きます。

まとめ

Webhook連携で実現できること:

  • リアルタイム通知: PDF生成完了を即座に検知
  • Slack・メール連携: チームへの自動通知(エスカレーション付き)
  • 社内システム連携: DB更新、ステータス管理、ダッシュボード反映
  • メッセージキュー: AWS SNS/SQSで大規模処理を信頼性高く実行
  • モニタリング: 受信ログ、応答時間、失敗率のメトリクス収集
  • 本番運用: タイムアウト管理、キュー処理、冪等性の確保

セキュリティ面では、シグネチャ検証・HTTPS・タイムスタンプ検証を必ず実装してください。本番環境では、Webhookハンドラを軽量に保ち、重い処理はキューに任せるのが鉄則です。

まずは無料プランでWebhookの動作を確認し、APIドキュメントでイベントの詳細仕様を確認してください。Markdownからの変換レポート生成と組み合わせれば、PDF生成から通知までの完全自動化が実現します。

関連リンク

Powered by FUNBREW PDF