# How to prevent duplicate charges with Stripe

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 10 minutes
- Compatibility: Stripe API v2024-12+, Node.js 18+
- Last updated: March 2026

## TL;DR

Prevent duplicate charges in Stripe by passing an idempotency key with every API request. Use the Idempotency-Key header (or the idempotencyKey option in the Node SDK) with a unique value like your order ID. Stripe returns the same result for duplicate requests within 24 hours instead of creating a new charge.

## Idempotency Keys: Your Shield Against Duplicate Charges

Network failures, timeouts, and retries can cause the same Stripe API call to execute multiple times. Without protection, this means double-charging your customers. Stripe's idempotency key system solves this: attach a unique key to each request, and if Stripe receives a duplicate request with the same key within 24 hours, it returns the original response instead of creating a new payment. This is essential for any production payment integration.

## Before you start

- Node.js 18+ with the stripe npm package installed
- A working PaymentIntent or Checkout Session endpoint
- Basic understanding of HTTP request retries and network failures

## Step-by-step guide

### 1. Add an idempotency key to a PaymentIntent

Pass the idempotencyKey option as the second argument to any Stripe API call. Use a value unique to the operation — like your order ID.

```
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

async function createPayment(orderId, amount) {
  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount: amount,
      currency: 'usd',
      metadata: { order_id: orderId },
    },
    {
      idempotencyKey: `payment_${orderId}`, // Unique per order
    }
  );

  return paymentIntent;
}
```

**Expected result:** Calling createPayment('order_123', 2000) twice returns the same PaymentIntent both times — no duplicate charge.

### 2. Use idempotency keys with Checkout Sessions

Apply the same pattern to Checkout Session creation to prevent creating duplicate sessions from button double-clicks.

```
app.post('/create-checkout-session', async (req, res) => {
  const { orderId } = req.body;

  const session = await stripe.checkout.sessions.create(
    {
      mode: 'payment',
      line_items: [{ price: 'price_xxx', quantity: 1 }],
      success_url: 'https://yoursite.com/success',
      cancel_url: 'https://yoursite.com/cancel',
      metadata: { order_id: orderId },
    },
    {
      idempotencyKey: `checkout_${orderId}`,
    }
  );

  res.json({ url: session.url });
});
```

**Expected result:** Double-clicking the checkout button returns the same Checkout Session URL instead of creating a new one.

### 3. Generate proper idempotency keys

Idempotency keys should be deterministic (same key for the same operation) and unique (different key for different operations). Use your internal order or transaction ID as the base.

```
const crypto = require('crypto');

// Option 1: Use your order ID directly
const key1 = `payment_${orderId}`;

// Option 2: Hash multiple values for uniqueness
const key2 = crypto
  .createHash('sha256')
  .update(`${userId}_${orderId}_${amount}`)
  .digest('hex');

// Option 3: UUID for non-retryable operations
const { v4: uuidv4 } = require('uuid');
const key3 = uuidv4(); // Only use this if you store it for retries
```

**Expected result:** The same operation always produces the same idempotency key, enabling safe retries.

### 4. Implement safe retries

With idempotency keys, you can safely retry failed requests. If the first request actually succeeded but the response was lost, the retry returns the original result.

```
async function createPaymentWithRetry(orderId, amount, maxRetries = 3) {
  const idempotencyKey = `payment_${orderId}`;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const paymentIntent = await stripe.paymentIntents.create(
        { amount, currency: 'usd', metadata: { order_id: orderId } },
        { idempotencyKey }
      );
      return paymentIntent;
    } catch (err) {
      if (attempt === maxRetries) throw err;
      // Wait before retrying (exponential backoff)
      await new Promise((r) => setTimeout(r, 1000 * attempt));
    }
  }
}
```

**Expected result:** The function retries up to 3 times with the same idempotency key. Even if a request succeeded but timed out, the retry safely returns the original PaymentIntent.

## Complete code example

File: `idempotent-payments.js`

