メインコンテンツまでスキップ

Webhook通知

PDF生成完了時に自動的にWebhook通知を受け取ることができます。これにより、ポーリング不要でリアルタイムに完了を検知し、メール送信などのワークフローを自動化できます。

概要

Webhook通知は、以下のすべてのPDF生成エンドポイントで自動的に送信されます:

  • POST /file/sync/single - 同期単一ファイル生成
  • POST /file/sync/multiple - 同期複数ファイル生成
  • POST /file/async/single - 非同期単一ファイル生成
  • POST /file/async/multiple - 非同期複数ファイル生成

Webhook URLの設定

Webhook URLを設定するには、ReportFlowのワークスペース設定画面から行います:

  1. ワークスペース設定を開く
  2. 「開発者」タブに移動
  3. 「Webhook URL」フィールドに、通知を受け取るHTTPS URLを入力
  4. 「更新」をクリック

Webhook通知ペイロード

PDF生成が完了すると、設定されたWebhook URLに以下のペイロードがPOSTされます。

ペイロード形式

{
"event": "file.completed",
"timestamp": "2026-02-15T10:30:45.123Z",
"workspaceId": "ws_abc123",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"designId": "design_123",
"version": 5,
"files": [
{
"fileId": "7f3d1a2b-4c5e-6f7a-8b9c-0d1e2f3a4b5c",
"fileName": "請求書.pdf",
"passthrough": { "pageId": "abc123" },
"share": {
"shareType": "workspace",
"url": "https://app.re-port-flow.com/file/{requestId}/{fileId}",
"passcodeEnabled": false
}
}
]
}

フィールド説明

