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:
- The customer presses "buy"
- They enter card details on the provider's screen and the payment completes
- The provider sends your app a notification saying "this was paid" — a webhook
- 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
- The provider's notification contains an event ID — a unique string, often starting with
evt_ - Before processing, check the database for whether that ID has already been handled
- If it has, do nothing and respond "received"
- 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:
- An attacker finds your webhook URL (the shape is often guessable)
- They construct data in the form of "payment succeeded" and send it
- 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
- Open the purchase screen and press F12 for the developer tools "Network" tab
- Press the buy button
- 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.
- Stripe dashboard → Developers → Webhooks
- Pick a past event
- Run "Resend"
- 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.