# Why double-charging happens when you let AI build payments, and how to prevent it

> How payment code written by AI ends up charging customers twice, explained for non-engineers. Why webhooks arriving more than once is by design, what idempotency means, why the amount must never come from the browser, and why signature verification matters. Grounded in a payment platform that has held double-charges at zero in production.

- Published: 2026-08-06
- Author: 友田 陽大
- Tags: バイブコーディング, 決済, セキュリティ, 個人開発, 信頼性
- URL: https://tomodahinata.com/en/blog/ai-app-payment-double-charge-prevention
- Category: Running the app you built with AI
- Pillar guide: https://tomodahinata.com/en/blog/ai-app-pre-launch-guide-for-non-engineers

## Key points

- Payment providers including Stripe deliver webhooks at least once. The same notification arriving more than once is by design, not a fault, and it is documented as such.
- An implementation that adds to a balance on every notification grants twice — and it never reproduces in local testing. It surfaces in production, at the busiest moment.
- The fix is idempotency: store the event ID under a unique key and do nothing when it has already been processed. It isn't difficult, it just goes unasked.
- Taking the amount from the browser lets anyone buy for one cent by editing the request. Accept only a product ID and quantity, and look the price up on the server.
- Without webhook signature verification, a third party can send a fake 'payment succeeded' event and receive goods for free. Verification needs the raw request body.

---

Once money is involved, the cost of a mistake changes. Data being exposed is serious too, but **a customer charged twice gets angry on the spot and never comes back**. You can refund the money. You cannot refund the trust.

And awkwardly, this defect **never reproduces in local testing**. It shows up in production, during your busiest hour.

If your app has no payments, skip this article.

---

## 1. What actually happens

Using a payment provider like Stripe, the flow looks like this:

1. The customer presses "buy"
2. They enter card details on the provider's screen and the payment completes
3. **The provider sends your app a notification saying "this was paid"** — a webhook
4. Your app receives it and hands over the goods, increases a balance, confirms an order

Step 3 is the problem.

**That notification can arrive more than once with identical content.**

And if step 4 is written as "add to the balance every time a notification arrives" — two arrivals means two additions. That is double-granting.

---

## 2. Why it arrives more than once — this is by design

"A notification arriving twice sounds like a bug." It isn't. **It's the design.**

The worst outcome for a payment provider is **the notification not arriving**. The payment completed but the goods were never handed over — that would undermine trust in the provider at the root.

So they designed it this way:

> **Rather than deliver exactly once, deliver at least once**

This is called **at-least-once delivery**. Resends occur when:

- Your app was slow to respond (treated as a timeout)
- Your app returned a temporary error
- The network was unstable
- The provider retried internally

Stripe's official documentation states both that events may be delivered more than once and that **the receiver should process them idempotently**.

Which means — **not sending duplicates is not the provider's job; not processing duplicates is yours.** That is the contract.

---

## 3. Why AI misses it

Ask for "increase the balance when payment completes" and AI writes exactly that. It's a correct implementation.

What it doesn't consider is **"what should happen if the same notification arrives twice."** Nobody asked.

This isn't malice or laziness — it's outside the scope of the instruction. And then:

**In local testing, the notification arrives once.**

You make a test purchase; one notification. It works. It works every time you try. The duplicate-handling branch **never executes once**.

Veracode's testing found security flaws in 45% of AI-generated code, but this class of defect — no error, breaks only in production — is among the harder ones for static analysis to catch too. The only option is to design it in from the start.

---

## 4. The fix — the idea of idempotency

The countermeasure has a name: **idempotency**.

> **The property of producing the same result however many times you do it**

Press a lift button five times and the lift still comes once. That is idempotent. It would be a problem if a lift arrived per press.

The way to achieve this for payments is, in fact, very simple.

### What to do

1. The provider's notification contains an **event ID** — a unique string, often starting with `evt_`
2. Before processing, check the database for **whether that ID has already been handled**
3. If it has, **do nothing and respond "received"**
4. If it hasn't, process it and then record the ID

That's it. It isn't difficult. **It just gets forgotten.**

### Three implementation points

**(1) Keep the record and the business change in one transaction**

Performing "increase the balance" and "record the ID" separately means a crash in between leaves the balance increased with no ID recorded. The next notification increases it again. These two must **succeed or fail as a single unit**.

**(2) Put a unique constraint on the ID column**

Another notification can arrive between "check" and "write" (a race). With a database-level constraint saying "the same ID cannot be inserted twice," whichever arrives second is reliably rejected. The point is not to rely on the application-level check alone.

**(3) Get the status code right**

When you did nothing because it was already processed, **return success (200)**. Returning an error makes the provider decide it hasn't arrived and resend even more.

