お金が絡むと、間違いのコストが変わります。データが見えてしまうのも深刻ですが、二重に課金された顧客は、その場で怒って、二度と戻ってきません。返金はできても、信用は戻りません。
そして厄介なことに、この不具合は手元のテストでは絶対に再現しません。本番で、しかも一番忙しい時間帯に初めて出ます。
決済機能が無いアプリの方は、この記事は飛ばして構いません。
1. まず、何が起きるのか
Stripe などの決済サービスを使うと、こういう流れになります。
- 顧客が「購入」を押す
- 決済サービスの画面でカード情報を入力し、支払いが完了する
- 決済サービスがあなたのアプリに「支払われましたよ」と通知を送る(Webhook と呼びます)
- あなたのアプリがその通知を受けて、商品を渡す・残高を増やす・注文を確定する
問題は 3 です。
この通知は、同じ内容で複数回届くことがあります。
そして 4 の処理が「通知を受けるたびに残高を足す」形だと——2回届けば2回足されます。それが二重付与です。
2. なぜ複数回届くのか — これは仕様です
「通知が2回来るなんて、バグでは?」と思うかもしれません。違います。設計です。
決済サービスにとって最悪の事態は、通知が届かないことです。支払いは完了したのに商品が渡されない——これは決済サービスの信頼を根本から損ないます。
だから彼らはこう設計しています。
確実に1回届けるより、最低1回は届ける
これを at-least-once(最低1回)配信 と呼びます。次のような場合に再送が起きます。
- あなたのアプリの応答が遅かった(タイムアウト扱いになる)
- あなたのアプリが一時的にエラーを返した
- ネットワークが不安定だった
- 決済サービス側の内部的な再試行
Stripe の公式ドキュメントにも、イベントが複数回配信され得ることと、受け取る側が冪等に処理すべきことが明記されています。
つまり——二重に届けないのは決済サービスの仕事ではなく、二重に処理しないのがあなたの仕事です。契約としてそうなっています。
3. なぜAIは書き漏らすのか
「支払いが完了したら残高を増やして」と頼めば、AI はその通りに書きます。それは正しい実装です。
ただし「同じ通知が2回来たらどうするか」は、頼まれていないので考慮されません。
これは意地悪でも手抜きでもなく、指示の範囲外だからです。そして——
手元での動作確認では、通知は1回しか来ません。
自分でテスト購入すると、通知は1回。正常に動く。何度やっても正常に動く。二重処理の分岐は一度も実行されないからです。
Veracode の検証でAI生成コードの45%に脆弱性が見つかったという結果がありますが、この種の「エラーは出ないが本番でだけ壊れる」欠陥は、静的な検査でも捕まえにくい部類です。設計として最初から入れておくしかありません。
4. 対策 — 冪等性という考え方
対策には名前があります。冪等性(べきとうせい、idempotency)。
何回やっても結果が同じになる性質
エレベーターのボタンを5回押しても、エレベーターは1回分しか来ません。これが冪等です。押した回数だけエレベーターが来たら困ります。
決済処理でこれを実現する方法は、実はとても単純です。
やること
- 決済サービスからの通知には、イベントID(
evt_で始まる一意の文字列など)が含まれている - 処理する前に、そのIDがすでに処理済みかどうかをデータベースで確認する
- 処理済みなら、何もせずに「受け取りました」と応答する
- 未処理なら、処理してからIDを記録する
これだけです。難しくありません。忘れられるだけです。
実装上の要点3つ
(1)記録と業務処理は同じトランザクションにまとめる
「残高を増やす」と「IDを記録する」を別々に行うと、その間でアプリが落ちたときに、残高は増えたのにIDが記録されていない状態になります。次の通知でまた増えます。この2つは必ず一括で成功か失敗かにします。
(2)IDの列に「一意制約」を付ける
「確認してから書き込む」の間に、もう1つの通知が同時に入ることがあります(競合状態)。データベース側で「同じIDは2行入れられない」という制約を付けておけば、後から来たほうが確実に弾かれます。アプリ側のチェックだけに頼らない、というのが要点です。
(3)返すステータスコードを間違えない
処理済みで何もしなかった場合も、成功(200)を返します。エラーを返すと、決済サービスは「まだ届いていない」と判断してさらに再送します。
逆に、本当に処理に失敗したときはエラーを返します。ここで成功を返してしまうと、決済サービスは「届いた」と判断して再送をやめ、通知が永久に失われます。
実案件での確認
私が担当した決済基盤では、この設計により本番での二重課金を0件に保っています。4つの面(顧客・加盟店・管理・店頭端末)を横断する規模の決済プラットフォームでの実績です。
強調しておきたいのは、これが技術的に高度な話ではないことです。「必ずやる」と決めているかどうかの差でしかありません。
5. もう2つの落とし穴
決済まわりには、二重課金以外にもよくある穴が2つあります。
落とし穴1: 金額をブラウザから受け取っている
こういう実装を、AI は書きがちです。
【危険】
ブラウザ: 「商品Aを、5000円で購入します」
サーバー: 「はい、5000円で決済を作成します」
画面の状態をそのままサーバーに渡す——素直で、正常に動きます。
しかしブラウザから送られてくる値は、利用者が自由に書き換えられます。開発者ツールを使えば、5000 を 1 に変えて送ることができます。
【正しい】
ブラウザ: 「商品Aを、1個購入します」
サーバー: 「商品Aの価格をデータベースで確認 → 5000円 → 決済を作成」
受け取るのは商品IDと数量だけ。価格はサーバー側で引き直す。 これが原則です。クライアントから来た金額は、絶対に信用しません。
あわせて、数量の上限(マイナスや異常な大きさを弾く)と在庫の確認も、サーバー側で行う必要があります。「数量 -1」で購入されると、返金が発生することがあります。
落とし穴2: 署名を検証していない
Webhook の受け口は、インターネットに公開されています。URLさえ分かれば、誰でもリクエストを送れます。
署名を検証していないと、こうなります。
- 攻撃者があなたのWebhookのURLを見つける(推測しやすい形が多い)
- 「支払いが成功しました」という形のデータを、自分で組み立てて送る
- あなたのアプリは、それを本物だと信じて商品を渡す
決済サービスは、通知に署名を付けています。これを検証すれば、本物かどうか判定できます。
実装上の注意: 署名の検証には、フレームワークが解析する前の「生のリクエストボディ」 が必要です。JSONとして解析してしまうと、文字列が微妙に変わって検証に失敗します。AI がこの点を見落とすと、「署名検証を入れたのに全部失敗する」という状態になり、そこで検証を外してしまう——という最悪の流れが起こり得ます。
6. 自分で確認する手順
コードが読めなくても、いくつかは確認できます。
確認1: 金額の改ざん
- 購入画面を開き、F12 で開発者ツールの「ネットワーク」タブを開く
- 購入ボタンを押す
- サーバーへ送られたリクエストの中身を見る
送信内容に「金額」「価格」「amount」「price」といった項目が含まれていたら要注意です。商品IDと数量だけなら正常です。
確認2: Webhookの署名検証
これはコードを見ないと分かりませんが、AI に聞けば答えられます(次章の指示文を使ってください)。
確認3: 二重処理
Stripe の管理画面には、Webhook イベントを手動で再送する機能があります。
- Stripe ダッシュボード → Developers → Webhooks
- 過去のイベントを選ぶ
- 「Resend(再送)」を実行する
- あなたのアプリのデータを確認する
残高が二重に増えた、注文が2つできた——なら、二重処理されています。何も変わらなければ、冪等性が効いています。
これが唯一の実地確認方法です。 公開前に必ず1回やってください。
7. AIに貼る修正指示
このアプリの決済まわりを、次の4点について確認して報告してください。
(1) Webhook の処理で、同じイベントが複数回届いたときに二重処理される
可能性があるか
(2) 決済金額がクライアント(ブラウザ)から送られた値を使っていないか
(3) Webhook の署名検証が行われているか
(4) 数量や在庫の検証がサーバー側で行われているか
そのうえで、次のように修正してください。
■ 冪等性
イベントIDを一意キー(データベースの unique 制約付き)として保存し、
処理済みなら早期リターンする実装を追加してください。イベントIDの記録と
業務処理(残高の更新・注文の確定など)は同一トランザクションにまとめて
ください。処理済みで何もしなかった場合は 200 を返し、本当に処理に失敗
した場合のみエラーを返して決済サービスが再送できるようにしてください。
■ 金額
クライアントから金額を受け取っている場合は、商品IDと数量だけを受け取り、
価格はサーバー側でデータベースから引き直す実装に変更してください。数量
の上限とマイナス値の検証も追加してください。
■ 署名検証
未実装なら、決済サービス公式の署名検証を実装してください。その際、
フレームワークが本文を解析する前の生のリクエストボディを使うように
し、検証に失敗したら処理せず 400 を返してください。
私はコードが読めないので、各項目について「修正前は何ができてしまう
状態だったか」を日本語で1行ずつ説明してください。
8. 修正したあと、必ず再送テストを
AI が「修正しました」と言っても、それだけでは確認になりません。
Stripe の管理画面から、実際にイベントを再送してください。 データが二重にならなければ、冪等性が効いています。
このテストは1分で終わり、しかも本番で最も痛い事故を1つ完全に潰せます。費用対効果で言えば、この記事のどの作業よりも高いはずです。
まとめ
- 決済サービスのWebhookは「最低1回」届く設計。複数回届くのは仕様であって障害ではない
- 二重に届けないのは決済サービスの仕事ではなく、二重に処理しないのがあなたの仕事
- 対策は冪等性。イベントIDを一意キーで保存し、処理済みなら何もしない。記録と業務処理は同一トランザクションで
- 金額はブラウザから受け取らない。商品IDと数量だけ受け取り、価格はサーバー側で引き直す
- Webhookの署名検証は必須。生のリクエストボディを使う点に注意
- 確認方法は Stripe 管理画面からのイベント再送。公開前に必ず1回
決済は、AI で作ったアプリで最も実害が出やすい領域です。同時に、対策は定型的で、一度入れれば終わりです。
他に何を確認すべきかは AIで作ったアプリを公開する前に にまとめています。