Skip to main content
友田 陽大
Running the app you built with AI
バイブコーディング
決済
セキュリティ
個人開発
信頼性

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
Reading time
8 min read
Author
友田 陽大
Share

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

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.

Frequently asked questions

Why do payment notifications arrive more than once?
Because payment providers prioritise the notification definitely arriving. If the network is unstable, if your app is slow to respond, or if it returns a temporary error, the provider decides the notification may not have arrived and resends. This is at-least-once delivery, and it is documented behaviour in Stripe's official docs. It is not a fault.
What does idempotency mean?
It is the property of producing the same result however many times you perform an operation. Pressing a lift button five times still brings the lift once — the same idea. For payments you achieve it by storing the event ID from the notification and, when an already-processed ID arrives, doing nothing and returning a success response.
Will testing locally find a double-charge?
It will not. In local testing the notification arrives once, so the duplicate-handling branch never executes. The defect appears in production, specifically when the network is unstable or processing is congested. That it cannot be found by testing is what makes this problem so awkward.
What goes wrong if the amount comes from the browser?
Anyone can buy at any price by editing the request, because values sent from the browser are freely modifiable by the user. AI tends to write handlers that pass the screen's state straight through to the server, and because it behaves correctly under normal use it is easy to miss. The fix is to accept only a product ID and quantity and look the price up on the server.
Why is webhook signature verification necessary?
Because your webhook endpoint is on the public internet — anyone who knows the URL can send a request to it. Without signature verification, a third party can POST a fake 'payment succeeded' event and receive goods or credit for free. Use the provider's official verification and reject anything that fails without processing it.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Already live, or handling money or personal data?

An engineer reads your AI-built app and tells you what is actually dangerous

No coding knowledge required. I read the repository, list what to fix first in priority order, and hand back a report written for non-engineers plus fix instructions you can paste into your AI tool. I build production B2B SaaS with Claude Code myself, so I am not going to tell you that using AI was the mistake.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading