これはStripeが実際に送信したWebhookペイロードで、一部を省略・伏せているが構造はそのままだ—— checkout.session.completed イベント、つまりCheckoutページで誰かが支払いを完了したときに発火するものだ。多くの開発者はこれをざっと一度見て、 data.object.customer と data.object.amount_totalだけを取り出して次に進む。デモならそれで十分なことが多い。だがそれこそが、アプリが注文を二重にフルフィルメントしたり、サブスクリプションの更新を見逃したり、「返金されていない」というクレームの初めてのサポートメールを受け取ったりする原因だ。全体を分解して見ていこう。
{
"id": "evt_1P8xQ2K7z3n9lWqA00Ff2gLm",
"object": "event",
"api_version": "2024-06-20",
"created": 1725456000,
"type": "checkout.session.completed",
"livemode": true,
"pending_webhooks": 1,
"request": { "id": "req_9F2mA1", "idempotency_key": null },
"data": {
"object": {
"id": "cs_live_a1B2c3D4",
"object": "checkout.session",
"customer": "cus_Q3fZ8xLmN2",
"customer_details": {
"email": "[email protected]",
"tax_ids": []
},
"payment_status": "paid",
"payment_intent": "pi_3P8xQ2K7z3n9lWqA1gH4iJ5k",
"subscription": "sub_1P8xQaK7z3n9lWqA",
"amount_total": 2900,
"currency": "usd",
"metadata": {
"app_user_id": "usr_38821",
"plan": "pro_monthly"
}
}
}
}
idと、それに伴い無償で背負うことになる冪等性の問題
evt_1P8xQ2K7z3n9lWqA00Ff2gLm は、重複注文とあなたとの間に立ちはだかる唯一のものだと気づくまではノイズにしか見えない。Stripeがwebhookを配信するのは 少なくとも1回であり、正確に一度ではない。サーバーがリクエストを受け付けたものの200を返す前にタイムアウトすると、Stripeは失敗とみなし、同じ idで同じイベントを再送する。数分後のこともあれば翌日のこともある。あなたのハンドラーがすでにこの正確な checkout.session.completed を処理済みかどうかを確認せずに、 idを見るたびにサブスクリプションを付与しているなら、いずれ二重に付与してしまう。修正はデータベース側の1行だけだ。このフィールドをキーにしたユニーク制約を processed_webhook_events テーブルに設定し、何をするよりも先にチェックする。全体の実装の中で最も地味な1行でありながら、実際に重要な唯一の1行でもある。
本文にはまったく含まれない署名ヘッダー
上記のペイロードはStripeがリクエストボディとして送信するものだ。そこに示されていないのが Stripe-Signature ヘッダーで、これはタイムスタンプとWebhook署名シークレットで計算されたHMAC-SHA256署名を伴って一緒に送られてくる。検証を省略すると、Webhookエンドポイントは、誰でもインターネット上から手作りの「決済成功」JSONを送りつけて有料プランを無料で解放できる公開POSTルートになってしまう。これは理論上の攻撃ではない。エンドポイントURLはクライアント側のJS、ログ、Slackへの貼り付けなどから漏れるものであり、スキャナーはまさにこの形のルートを探している。
この正確なバグを本番環境で2回見たことがある——どちらの場合も修正自体は5分で終わったが、データベース内に無料の「pro」アカウントが紛れ込んだまま、誰にも気づかれず何ヶ月も稼働し続けていた。
検証にはStripe SDKで3行のコストしかかからない(stripe.webhooks.constructEvent(body, sig, secret))が、これは生の未パースのリクエストボディに対して実行する必要がある——フレームワークのJSONミドルウェアがハンドラーに渡す前にすでにパースしてオブジェクトにしてしまっていると、署名チェックは実際の攻撃とは無関係にバイト単位の不一致で失敗する。これがStripe自身のフォーラムで最もよく報告される「Webhookが常に400を返す理由」というバグだ。
data.object.customer と data.object.customer_details
これらは冗長に見えるが、そうではない。 customer はStripeの顧客ID——安定していて再利用可能で、外部キーとして保存すべきものだ。 customer_details はその時点で購入者がチェックアウトフォームに入力した内容のスナップショットだ——メールアドレス、税ID、時には名前——そして customer がnullの場合でも存在しうる。これはStripeにCustomerオブジェクトの作成を依頼しなかった単発Checkoutセッションで起こる。オンボーディングロジックが customer が常に入っていると想定して読み込んでいると、ゲストチェックアウトでそれが静かに壊れる。
metadata: 実際に自分で設定した2つのフィールド
app_user_id と plan はStripeのフィールドではない——Checkoutセッションを作成した際に自分で付与したものだ。これは統合全体の中で最も重要な設計判断でありながら、Stripeのクイックスタートがあまり触れないために見落とされがちだ。 metadataに自分のユーザーIDを入れていないと、この支払いをデータベース内の行に結びつける唯一の方法はメールアドレスでの照合になる。しかしメールアドレスは変わったり、タイプミスされたり、チームメイトの代わりに支払っている誰かのものだったりする。振り返ってみると、私が書いた設計の悪いWebhookハンドラーはすべて、3ステップ前に自分でセットしていたはずのmetadataを信頼せず customer_details.email から身元を再構築しようとしたものだった。
payment_status: paid だけが値ではない
このイベントの存在そのものを支払い完了の証拠として扱いたくなるが、常にそうとは限らない—— payment_status は unpaid (セッションは完了したが、銀行引き落としのような遅延決済手段がまだ確定していない)や no_payment_required (全額割引のチェックアウトや、カード請求のない無料トライアル)にもなり得る。このフィールドを確認せずに checkout.session.completed でフルフィルメントを行うと、実際に入金が確定する前に商品を発送してしまうことになる。数ドルを超える金額であれば payment_status: "paid" を待つか、より良くは invoice.paid / payment_intent.succeeded をセッションイベントの代わりにフルフィルメントの起点にすべきだ。
| イベントタイプ | 発火するタイミング | 対応すべきこと |
|---|---|---|
checkout.session.completed | 購入者がCheckoutフォームの入力を完了した | ログには記録するが、 payment_status を確認してからフルフィルメントを行う |
payment_intent.succeeded | 実際に決済が確定した | 単発購入のフルフィルメントを行う安全なタイミング |
invoice.paid | サブスクリプションの請求書が支払われた(初回または更新時) | アクセス権を延長し、利用回数のカウンターをリセットする |
customer.subscription.updated | プラン変更、数量変更、期間終了時キャンセルの切り替え | 権限を同期する。キャンセルを意味するとは限らない |
charge.refunded | 自分または購入者の銀行が支払いを取り消した | アクセス権を取り消す。最も見落とされがちな対応 |
このペイロードに含まれていないフィールド:この後に何が起こるか
このJSONには、Stripeが配信失敗時に最大3日間バックオフスケジュールで再送を試みることや、連続失敗が一定回数を超えるとエンドポイントを無効化してメールで通知することは一切書かれていない。この挙動はペイロードではなくダッシュボード設定側にあり、多くの開発者はデプロイでルートパスが変わったせいでエンドポイントが1週間静かに死んでいたことに、後になって初めて気づく。
最初の更新見逃しの後ではなく、Webhookエンドポイントを組み込んだその日のうちに、成功率を監視するチェックを設定すること。ペイロードは何が起きたかを教えてくれるが、自分のハンドラーが静かに動作を止めたことまでは警告してくれない。



