Project Webhook
Overview
Webhookイベントを受け付けるサーバーの作り方についてまとめているセクション。
結論(おすすめ構成) • 生(raw)イベントは “1枚の append-only テーブル” に全保存 → 署名検証・冪等判定・監査の単一責務を担う。ここは分割しないのが運用上いちばん強い。 • 業務で使う情報(決済・返金・サブスク状態など)は別の正規化テーブルに投影 → payments / refunds / subscription_states などに非同期で反映。 • 容量はテーブル分割ではなく “ライフサイクルとストレージ階層化” で解決 → パーティション+圧縮+アーカイブ(S3/BigQuery 等)+保持期間でコントロール。
Stripe / Adyen / Braintree 系の“イベント→台帳(ledger)に反映”の発想に近いです。多くの決済システムは「生のイベントはとにかく残す、使う形は別テーブル」で運用します。
典型パターン(業界でよく見る形)
| 層 | 役割 | 具体 |
|---|---|---|
| 受信・監査(Raw Event Store) | 100%ロス無し・改変不可・冪等化 | webhook_events 1枚に集約。JSONBで原文保持、署名・受信時刻・重要キーを列化、ユニークハッシュで重複排除。日/週パーティション。 |
| ドメイン台帳(Operational Store) | クエリ/整合性/参照最適化 | payments、refunds、subscription_states、必要に応じ subscription_event_log(軽量時系列)。非同期ジョブで投影。 |
| 分析・長期保管(Archive / DWH) | コスト最適化・長期保持 | 古い webhook_events を gz 圧縮 JSON として S3、かつ BigQuery/Snowflake にもロード。ホットは90〜180日、以降はコールド。 |
「一枚テーブル or 分割?」への答え • Raw は一枚がベスト: 同じ検証・冪等・監査ロジックを複数テーブルに重複させないため。イベント種類の増減にも強い。 • 分けるのは“業務テーブル”: 例)決済完了・返金・チャージバック等の**台帳(facts)**はそれぞれスキーマを持つ。レポートや整合チェックが段違いに楽。
容量の現実的見積りと対策
ざっくり指標(例): • イベント 1件の平均サイズ:4–8KB(JSONB + 主要列 + インデックスを含めると、実効で×1.5〜2倍見積り) • 100万件/月 × 6KB ≒ 6GB(データのみ)、インデックス等含め 10〜12GB/月 程度 → 年100GB級でも、パーティション+圧縮+アーカイブで十分運用可能。
効く対策(優先度順):
- 日または週パーティション(received_at 基準)
- 圧縮(TimescaleDB/pgzstd/PG14+ TOAST最適化)
- 列の最小化(主キー・検索キーは列化、残りは JSONB)
- ホット保持期間を短く(例:90日)→ 以降はS3へエクスポート後にパーティション DROP
- アーカイブを検索可能に(BigQuery/Snowflakeへもロードしておくと後追い調査が楽)
ライフサイクル例
層 期間 置き場 目的 Hot 0–90日 Postgres(分割+圧縮) 再処理・調査・デバッグ Warm 3–12ヶ月 S3 + Athena / DWH 監査・長期調査 Cold 1年〜 S3 Glacier 等 監査要件満たすだけ
テーブル設計のたたき台
Raw(append-only, 監査)
列 型 例/用途 id UUID PK provider TEXT revenuecat event_type TEXT INITIAL_PURCHASE, RENEWAL, REFUND 等 app_user_id / transaction_id / original_transaction_id / product_id TEXT 冪等・検索キー occurred_at / received_at TIMESTAMPTZ 発生時刻 / 受信時刻 signature TEXT 署名ヘッダ api_version / environment / store TEXT 解析時に便利 raw_payload JSONB 原文 processed BOOL 投影済みフラグ processing_error TEXT 再試行用 hash_dedup TEXT UNIQUE 重要キー + payload の SHA-256
主要インデックス:(event_type), (app_user_id), (transaction_id, original_transaction_id), (received_at DESC), hash_dedup UNIQUE
ドメイン(例)
payments(決済確定の台帳) • payment_id(外部ID or 自社採番), app_user_id, store, product_id, amount, currency, paid_at, status • webhook_event_id(raw への参照)
refunds(返金の台帳) • refund_id, payment_id(または original_transaction_id で合流), amount, currency, refunded_at, reason, status • webhook_event_id
subscription_states(現在状態のスナップショット) • app_user_id, product_id, store, status, started_at, renewed_at, expires_at, cancelled_at, updated_from_event_id • UNIQUE(app_user_id, product_id, store)
(任意)subscription_event_log(軽量な時系列) • app_user_id, product_id, event_type, at, webhook_event_id
⸻
処理フロー(要点)
- 署名検証 → raw insert(冪等) → 即 200
- ジョブで投影:イベント種別ごとのハンドラで payments/refunds/subscription_states を更新
- 再試行:processing_error がある行をキューに戻す
- アーカイブ:所定の期間でパーティションを S3/BigQuery に出力 → DROP
⸻
よくある落とし穴 • Raw をイベント種類ごとにテーブル分割してしまう(検証・冪等が重複、運用が破綻しがち) • Raw を“永遠にDBに置きっぱなし”にする(アーカイブ戦略不在でコスト爆発) • ドメイン台帳を作らず JSONB直読みクエリに寄せる(後で必ず死ぬ) • 冪等キーが弱く重複挿入(hash_dedup + UNIQUE は必須) • 監査要件なのに raw を UPDATE してしまう(append-only原則を守る)
⸻
いまのあなたの状況への最小ステップ
- webhook_events(上記スキーマ)をまず導入(週パーティション・ユニークハッシュ)
- キュー(BullMQなど)で payments / refunds / subscription_states へ投影
- 90日で S3 + DWH にエクスポート → パーティション DROP の運用タスクを cron で回す
- メトリクス:イベント受信数・処理遅延・投影失敗率をダッシュボード化
⸻
必要なら、あなたの NestJS/TypeORM の現行コードに合わせて • マイグレーション(webhook_events + パーティショニングDDL) • payments/refunds/subscription_states のエンティティ/リポジトリ • BullMQ のジョブ雛形 まで一気に書き起こすよ。DBは Postgres でOK?保持ポリシー(例:Hot 90日)も決めたいので、だいたいのイベント量(件/日)教えてくれたら容量見積りまで出す!
結論:同時(同期)に2つへ書くのは基本NGです。 おすすめは「Rawだけ同期保存+即ACK → 非同期ジョブでドメイン表(payments/refunds/subscription_states)を更新」。
理由はこれ👇
方式 何が起きるか デメリット 同期でRawと業務テーブルに二重書き 受信処理中に両方INSERT/UPDATE 途中で落ちると片方だけ反映の不整合、再送で重複もしやすい、応答が重くなる Rawのみ同期書き+後段で投影(推奨) まずRawに確実に記録→キュー/ワーカーで業務テーブルへ 設計が一段階増えるが、整合性と回復力が高い
⸻
推奨フロー(NestJS/TypeORM)
- 受信コントローラで • 署名検証 • **冪等キー(hash_dedup)**を作る • Raw(webhook_events)にINSERT(ユニーク制約で二重排除) • 200を即返す(ここまで超軽量)
- バックグラウンド(BullMQ など)で • event_typeごとにハンドラ実行 • payments / refunds / subscription_states をトランザクション更新 • 成功したら webhook_events.processed=true、失敗なら processing_error に記録→再試行
二重書きの整合性問題は、Transactional Outboxで解決できます。Raw保存と同時にoutboxテーブルにも行を入れ、ワーカーがそれを処理します(Rawテーブルをoutbox兼用にしてもOK)。
⸻
具体の設計ポイント • Rawテーブルはappend-only+JSONB全文+重要キー(user/transaction/product等)を列化 • ユニーク制約 • Raw:hash_dedup UNIQUE(event_type + app_user_id + transaction_id + occurred_at + payload_hash 等で生成) • 業務:payments(original_transaction_id)、refunds(refund_id or (orig_tx_id, refunded_at)) など外部IDで一意 • 再処理に強く:processing_error IS NOT NULL を定期ジョブで再試行 • SLAが厳しい“即時表示”が要る場合だけ最小限のステータス(例:最新のsubscription_states更新)を軽量に同期する選択肢はあるけど、基本は避けるのが安全
⸻
迷ったらこれで始めよう(最小構成) • 同期:webhook_events へINSERTのみ • 非同期:payments / refunds / subscription_states を更新 • 90日で webhook_events をS3/DWHへアーカイブ→古いパーティションDROP
必要なら、いまのプロジェクトに合う • マイグレーション(Raw+ユニーク制約、業務テーブル) • BullMQのジョブ雛形 • 冪等キー生成の実装 まで一式書くよ。DBはPostgres想定でいい?