```javascript
const express = require('express');
const crypto = require('crypto');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();
app.use(express.json());

// Generate deterministic idempotency key
function getIdempotencyKey(prefix, ...parts) {
  const hash = crypto
    .createHash('sha256')
    .update(parts.join('_'))
    .digest('hex')
    .substring(0, 32);
  return `${prefix}_${hash}`;
}

// Create PaymentIntent with idempotency
app.post('/create-payment', async (req, res) => {
  const { orderId, amount } = req.body;

  if (!orderId || !amount) {
    return res.status(400).json({ error: 'orderId and amount required' });
  }

  try {
    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount,
        currency: 'usd',
        automatic_payment_methods: { enabled: true },
        metadata: { order_id: orderId },
      },
      {
        idempotencyKey: getIdempotencyKey('pi', orderId),
      }
    );

    res.json({ clientSecret: paymentIntent.client_secret });
  } catch (err) {
    if (err.type === 'StripeIdempotencyError') {
      // Idempotency key reused with different parameters
      return res.status(409).json({
        error: 'Duplicate request with different parameters',
      });
    }
    res.status(500).json({ error: err.message });
  }
});

// Checkout with idempotency
app.post('/create-checkout', async (req, res) => {
  const { orderId } = req.body;

  try {
    const session = await stripe.checkout.sessions.create(
      {
        mode: 'payment',
        line_items: [{
          price_data: {
            currency: 'usd',
            product_data: { name: 'Widget' },
            unit_amount: 2000,
          },
          quantity: 1,
        }],
        metadata: { order_id: orderId },
        success_url: `${req.headers.origin}/success`,
        cancel_url: `${req.headers.origin}/cancel`,
      },
      { idempotencyKey: getIdempotencyKey('cs', orderId) }
    );

    res.json({ url: session.url });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.listen(4000, () => console.log('Server on port 4000'));
```

## Common mistakes

- **Using random UUIDs as idempotency keys without storing them** — undefined Fix: A random UUID changes on each retry, defeating the purpose. Use a deterministic key based on your order ID or transaction identifier.
- **Reusing the same idempotency key with different parameters** — undefined Fix: Stripe throws a StripeIdempotencyError if you send the same key with different request parameters. Each unique operation needs a unique key.
- **Not using idempotency keys at all** — undefined Fix: Without idempotency keys, network retries, load balancer retries, or user double-clicks can create duplicate charges. Always use them for payment-creating API calls.
- **Assuming idempotency keys last forever** — undefined Fix: Keys expire after 24 hours. If you need to retry an operation after 24 hours, generate a new order ID or accept that a new payment will be created.

## Best practices

- Use idempotency keys on all payment-creating API calls (PaymentIntents, Checkout Sessions, charges)
- Generate deterministic keys from your internal order or transaction IDs
- Handle StripeIdempotencyError when the same key is sent with different parameters
- Implement exponential backoff when retrying failed requests
- Disable the frontend submit button immediately to reduce duplicate requests at the source
- Test idempotency by calling the same endpoint twice with the same order ID and verifying only one payment is created

## Frequently asked questions

### How long do idempotency keys last?

Idempotency keys expire after 24 hours. Within that window, sending the same key returns the cached response. After expiration, the same key creates a new request.

### Do I need idempotency keys for read operations like retrieve?

No. Idempotency keys are only needed for write operations that create or modify resources (create, update). Read operations (retrieve, list) are naturally idempotent.

### What happens if I send the same key with different parameters?

Stripe returns a StripeIdempotencyError (HTTP 400). This prevents accidental misuse where the same key would return stale data for a different operation.

### Should I use idempotency keys in test mode?

Yes. Test with idempotency keys in test mode (card 4242 4242 4242 4242) to verify your retry logic works correctly before going live.

### Can RapidDev help with payment reliability?

Yes. RapidDev can audit your payment integration for duplicate charge risks, implement proper idempotency, set up webhook-based reconciliation, and build monitoring for failed payment attempts.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-prevent-duplicate-charges-with-stripe-api
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-prevent-duplicate-charges-with-stripe-api
