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のワークスペース設定画面から行います:
- ワークスペース設定を開く
- 「開発者」タブに移動
- 「Webhook URL」フィールドに、通知を受け取るHTTPS URLを入力
- 「更新」をクリック
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
}
}
]
}
フィールド説明
| フィールド | 型 | 説明 |
|---|---|---|
event | string | 固定値: "file.completed" |
timestamp | string | イベント発生時刻 (ISO 8601形式) |
workspaceId | string | ワークスペースID(参照用) |
requestId | string | リクエストID(ダウンロードエンドポイントで使用) |
designId | string | デザインID |
version | number | バージョン番号 |
files | array | 生成ファイル情報の配列 |
files[].fileId | string | ファイルID(個別ダウンロードエンドポイントで使用) |
files[].fileName | string | ファイル名(拡張子付き) |
files[].passthrough | object | リクエスト時に指定した passthrough の値(指定時のみ) |
files[].share.shareType | string | 共有タイプ(workspace / invited / public) |
files[].share.url | string | ファイル表示URL |
files[].share.passcodeEnabled | boolean | パスコード有効フラグ |
files[].share.passcode | string | サーバー生成パスコード(passcodeEnabled=true かつ生成直後のみ) |
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 ヘッダは付与されません。署名検証を導入する受信側は、まず開発者タブでシークレットを生成してください。