フィールド説明
eventstring固定値: "file.completed"
timestampstringイベント発生時刻 (ISO 8601形式)
workspaceIdstringワークスペースID(参照用)
requestIdstringリクエストID(ダウンロードエンドポイントで使用)
designIdstringデザインID
versionnumberバージョン番号
filesarray生成ファイル情報の配列
files[].fileIdstringファイルID(個別ダウンロードエンドポイントで使用)
files[].fileNamestringファイル名(拡張子付き)
files[].passthroughobjectリクエスト時に指定した passthrough の値(指定時のみ)
files[].share.shareTypestring共有タイプ(workspace / invited / public
files[].share.urlstringファイル表示URL
files[].share.passcodeEnabledbooleanパスコード有効フラグ
files[].share.passcodestringサーバー生成パスコード(passcodeEnabled=true かつ生成直後のみ)
passthrough の使い所

ReportFlow はセキュリティとペイロードサイズの観点から、レスポンスや Webhook 通知に params(生成データ)を返しません。

受信側で「どの業務レコードに対する PDF か」を識別したい場合は、 リクエスト時に passthrough に自社DBのIDなどを入れてください。 レスポンスや Webhook でそのまま返却されます。

{
"fileName": "invoice.pdf",
"passthrough": { "invoiceId": "INV-001", "tenantId": "acme" },
"params": { "customerName": "山田太郎", "amount": 10000 }
}

Webhook 受信時に passthrough の値がそのまま返るため、invoiceId から DB を引いて該当レコードを更新する、といった処理が組めます。 params(顧客名や金額)は外部に送信されません。

PDFのダウンロード

Webhook通知のペイロードから、以下の情報を使ってPDFをダウンロードできます:

  • requestId: ペイロードの requestId(ZIP一括ダウンロード用)
  • fileId: ペイロードの files[].fileId(個別ダウンロード用)
# ZIP一括ダウンロード
GET /v1/file/download/{requestId}

# 個別ファイルダウンロード
GET /v1/file/download/{requestId}/{fileId}

詳細はファイルダウンロードを参照してください。

実装例

Node.js (Express)

import express from 'express';
import axios from 'axios';
const app = express();

app.use(express.json());

app.post('/webhooks/pdf-completed', async (req, res) => {
const payload = req.body;

// イベント検証
if (payload.event !== 'file.completed') {
return res.status(400).json({ error: 'Unknown event' });
}

console.log(`PDF生成完了: ${payload.files.length}`);

// 各ファイルをダウンロード
for (const file of payload.files) {
const downloadUrl = `https://api.re-port-flow.com/v1/file/download/${payload.requestId}/${file.fileId}`;

try {
// PDFダウンロード
const pdfResponse = await axios.get(downloadUrl, {
headers: {
'appkey': process.env.APP_KEY
},
responseType: 'arraybuffer'
});

const pdfBuffer = Buffer.from(pdfResponse.data);

// 送信先などの業務メタデータは、リクエスト時に content.passthrough に
// 入れて Webhook で受け取る。Webhook ペイロードに params (顧客名や金額)
// は返らないため、params から引くサンプルは動かない。
// 例: リクエスト content.passthrough = { recipientEmail, invoiceId }
await sendEmailWithAttachment({
to: file.passthrough?.recipientEmail,
subject: `PDFファイル: ${file.fileName}`,
attachments: [{
filename: file.fileName,
content: pdfBuffer
}]
});

console.log(`メール送信完了: ${file.fileName}`);
} catch (error) {
console.error(`ファイルダウンロードエラー: ${file.fileName}`, error.message);
}
}

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

app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});

Python (Flask)

from flask import Flask, request, jsonify
import requests
import os

app = Flask(__name__)

@app.route('/webhooks/pdf-completed', methods=['POST'])
def webhook_handler():
payload = request.json

# イベント検証
if payload.get('event') != 'file.completed':
return jsonify({'error': 'Unknown event'}), 400

print(f"PDF生成完了: {len(payload['files'])}件")

# 各ファイルをダウンロード
request_id = payload['requestId']

for file_info in payload['files']:
download_url = f"https://api.re-port-flow.com/v1/file/download/{request_id}/{file_info['fileId']}"

try:
# PDFダウンロード
pdf_response = requests.get(
download_url,
headers={
'appkey': os.getenv('APP_KEY')
}
)
pdf_response.raise_for_status()

# ファイルに保存
with open(file_info['fileName'], 'wb') as f:
f.write(pdf_response.content)

print(f"ダウンロード完了: {file_info['fileName']}")

except Exception as e:
print(f"エラー: {file_info['fileName']}, {str(e)}")

# 200 OKを返す
return jsonify({'received': True}), 200

if __name__ == '__main__':
app.run(port=3000)

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

1. HTTPS URLを使用

Webhook URLは必ずHTTPSを使用してください。HTTP URLは拒否されます。

2. 署名検証の実装(HMAC-SHA256)

ReportFlow は Webhook ペイロードに HMAC-SHA256 署名を付与します。受信側で署名を検証することで、第三者によるなりすましリクエストを拒否できます。

署名鍵(Webhook Secret)の取得

ワークスペースごとに発行される whsec_ プレフィックス付きの秘密鍵を使用します。

  • UI から取得: ワークスペース設定 > 開発者タブ
  • API から再生成: POST /workspace/:workspaceId/webhook/secret/regenerate
  • API から取得: GET /workspace/:workspaceId/webhook/secret

再生成すると既存のシークレットは即座に無効化されます。

署名ヘッダの形式

X-Report-Flow-Signature: t=1739610645,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t: Unix タイムスタンプ(秒)。リプレイ攻撃対策として、現在時刻との差が 5 分以内であることを推奨。
  • v1: HMAC-SHA256 署名。署名対象は <t>.<rawBody>(タイムスタンプとリクエストボディを . で連結)。

重要: 署名対象は 生のリクエストボディ(パース前) です。JSON パース後にシリアライズし直すとキー順序や空白が変わって署名が一致しなくなります。

検証サンプル(Node.js)

import crypto from 'crypto';
import express from 'express';

const app = express();
app.use(express.raw({ type: 'application/json' })); // 生のボディを保持

app.post('/webhook', (req, res) => {
const sigHeader = req.header('X-Report-Flow-Signature') || '';
const parts = Object.fromEntries(
sigHeader
.split(',')
.map((kv) => kv.split('='))
.filter(([, v]) => v !== undefined),
);
const timestamp = parts.t;
const signature = parts.v1;

if (!timestamp || !signature) {
return res.status(400).send('Missing signature components');
}

// 5分以内のタイムスタンプか確認
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('Stale timestamp');
}

const expected = crypto
.createHmac('sha256', process.env.REPORT_FLOW_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body.toString()}`)
.digest('hex');

// timingSafeEqual は長さが違うと TypeError を投げるので先に長さを揃える
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
if (
expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)
) {
return res.status(400).send('Invalid signature');
}

const payload = JSON.parse(req.body.toString());
console.log('Verified webhook:', payload.event);
res.status(200).send('OK');
});

検証サンプル(Python / Flask)

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['REPORT_FLOW_WEBHOOK_SECRET']

@app.post('/webhook')
def webhook():
raw = request.get_data() # 生バイト列
sig_header = request.headers.get('X-Report-Flow-Signature', '')
parts = dict(
kv.split('=', 1) for kv in sig_header.split(',') if '=' in kv
)
timestamp, signature = parts.get('t'), parts.get('v1')

if not timestamp or not signature:
abort(400, 'Missing signature components')

if abs(time.time() - int(timestamp)) > 300:
abort(400, 'Stale timestamp')

# raw はバイナリのまま結合する (UTF-8 でない可能性に備えて decode しない)
signed_payload = f'{timestamp}.'.encode() + raw
expected = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()

if not hmac.compare_digest(expected, signature):
abort(400, 'Invalid signature')

return 'OK', 200

検証サンプル(Go)

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)

var secret = os.Getenv("REPORT_FLOW_WEBHOOK_SECRET")

func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
parts := map[string]string{}
for _, kv := range strings.Split(r.Header.Get("X-Report-Flow-Signature"), ",") {
if i := strings.Index(kv, "="); i > 0 {
parts[kv[:i]] = kv[i+1:]
}
}
if parts["t"] == "" || parts["v1"] == "" {
http.Error(w, "missing signature components", 400)
return
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
http.Error(w, "invalid timestamp", 400)
return
}
if diff := time.Now().Unix() - ts; diff > 300 || diff < -300 {
http.Error(w, "stale timestamp", 400)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal は長さ不一致でも安全に false を返す
if !hmac.Equal([]byte(expected), []byte(parts["v1"])) {
http.Error(w, "invalid signature", 400)
return
}
w.WriteHeader(200)
}

後方互換について

シークレット未生成のワークスペースには X-Report-Flow-Signature ヘッダは付与されません。署名検証を導入する受信側は、まず開発者タブでシークレットを生成してください。

3. タイムアウト設定

Webhookエンドポイントは5秒以内に応答することを推奨します。重い処理(メール送信、データベース書き込みなど)は非同期ジョブキューで実行してください。

4. URLに認証情報を含めない

Webhook URLのクエリパラメータに認証トークンやシークレットを含めないでください。認証が必要な場合は、Headerで送信するか、別途管理してください。

リトライ動作

Webhookエンドポイントが以下のステータスコードを返した場合、ReportFlowは自動的にリトライします:

  • 200-299: 成功(リトライしない)
  • 400-499: クライアントエラー(リトライしない)
  • 500-599: サーバーエラー(リトライする

リトライは最大3回まで行われます。すべてのリトライが失敗しても、PDF生成処理自体は成功扱いとなります。

トラブルシューティング

通知が届かない

以下を確認してください:

  1. Webhook URLが設定されているか

    • ワークスペース設定 > 開発者タブで確認
  2. HTTPSを使用しているか

    • HTTP URLは拒否されます
  3. 公開URLを使用しているか

    • localhostやプライベートIPアドレスは拒否されます
    • テスト環境ではwebhook.siteなどのサービスを利用してください
  4. エンドポイントが200を返すか

    • エラーステータスコード(4xx/5xx)を返している場合、リトライされます
    • エンドポイントのログを確認してください

通知が届かない場合は、サポートにお問い合わせください。

次のステップ