0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stripe Webhook二重付与を封じる — 署名検証と冪等ログの単一入口

0
Posted at

症状 — 本番で権限が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つだけ。

  1. Webhook 入口は1ファイル・1関数 — 署名検証 → ログ → fulfillment まで分岐を増やさない。
  2. 冪等は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 だけ返ってログが静か」は危険信号。

検証手順

  1. Stripe CLI で転送: stripe listen --forward-to localhost:8000/stripe/webhook
  2. 別ターミナル: stripe trigger checkout.session.completed
  3. 同じ payload を curl で再 POST(CLI が表示する Stripe-Signature 付き)→ HTTP 200、DB の event_id / session_id が 各1行
  4. 意図的に fulfillment 内で 10s sleep → Stripe Dashboard で再送 → 2回目も副作用ゼロを確認
  5. 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回目を吸収した」ことを可視化する。これが本番で二重付与を封じる最小構成だ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?