Guides · Free check · $9 fix pack

Guides

The customer clicked Pay twice. Stripe charged them twice.

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.

Where the second charge comes from

How to check in two minutes

  1. In the Stripe Dashboard, open the customer and look at their payments. Two successful payments for the same amount seconds apart is the double-click pattern. Two subscriptions on one customer is the double-subscribe pattern.
  2. Or list them with the API: list charges filtered by customer, which will "Only return charges for the customer specified by this customer ID" (Stripe API: List all charges).
  3. In your code, find where you call 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.

The fix

  1. Send an idempotency key based on the order, not on the click. Stripe: "When creating or updating an object, use an idempotency key. Then, if a connection error occurs, you can safely repeat the request without risk of creating a second object or performing the update twice" (Stripe API: Idempotent requests). For PaymentIntents Stripe adds: "provide an idempotency key to prevent the creation of duplicate PaymentIntents for the same purchase. This key is typically based on the ID that you associate with the cart or customer session in your application" (Stripe docs: Payment Intents API). A random key per request does nothing for double clicks, because each click gets a new key.
  2. Reuse what you already created. Stripe's advice is to "reuse the same PaymentIntent instead of creating a new one" when checkout resumes, and to store its ID "on the customer’s shopping cart or session" (source). The same works for Checkout: save the Session ID on the order, and if it's still open, send the buyer to its URL instead of making a new one.
  3. Close old sessions you won't use. If you do create a new Checkout Session for an order, expire the old one. After a session expires, "a customer can’t complete a Checkout Session" (Stripe API: Expire a Checkout Session). Sessions otherwise stay open "24 hours from creation" by default (Stripe API: Create a Checkout Session).
  4. One subscription per customer. Stripe Checkout can send customers who already have an active subscription to the customer portal or your site instead of letting them subscribe again. Stripe "uses either the Customer object (if you provide it in the Checkout Session) or the email address to detect if a customer already has an active subscription" (Stripe docs: Limit customers to one subscription). Pass the existing customer when you create the session so the check can find them.
  5. Disable the button after the first click. Do it, but don't rely on it. A slow network, a second tab or a retry still gets through, so the server-side steps above are the actual fix.
// 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).

The webhook side

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 duplicate

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.

Prompt for your AI builder

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.

Check it 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.

Check yours in 10 seconds (free).

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 check

FAQ

Why did Stripe charge my customer twice?

Usually 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.

Does an idempotency key stop double clicks?

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.

How do I stop customers subscribing twice?

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.

How do I refund a duplicate Stripe charge?

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.

Sources

More guides

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.