Short answer
The PaymentIntent was already paid, so Stripe refuses to confirm it again. Check the Dashboard first: if the payment shows as succeeded, the customer was charged once and nothing needs refunding. Then find what confirmed it twice: a pay button that stays clickable, a page that confirms again on reload, or a retry after a timeout. Fulfil the order from the payment_intent.succeeded webhook, not from the browser.
Why it was confirmed twice, and the fix
01
The payment already went through
“The PaymentIntent’s state was incompatible with the operation you were trying to perform.” Stripe, Error codes
Stripe returns payment_intent_unexpected_state when the PaymentIntent isn’t in a state that allows the request. A status of succeeded is final. Look the PaymentIntent up in the Dashboard by its pi_ ID before you change any code, and tell the customer their payment is safe if it shows as succeeded.
02
The pay button sends a second confirm
“A PaymentIntent with a succeeded status means that the corresponding payment flow is complete.” Stripe, How Payment Intents and Setup Intents work
A double click, a slow network or a button that isn’t disabled while the first request runs can call confirmPayment twice for the same PaymentIntent. Disable the button as soon as it’s pressed, and only re-enable it if the first attempt fails. A page that confirms again when it reloads, or when a customer comes back from a 3D Secure check, does the same. Also check that you don’t confirm in both places: creating the PaymentIntent with confirm: true on the server and then calling confirmPayment in the browser.
03
The app reuses an old PaymentIntent for a new order
“Each PaymentIntent typically correlates with a single shopping cart or customer session in your application.” Stripe, The Payment Intents API
If the app stores one PaymentIntent ID and confirms it again for the next purchase, the second purchase fails with this error. Stripe pairs a PaymentIntent with one cart or session: reuse it while that checkout is still open, and create a new one once it has succeeded.
04
Retries create or confirm twice
“Remember to provide an idempotency key to prevent the creation of duplicate PaymentIntents for the same purchase.” Stripe, The Payment Intents API
When a server request times out and the app sends it again, use an idempotency key based on the order or cart ID, so the retry returns the first result instead of doing the work twice.
05
Fulfil from the webhook, once per event
“After the client confirms the payment, it’s a best practice for your server to monitor webhooks to detect when the payment successfully completes or fails.” Stripe, The Payment Intents API
If the browser is what unlocks the order, a failed second confirm can leave a paid customer with nothing. Fulfil from the payment_intent.succeeded webhook on your server, and record each event ID so a repeated event doesn’t fulfil the order twice.
Checked against the vendor’s docs on : Stripe docs: Error codes; Stripe docs: How Payment Intents and Setup Intents work; Stripe docs: The Payment Intents API; Stripe API reference: Idempotent requests; Stripe docs: Receive Stripe events in your webhook endpoint.
Rather hand it over?
Fixed scope, fixed price, paid upfront. The work happens in writing; no call needed.
- $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 $450App Fix TicketOne reproducible bug in an existing mobile or web app, diagnosed and fixed.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.See scope and start
Related: Stripe paid but account not upgraded · Stripe webhook 200 but database not updated · Stripe subscription incomplete, requires action · Stripe Invalid API Key provided