Skip to main content
RapidDev - Software Development Agency
stripe-guide

How to prevent duplicate charges with Stripe

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.

What you'll learn

  • What idempotency keys are and why they prevent duplicate charges
  • How to pass idempotency keys in the Stripe Node.js SDK
  • Best practices for generating unique, deterministic idempotency keys
  • How to handle retries safely with idempotent requests
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate6 min read10 minutesStripe API v2024-12+, Node.js 18+March 2026RapidDev Engineering Team
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.

Prerequisites

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

typescript
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
2
3async function createPayment(orderId, amount) {
4 const paymentIntent = await stripe.paymentIntents.create(
5 {
6 amount: amount,
7 currency: 'usd',
8 metadata: { order_id: orderId },
9 },
10 {
11 idempotencyKey: `payment_${orderId}`, // Unique per order
12 }
13 );
14
15 return paymentIntent;
16}

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.

typescript
1app.post('/create-checkout-session', async (req, res) => {
2 const { orderId } = req.body;
3
4 const session = await stripe.checkout.sessions.create(
5 {
6 mode: 'payment',
7 line_items: [{ price: 'price_xxx', quantity: 1 }],
8 success_url: 'https://yoursite.com/success',
9 cancel_url: 'https://yoursite.com/cancel',
10 metadata: { order_id: orderId },
11 },
12 {
13 idempotencyKey: `checkout_${orderId}`,
14 }
15 );
16
17 res.json({ url: session.url });
18});

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.

typescript
1const crypto = require('crypto');
2
3// Option 1: Use your order ID directly
4const key1 = `payment_${orderId}`;
5
6// Option 2: Hash multiple values for uniqueness
7const key2 = crypto
8 .createHash('sha256')
9 .update(`${userId}_${orderId}_${amount}`)
10 .digest('hex');
11
12// Option 3: UUID for non-retryable operations
13const { v4: uuidv4 } = require('uuid');
14const 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.

typescript
1async function createPaymentWithRetry(orderId, amount, maxRetries = 3) {
2 const idempotencyKey = `payment_${orderId}`;
3
4 for (let attempt = 1; attempt <= maxRetries; attempt++) {
5 try {
6 const paymentIntent = await stripe.paymentIntents.create(
7 { amount, currency: 'usd', metadata: { order_id: orderId } },
8 { idempotencyKey }
9 );
10 return paymentIntent;
11 } catch (err) {
12 if (attempt === maxRetries) throw err;
13 // Wait before retrying (exponential backoff)
14 await new Promise((r) => setTimeout(r, 1000 * attempt));
15 }
16 }
17}

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 working example

idempotent-payments.js
1const express = require('express');
2const crypto = require('crypto');
3const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
4
5const app = express();
6app.use(express.json());
7
8// Generate deterministic idempotency key
9function getIdempotencyKey(prefix, ...parts) {
10 const hash = crypto
11 .createHash('sha256')
12 .update(parts.join('_'))
13 .digest('hex')
14 .substring(0, 32);
15 return `${prefix}_${hash}`;
16}
17
18// Create PaymentIntent with idempotency
19app.post('/create-payment', async (req, res) => {
20 const { orderId, amount } = req.body;
21
22 if (!orderId || !amount) {
23 return res.status(400).json({ error: 'orderId and amount required' });
24 }
25
26 try {
27 const paymentIntent = await stripe.paymentIntents.create(
28 {
29 amount,
30 currency: 'usd',
31 automatic_payment_methods: { enabled: true },
32 metadata: { order_id: orderId },
33 },
34 {
35 idempotencyKey: getIdempotencyKey('pi', orderId),
36 }
37 );
38
39 res.json({ clientSecret: paymentIntent.client_secret });
40 } catch (err) {
41 if (err.type === 'StripeIdempotencyError') {
42 // Idempotency key reused with different parameters
43 return res.status(409).json({
44 error: 'Duplicate request with different parameters',
45 });
46 }
47 res.status(500).json({ error: err.message });
48 }
49});
50
51// Checkout with idempotency
52app.post('/create-checkout', async (req, res) => {
53 const { orderId } = req.body;
54
55 try {
56 const session = await stripe.checkout.sessions.create(
57 {
58 mode: 'payment',
59 line_items: [{
60 price_data: {
61 currency: 'usd',
62 product_data: { name: 'Widget' },
63 unit_amount: 2000,
64 },
65 quantity: 1,
66 }],
67 metadata: { order_id: orderId },
68 success_url: `${req.headers.origin}/success`,
69 cancel_url: `${req.headers.origin}/cancel`,
70 },
71 { idempotencyKey: getIdempotencyKey('cs', orderId) }
72 );
73
74 res.json({ url: session.url });
75 } catch (err) {
76 res.status(500).json({ error: err.message });
77 }
78});
79
80app.listen(4000, () => console.log('Server on port 4000'));

Common mistakes when preventing duplicate charges with Stripe

Why it's a problem: Using random UUIDs as idempotency keys without storing them

How to avoid: A random UUID changes on each retry, defeating the purpose. Use a deterministic key based on your order ID or transaction identifier.

Why it's a problem: Reusing the same idempotency key with different parameters

How to avoid: Stripe throws a StripeIdempotencyError if you send the same key with different request parameters. Each unique operation needs a unique key.

Why it's a problem: Not using idempotency keys at all

How to avoid: Without idempotency keys, network retries, load balancer retries, or user double-clicks can create duplicate charges. Always use them for payment-creating API calls.

Why it's a problem: Assuming idempotency keys last forever

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

Write a Node.js function that creates a Stripe PaymentIntent with an idempotency key derived from the order ID. Include retry logic with exponential backoff and handle StripeIdempotencyError. Explain why the idempotency key must be deterministic.

Stripe Prompt

Add idempotency protection to my Stripe payment endpoints. Generate deterministic idempotency keys from order IDs, pass them to stripe.paymentIntents.create() and stripe.checkout.sessions.create(), and handle StripeIdempotencyError gracefully.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.