Short answer
If it is one order, your checkout created a second payment for it. This can happen when the customer presses Pay twice, reloads the payment page, or your server retries a request. Refund the duplicate in the Stripe Dashboard, then fix the cause: create one PaymentIntent per order and reuse it, send an idempotency key based on the order ID with every create request, and disable the Pay button while a payment is in progress.
Rather not fix it yourself? App Fix Ticket starts with a fixed-price diagnosis, then a fixed price for the fix before any fix work.
How the second charge happens, and the fix
01
A double click or a reload starts a new payment
“If the checkout process is interrupted and resumes later, attempt to reuse the same PaymentIntent instead of creating a new one.” Stripe, Payment Intents API (Best practices)
If your code creates a new PaymentIntent each time the payment page loads or the Pay button is pressed, a second click or a refresh can start a second payment. Create the PaymentIntent once per order, save its ID with the cart or order, and fetch that same one again when the customer comes back. Disable the Pay button until the first attempt returns.
02
Your server retries without an idempotency key
“The API supports idempotency for safely retrying requests without accidentally performing the same operation twice.” Stripe, Idempotent requests
A timeout or a dropped connection can make your server, or a job queue, send the same create request again. Without an idempotency key Stripe treats the retry as a new payment. With one, a retry that repeats the same key gets the first request’s result back.
const paymentIntent = await stripe.paymentIntents.create( { amount: order.amountInCents, currency: 'usd', automatic_payment_methods: { enabled: true } }, { idempotencyKey: `order-${order.id}` } );03
The key changes on every attempt
“This key is typically based on the ID that you associate with the cart or customer session in your application.” Stripe, Payment Intents API (Best practices)
An idempotency key only helps when the retry sends the same key. A random value made fresh on each click or each request gives every attempt its own key, so nothing is caught. Build the key from your own order or cart ID.
Checked against the vendor’s docs on : Stripe docs: Payment Intents API; Stripe API reference: Idempotent requests.
Rather hand it over?
Fixed scope, fixed price, paid upfront. The work happens in writing; no call needed.
- from $450App Fix TicketOne reproducible bug in an existing mobile or web app, diagnosed and fixed.See scope and start
- $950Stripe Billing Fix and SetupSubscriptions, webhooks, customer portal and failed payments set up or fixed, tested in Stripe test mode.See scope and start
- from $450AI-Built App RescueA Lovable, Bolt or Cursor app checked end to end in 3 days, then its worst problems fixed at a fixed price.No issues worth fixing, no charge. If the Launch Check finds nothing rated medium or higher, Simon refunds the full $450. You keep the report either way. This applies to the first 10 Launch Checks bought after October 30, 2026.See scope and start
“Very responsive, attentive to fixing bugs, and completed the project as needed! Thank you!”
Related: Stripe webhook 200 but database not updated · Paid on Stripe but account not upgraded · Stripe dispute or chargeback · Refund a disputed Stripe payment