症状 — 本番で権限が2倍になる夜
Stripe Checkout 完了後、ユーザーから「決済は1回なのにアクセス権が2つある」と連絡が来た。Dashboard の Events には checkout.session.completed が 同一 event.id で2行 並んでいた。
STRIPE_WEBHOOK_RECV path=/stripe/webhook bytes=3842 sig_present=1
STRIPE_WEBHOOK_OK event_id=evt_1Qabc... type=checkout.session.completed
STRIPE_CHECKOUT_FULFILL session=cs_test_... tier=library payment_status=paid
# 90秒後 — 502 タイムアウト後の再送
STRIPE_WEBHOOK_RECV path=/stripe/webhook bytes=3842 sig_present=1
STRIPE_WEBHOOK_OK event_id=evt_1Qabc... type=checkout.session.completed
STRIPE_CHECKOUT_FULFILL session=cs_test_... tier=library payment_status=paid
2行目が出た時点で、fulfillment が session_id ベースの冪等チェックを持っていなければ 二重付与は確定する。Stripe は「届いたか不明」なら同じ event を再送する。これはバグではなく Webhook の再送仕様 そのものだ。
原因分析 — 3つの穴が重なる
| 穴 | 起きること | 典型ログ |
|---|---|---|
| 署名未検証 | 偽 payload で fulfillment が走る |
SignatureVerificationError 以前に DB 更新 |
| event 再送 | 同一 event.id が複数回届く |
Dashboard で同じ event が Delivered × N |
| 502 / 遅延 | Stripe が未 ACK と判断して再送 | Render / uvicorn が 30s で落ちる |
ローカルでは stripe listen 1回だけなので再現しない。本番だけ 502・デプロイ中・cold start が重なると再送が走る。
環境
| 項目 | バージョン |
|---|---|
| Python | 3.12 |
| FastAPI | 0.115.x |
| stripe (Python SDK) | 11.x |
| uvicorn | 0.32.x |
| Stripe API | 2024-11-20.acacia(Dashboard 表示に準拠) |
検証は Stripe CLI 8.x + テストモード whsec_... で行った。参考: 署名検証ドキュメント
解決策 — 単一入口 + 2段階冪等
方針は2つだけ。
- Webhook 入口は1ファイル・1関数 — 署名検証 → ログ → fulfillment まで分岐を増やさない。
-
冪等は2層 — 入口で
event.id、ビジネスロジックでsession_id/invoice_id。
入口(FastAPI)
from fastapi import HTTPException, Request
import stripe
async def handle_stripe_webhook(request: Request, stripe_signature: str | None):
secret = os.environ["STRIPE_WEBHOOK_SECRET"] # Dashboard の whsec
if not stripe_signature:
raise HTTPException(400, detail="Stripe-Signature ヘッダーがありません")
payload = await request.body()
try:
event = stripe.Webhook.construct_event(payload, stripe_signature, secret)
except stripe.error.SignatureVerificationError as e:
raise HTTPException(400, detail="Webhook signature verification failed") from e
event_id = str(event.id)
event_type = str(event.type)
print(f"STRIPE_WEBHOOK_OK event_id={event_id} type={event_type}", flush=True)
if event_type == "checkout.session.completed":
session = event.data.object
result = await process_checkout_completed(session)
return {"status": "success", "event_id": event_id, "unlock": result}
print(f"STRIPE_WEBHOOK_IGNORE type={event_type}", flush=True)
return {"status": "success", "event_id": event_id, "ignored": True}
construct_event より前 に DB 更新やメール送信を書かない。署名 NG は 400 で即返す。
event.id 冪等ストア(入口層)
# SQLite 例 — キーは event.id のみ
async def claim_event(event_id: str) -> bool:
"""INSERT 成功=True(初回)、UNIQUE 違反=False(再送)"""
try:
await db.execute(
"INSERT INTO stripe_events (event_id, processed_at) VALUES (?, datetime('now'))",
(event_id,),
)
return True
except IntegrityError:
return False
入口で claim_event が False なら fulfillment に入らず 200 を返す。Stripe は 2xx なら再送を止める。
session_id 冪等(fulfillment 層)
event 冪等だけでは不十分なケースがある。別 event タイプで同じ session を触る、修復ジョブが走る——そのため fulfillment 側でも ビジネスキー で守る。
async def process_checkout_completed(session) -> dict | None:
session_id = str(session.get("id") or "")
existing = lookup_entitlement_by_session(session_id)
if existing:
print(f"STRIPE_CHECKOUT_IDEMPOTENT session={session_id}", flush=True)
return {"session_id": session_id, "idempotent": True, **existing}
# 初回のみ: 権限レコード作成・外部 API 呼び出し
...
サブスクリプション継続課金では invoice.id を同様に記録する。ログに idempotent: True が出れば再送を安全に吸収できている。
観測ログ(本番の合格ライン)
STRIPE_WEBHOOK_RECV path=/stripe/webhook bytes=3842 secret=whsec_…abcd sig_present=1
STRIPE_WEBHOOK_OK event_id=evt_xxx type=checkout.session.completed
STRIPE_CHECKOUT_IDEMPOTENT session=cs_xxx
STRIPE_WEBHOOK_FULFILL event_id=evt_xxx type=checkout.session.completed result=ok
再送時は STRIPE_CHECKOUT_IDEMPOTENT または duplicate=true が 必ず 1行出る設計にする。「200 だけ返ってログが静か」は危険信号。
検証手順
- Stripe CLI で転送:
stripe listen --forward-to localhost:8000/stripe/webhook - 別ターミナル:
stripe trigger checkout.session.completed - 同じ payload を curl で再 POST(CLI が表示する
Stripe-Signature付き)→ HTTP 200、DB の event_id / session_id が 各1行 - 意図的に fulfillment 内で 10s
sleep→ Stripe Dashboard で再送 → 2回目も副作用ゼロを確認 -
whsecを1文字改ざん → 400 + fulfillment 未実行
よくある穴
- in-memory set で event_id 管理 — Worker 再起動で消える。Render 複数インスタンスでは穴が開く。
-
session.idだけで event 冪等を代用 — 同一 session に別 event タイプが来たとき破綻する。event.id と business key は役割が違う。 - 200 を返す前に mark — 処理途中で落ちると「Stripe は成功扱い・ユーザーは未付与」。mark は 副作用成功後。
-
Dashboard の URL と
ENDPOINT_SECRETの取り違え — テスト用 whsec を本番 URL に貼るとSignatureVerificationErrorが永続する。 -
複数パス —
/webhookと/stripe/webhookが別実装だと、片方だけ冪等で再送経路が残る。エイリアスは 同一ハンドラ に集約する。
まとめ
Stripe Webhook の再送は「いつか来る」前提で設計する。署名検証 + event.id 入口冪等 + session_id / invoice_id 業務冪等を 1エンドポイント に集約し、ログで「2回目を吸収した」ことを可視化する。これが本番で二重付与を封じる最小構成だ。