Engineering

Stripe webhook を本番運用する時に踏むべき 6 つの落とし穴 — 冪等性・署名検証・タイムアウト・再送・重複・失敗記録

XIORA INSIGHT xiora-official.com Stripe webhook を本番運用する 時に踏むべき 6 つの落とし穴 — 冪等性・ 署名検証・タイムアウト・再送・重複・失敗記録 Xiora — AI-native software, engineered for business.

Stripe webhook を動くところまで実装するのは 30 分ですが、本番運用 6 ヶ月で受ける再送や失敗はまた別の設計が要ります。 冪等性・署名検証・5 秒タイムアウト・再送耐性・重複キー選定・失敗 audit の 6 落とし穴を、Xiora の billing-core-service (FastAPI) 実装事例を根拠に整理します。

KR
沓澤 怜士 (Kutsuzawa Reo)
Xiora 代表 · info@xiora-official.com

Stripe webhook を「動く」ところまで実装する時間は、ドキュメントを見ながらで 30 分程度です。 Stripe CLI の stripe listen --forward-to localhost:3000/webhook を起動し、checkout.session.completed を受けて 200 を返す関数を書けば、開発環境の確認までは到達できます。 一方、本番運用 6 ヶ月では、失敗した event が最大 3 日間・複数回リトライされる旨が Stripe 公式に明記されており (Stripe Docs — Best practices for using webhooks)、無視できない量の再送が現実に発生します。 落とし穴は先に潰したほうが安く済みます。 本稿は Xiora の billing-core-service (FastAPI, port 3025、services/shared/billing-core-service/app/main.py) を実装した際に整理した 6 落とし穴を、実装レベルで振り返る形で共有します。

落とし穴 1: 冪等性 (idempotency) は event.id UNIQUE で担保する

Stripe webhook は、ネットワーク障害・受信側タイムアウト・5xx 応答などを契機に同じ event を複数回送ってきます。 これに対して DB を毎回書き換えると、決済 1 回に対して subscription が 2 回 activate する / invoice が 2 回発行される、といった不整合が起こります。 対策は、Stripe が発行する event.id (例: evt_1P...) を DB カラムの UNIQUE 制約で守り、INSERT-or-skip pattern で書くことです。 Xiora の billing-core-service では BillingEvent.event_id を UNIQUE にし、webhook ハンドラで最初に INSERT を試み、IntegrityError を捕まえた場合は 200 で duplicate=true を返すだけにしています。 SELECT で存在確認 → INSERT に切り替えると、2 プロセス同時受信のレースで UNIQUE 違反を潰しきれないため、順序として INSERT を先に置くのがポイントです。

落とし穴 2: 署名検証は SDK の constructEvent 系関数を通す

Stripe webhook エンドポイントは公開 URL のため、第三者からもリクエストが届きます。 「payload の中身に customer_id が含まれていれば信じる」実装は、payload を偽造されれば被害に直結します。 Stripe は各 event のリクエストヘッダに Stripe-Signature を付けており、受信側は endpoint secret (whsec_...) と HMAC 突合で「Stripe が発行した payload か」を検証できます (Stripe Docs — Verify webhook signatures)。 実装は、Python なら stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)、Node.js なら stripe.webhooks.constructEvent(...) を通すのが基本形です。 endpoint secret はコードに埋めず環境変数 (Vault / .env) に置き、test / live で別 secret を用意すると、テスト payload の混入も見分けられます。 Xiora では未設定時は 503 で fail-secure に落とし、「動くけど検証していない」状態を作らない設計にしています。

落とし穴 3: 5 秒タイムアウトを前提に、重処理は queue に押し出す

Stripe 公式は、webhook に対してタイムリーに 2xx を返すことを求めており、応答が遅い / 失敗するとリトライ対象になります (Stripe Docs — Handle webhook events)。 現場では 5 秒以内に 200 を返す設計にしておくと、ネットワーク揺らぎ込みで収まります。 webhook ハンドラの中で Clerk のユーザーメタデータ更新・welcome メール送信・Slack 通知・BigQuery 書き込みまで同期でやると、外部 API が 1 つ詰まった瞬間に 5 秒を超え、Stripe がリトライを始め、多重処理のリスクが積み上がります。 対策は、webhook 側は「署名検証 + event の DB 記録 + state の UPSERT」までに絞り、重処理は別 consumer (Celery / RQ / background worker / SQS など) に押し出すことです。 Xiora の billing-core-service も同じ設計で、Nexa の Clerk メタデータ更新や Gourmie の plan flip は別 PR の consumer 側で拾う責務分割にしています。

落とし穴 4: 再送への耐性は「同じ event を 2 回処理しても壊れない」を test で押さえる

