# AIに決済を作らせたとき、二重課金が起きる理由と防ぎ方

> AIが書いた決済処理で顧客が二重に課金される仕組みを、非エンジニア向けに解説します。Webhookが複数回届くのは仕様であること、冪等性という対策、金額をブラウザから送ってはいけない理由、署名検証の必要性。本番二重課金0件を維持した決済基盤の実装知見に基づきます。

- 公開日: 2026-08-06
- 著者: 友田 陽大
- タグ: バイブコーディング, 決済, セキュリティ, 個人開発, 信頼性
- URL: https://tomodahinata.com/blog/ai-app-payment-double-charge-prevention
- カテゴリ: AIで作ったアプリの運営
- 総合ガイド: https://tomodahinata.com/blog/ai-app-pre-launch-guide-for-non-engineers

## 要点

- Stripeを含む決済サービスのWebhookは「最低1回」届く設計。同じ通知が複数回届くのは障害ではなく仕様で、公式ドキュメントにも明記されている
- 通知を受けるたびに残高を足す実装は、そのまま二重付与になる。しかも手元のテストでは絶対に再現しない — 本番の混雑時に初めて出る
- 対策は冪等性。イベントIDを一意キーで保存し、処理済みなら何もせず正常応答を返すだけ。難しくはなく、頼まれないから書かれないだけ
- 金額をブラウザから受け取る実装は、リクエストを書き換えるだけで1円で買える。受け取るのは商品IDと数量だけにして、価格はサーバー側で引き直す
- Webhookの署名検証が無いと、第三者が「支払い成功」の偽通知を送るだけで商品をタダで受け取れる。検証には生のリクエストボディが必要な点に注意

---

お金が絡むと、間違いのコストが変わります。データが見えてしまうのも深刻ですが、**二重に課金された顧客は、その場で怒って、二度と戻ってきません**。返金はできても、信用は戻りません。

そして厄介なことに、この不具合は**手元のテストでは絶対に再現しません**。本番で、しかも一番忙しい時間帯に初めて出ます。

決済機能が無いアプリの方は、この記事は飛ばして構いません。

---

## 1. まず、何が起きるのか

Stripe などの決済サービスを使うと、こういう流れになります。

1. 顧客が「購入」を押す
2. 決済サービスの画面でカード情報を入力し、支払いが完了する
3. **決済サービスがあなたのアプリに「支払われましたよ」と通知を送る**（Webhook と呼びます）
4. あなたのアプリがその通知を受けて、商品を渡す・残高を増やす・注文を確定する

問題は 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回分しか来ません。これが冪等です。押した回数だけエレベーターが来たら困ります。

決済処理でこれを実現する方法は、実はとても単純です。

### やること

1. 決済サービスからの通知には、**イベントID**（`evt_` で始まる一意の文字列など）が含まれている
2. 処理する前に、そのIDが**すでに処理済みかどうか**をデータベースで確認する
3. 処理済みなら、**何もせずに「受け取りました」と応答する**
4. 未処理なら、処理してから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さえ分かれば、誰でもリクエストを送れます。

署名を検証していないと、こうなります。

1. 攻撃者があなたのWebhookのURLを見つける（推測しやすい形が多い）
2. 「支払いが成功しました」という形のデータを、自分で組み立てて送る
3. あなたのアプリは、それを本物だと信じて商品を渡す

決済サービスは、通知に**署名**を付けています。これを検証すれば、本物かどうか判定できます。

**実装上の注意**: 署名の検証には、**フレームワークが解析する前の「生のリクエストボディ」** が必要です。JSONとして解析してしまうと、文字列が微妙に変わって検証に失敗します。AI がこの点を見落とすと、「署名検証を入れたのに全部失敗する」という状態になり、そこで検証を外してしまう——という最悪の流れが起こり得ます。

---

## 6. 自分で確認する手順

コードが読めなくても、いくつかは確認できます。

### 確認1: 金額の改ざん

1. 購入画面を開き、F12 で開発者ツールの「ネットワーク」タブを開く
2. 購入ボタンを押す
3. サーバーへ送られたリクエストの中身を見る

**送信内容に「金額」「価格」「amount」「price」といった項目が含まれていたら要注意**です。商品IDと数量だけなら正常です。

### 確認2: Webhookの署名検証

これはコードを見ないと分かりませんが、AI に聞けば答えられます（次章の指示文を使ってください）。

### 確認3: 二重処理

Stripe の管理画面には、**Webhook イベントを手動で再送する機能**があります。

1. Stripe ダッシュボード → Developers → Webhooks
2. 過去のイベントを選ぶ
3. 「Resend（再送）」を実行する
4. **あなたのアプリのデータを確認する**

残高が二重に増えた、注文が2つできた——なら、二重処理されています。何も変わらなければ、冪等性が効いています。

**これが唯一の実地確認方法です。** 公開前に必ず1回やってください。

---

## 7. AIに貼る修正指示

```text
このアプリの決済まわりを、次の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で作ったアプリを公開する前に](/blog/ai-app-pre-launch-guide-for-non-engineers) にまとめています。