Conversely, when processing genuinely failed, **return an error**. Returning success there makes the provider consider it delivered, stop resending, and the notification is lost permanently.

### Confirmed on a real engagement

On the payment platform I worked on, this design has kept **double-charges in production at zero**. That was a payment platform spanning four surfaces — customer, merchant, admin, and in-store terminal.

Worth emphasising: **this is not technically advanced**. The only difference is whether you decided to always do it.

---

## 5. Two more traps

Payments have two other common holes besides double-charging.

### Trap 1: taking the amount from the browser

AI tends to write this:

```
[dangerous]
browser: "buying product A, for 5000 yen"
server:  "understood, creating a 5000 yen payment"
```

Passing the screen's state straight through — straightforward, and it works.

But **values sent from the browser are freely modifiable by the user**. Using the developer tools, `5000` can be changed to `1` before sending.

```
[correct]
browser: "buying 1 of product A"
server:  "look up product A's price in the database → 5000 yen → create the payment"
```

**Accept only a product ID and quantity. Look the price up on the server.** That is the principle. Never trust an amount that came from the client.

Alongside this, quantity limits (rejecting negatives and absurd sizes) and stock checks must also happen on the server. A purchase with "quantity −1" can generate a refund.

### Trap 2: not verifying the signature

Your webhook endpoint is on the public internet. Anyone who knows the URL can send it a request.

Without signature verification:

1. An attacker finds your webhook URL (the shape is often guessable)
2. They construct data in the form of "payment succeeded" and send it
3. Your app believes it and hands over the goods

Payment providers attach a **signature** to the notification. Verifying it tells you whether it is genuine.

**An implementation note**: signature verification needs the **raw request body, before the framework parses it**. Parse it as JSON first and the string shifts subtly, so verification fails. If AI misses this, you end up with "I added signature verification and now everything fails" — and the worst possible next step is removing the verification.

---

## 6. How to check for yourself

Even without reading code, some of this is checkable.

### Check 1: amount tampering

1. Open the purchase screen and press F12 for the developer tools "Network" tab
2. Press the buy button
3. Inspect the request sent to the server

**If the payload contains a field like "amount" or "price", be suspicious.** Only a product ID and quantity is correct.

### Check 2: webhook signature verification

This needs the code, but an AI can answer it (use the instruction in the next section).

### Check 3: duplicate processing

The Stripe dashboard has a feature to **resend a webhook event manually**.

1. Stripe dashboard → Developers → Webhooks
2. Pick a past event
3. Run "Resend"
4. **Check your app's data**

Balance increased twice, or two orders created — you are processing duplicates. Nothing changed — idempotency is working.

**This is the only real-world confirmation.** Do it once before launch, without exception.

---

## 7. The instruction to paste into your AI tool

```text
Check this app's payment code against the following four points and report.

(1) Whether the webhook handler can process the same event twice when it
    arrives more than once
(2) Whether the payment amount uses a value sent from the client (browser)
(3) Whether the webhook signature is verified
(4) Whether quantity and stock are validated on the server

Then make these fixes.

■ Idempotency
Store the event ID as a unique key (with a database unique constraint) and
return early when it has already been processed. Keep recording the event ID
and the business change (balance update, order confirmation) in a single
transaction. Return 200 when you did nothing because it was already processed,
and return an error only when processing genuinely failed, so the provider can
resend.

■ Amount
If the amount comes from the client, change it to accept only a product ID and
quantity and look the price up on the server. Add validation for quantity
limits and negative values.

■ Signature verification
If it is missing, implement the provider's official signature verification.
Use the raw request body before the framework parses it, and return 400
without processing when verification fails.

I cannot read code, so explain in one plain sentence per item what was possible
before the fix.
```

---

## 8. After fixing, always resend a test event

An AI saying "fixed" is not confirmation.

**Resend an event from the Stripe dashboard for real.** If the data doesn't double, idempotency is working.

That test takes a minute and fully closes the most painful incident in production. In cost-effectiveness terms it beats every other task in this article.

---

## Summary

- Payment webhooks are delivered at least once. Multiple arrivals are by design, not a fault
- Not sending duplicates isn't the provider's job; not processing duplicates is yours
- The fix is idempotency: store the event ID under a unique key and do nothing when already handled. Keep the record and the business change in one transaction
- Don't take the amount from the browser. Accept a product ID and quantity, and look the price up on the server
- Webhook signature verification is mandatory, and it needs the raw request body
- Confirm by resending an event from the Stripe dashboard. Once, before launch

Payments are where AI-built apps do **the most concrete damage**. They are also where the countermeasures are the most formulaic — put them in once and you're done.

What else to check is in [Before you launch the app you built with AI](/blog/ai-app-pre-launch-guide-for-non-engineers).