Stripe は webhook の失敗を最大 3 日間リトライする旨を明記しています (Stripe Docs — Webhook retry logic)。 これは「1 回目の処理で DB を半分だけ書き換えた」「2 回目で残りを書き換えようとした」というパターンが現実に起きうるということです。 対策は 2 段構えで、(a) webhook ハンドラを transactional に書く (dispatch 中に例外が出たら rollback → error 列付きで最小 row を書き残す)、(b) 「同じ payload を 2 回渡しても状態が変わらない」ことを pytest で assert する、の 2 つです。 Xiora の billing-core-service では tests/test_webhook_endpoint.py で同じ event を 2 回 POST し、2 回目のレスポンスに duplicate=true が含まれることと DB の row 数が 1 のままであることを検証しています。

落とし穴 5: 重複防止のキーに checkout session id / payment intent id を使わない

冪等性のキー選定でよくある失敗が、「checkout session id」「payment intent id」「subscription id」を dedup key に使ってしまうパターンです。 これらは同じ 1 つのオブジェクトに対して Stripe が複数の event (例: checkout.session.completed, payment_intent.succeeded, invoice.paid) を独立に送るため、同じキーで dedup すると関連する別 event までまとめて捨ててしまいます。 Stripe が webhook 冪等性は event.id を使うよう案内している通り (Stripe Docs — Webhooks best practices)、dedup key は event.id 一択にするのが安全です。 Xiora では、BillingEvent.event_id が UNIQUE、SubscriptionState は customer × product の複合 unique + last_event_id で「どの event で最後に上書きしたか」を追跡する構造に分けています。

落とし穴 6: 失敗した event の payload + error + timestamp を audit として残す

理想的な webhook 処理は「常に成功する」ですが、現実には downstream の DB deadlock、外部 API 障害、想定外の event 型、API version bump など、失敗理由は尽きません。 ここで多いのが「500 を返せば Stripe が再送してくれるから log だけ残そう」という判断ですが、この設計は 3 日後に消えます (Stripe が retry を諦めた瞬間、我々側にも何も残りません)。 対策は、失敗した event についても payload と error 文字列と timestamp を DB に残し、Stripe Dashboard 側の Events view と cross-reference で「Stripe 側は成功、我々側では副作用が反映されていない」不整合を検知できる状態を作ることです。 Xiora の billing-core-service では、dispatch 失敗時にも BillingEvent を error 列付きで INSERT し、/events audit endpoint (Basic auth) から検索できるようにしています。

Xiora 実装のケース: 6 落とし穴を pytest で押さえる

Xiora の billing-core-service (services/shared/billing-core-service/) は、上記 6 落とし穴を test で拾う構成にしています。 tests/test_webhook_endpoint.pytests/test_subscription_state.py に、署名不正 400 / secret 未設定 503 / duplicate event の idempotent 200 / dispatch 失敗時の audit row 作成 / status 遷移 (active → past_due → canceled) の各パスを分けて記述し、pytest で 35+ ケースが通ります。 実装本体は薄い FastAPI サーバ (app/main.py 181 行) に留め、Stripe SDK は既存 billing-core library (services/shared/billing-core/) 経由でしか触らない責務分割で、library 変更が service に不必要に波及しない設計を取っています。

お問い合わせ / 30 分無料相談

Xiora の Stripe 実装事例 (billing-core-service のコード構成、pytest cover 範囲、5 秒 SLA 設計) を、貴社の SaaS 課金要件に当てはめる形で 30 分お話しします。 SaaS スタートアップの本番前 review、または既存 webhook 実装のセルフレビュー観点として活用ください。



関連 Xiora プロダクト

本記事のトピックに直接関わる Xiora 自社プロダクトです。 いずれも公開情報、¥0 で概要確認できます。


関連ツール (提携リンク)

本記事の内容を実装するにあたり、Xiora が業務で採用しているツール群です。 リンクは提携リンク (advertisement) を含み、経由でお申込みの場合は Xiora に紹介料が入る場合があります。 記事内容の中立性は維持しています。

  • Notion — SMB のドキュメント / ナレッジ管理 SaaS
  • Vercel — Next.js を最速で公開する PaaS
  • Railway — コンテナ / DB を 1 分で立てる PaaS
  • Cloudflare — エッジ CDN + Zero Trust

※ Reo Absolute Gate (KYC) 承認待ちの placeholder URL。 承認後、代理人が sed で real referral ID に一括置換します。

本記事の 参考文献 セクション には 楽天アフィリエイトリンク が含まれます (収益は Xiora の運営に還元されます)。

参考文献 (楽天ブックス)

← Insights 一覧へ