The button looked stuck, so the buyer clicked it again. Your server did exactly what it was told both times: it created two PaymentIntents (or two subscriptions), and both got paid. Stripe isn't wrong here. Your app asked for two payments.
customer, which will "Only return charges for the customer specified by this customer ID" (Stripe API: List all charges).paymentIntents.create, checkout.sessions.create or subscriptions.create. If there's no idempotency key and nothing checks for an existing open payment, a second click makes a second one.open, send the buyer to its URL instead of making a new one.customer when you create the session so the check can find them.// Server: create (or reuse) ONE PaymentIntent per order.
async function paymentForOrder(order) {
if (order.paymentIntentId) {
const pi = await stripe.paymentIntents.retrieve(order.paymentIntentId);
if (pi.status !== 'canceled') return pi; // reuse it, don't make another
}
const pi = await stripe.paymentIntents.create(
{ amount: order.amountCents, currency: 'usd', customer: order.stripeCustomerId,
metadata: { order_id: order.id } },
{ idempotencyKey: 'pi-order-' + order.id } // same order => same PaymentIntent
);
await db.orders.update(order.id, { paymentIntentId: pi.id });
return pi;
}
Two details from Stripe's docs matter here. The idempotency layer "compares incoming parameters to those of the original request and errors if they’re not the same", so if the cart changes, make a new order (and a new key) instead of reusing the old one. And keys can be removed "after they’re at least 24 hours old" (source), so the stored PaymentIntent ID, not the key, is what protects an order that's resumed days later. If a request fails with a network error, retry "with the same idempotency key and parameters within the first 24 hours"; after a 500, "Don’t retry with a new idempotency key until you’ve resolved the original outcome" (Stripe docs: Low-level error handling).
Idempotent fulfillment (duplicate events guide) stops you from unlocking twice for one payment. It doesn't notice two separate payments for the same thing, because those are two different sessions or PaymentIntents. Add one check: when a payment arrives for something the user already owns, don't fail silently. Log it and flag it for a refund.
Refund the second payment by its PaymentIntent or Charge. The refund API accepts reason with "possible values are duplicate, fraudulent, and requested_by_customer" (Stripe API: Create a refund). Use duplicate. For a double subscription, cancel the extra one too, or it bills again next month.
Customers must never be charged twice for one order. 1) When the buyer clicks Pay, create or load an order row first. If the order already has a Stripe PaymentIntent or Checkout Session that is still usable, return it instead of creating a new one. 2) Pass an idempotency key derived from the order ID (for example 'pi-order-' + order.id) on every Stripe create call, and never generate a random key per request. 3) For subscriptions, pass the existing Stripe customer ID to Checkout and refuse to start a new subscription if the customer already has an active one; send them to the billing portal instead. 4) Disable the Pay button after the first click. 5) In the webhook, if a payment arrives for something the user already owns, log it for a refund instead of granting access again.
In a sandbox, double-click Pay as fast as you can, or open the checkout in two tabs and pay in both. You should end up with one payment, or with the second tab showing an expired or already-paid session. Then subscribe with a test customer who already has a subscription and confirm Checkout sends them to manage it instead.
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkUsually because your app created two payments: two PaymentIntents from a double click, two subscriptions, or a retried API call with no idempotency key. Stripe processes each request it receives.
Only if the key comes from the order or cart ID. A random key per request gives every click a new key, so Stripe treats them as separate payments.
Pass the existing customer to Checkout and turn on Stripe's setting that sends customers with an active subscription to the customer portal or your site instead.
Create a refund for the second PaymentIntent or Charge with reason set to duplicate. For a second subscription, also cancel it so it doesn't renew.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-11-01. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.