/stripe-guides

How to prevent duplicate charges with Stripe API?

Learn how to prevent duplicate charges with Stripe API using idempotency keys, order checks, webhooks, and best practices for secure, reliable payment processing.

Matt Graham, CEO of Rapid Developers

Book a call with an Expert

Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.

Book a free consultation

How to prevent duplicate charges with Stripe API?

How to Prevent Duplicate Charges with Stripe API: A Comprehensive Tutorial

 

Introduction

 

Preventing duplicate charges is crucial for maintaining customer trust and avoiding the hassle of processing refunds. Stripe provides several mechanisms to help prevent duplicate charges. This tutorial covers comprehensive strategies to implement these safeguards in your payment processing system.

 

Step 1: Understand Idempotency Keys

 

Idempotency keys are unique identifiers that allow you to retry API requests without performing the same operation twice. When you make a request with an idempotency key, Stripe ensures that only one charge is created regardless of how many times the request is sent.

 

Step 2: Implement Idempotency Keys in Your Charge Requests

 

For every charge request, generate a unique idempotency key and include it in the request headers:


// Node.js implementation with Stripe
const stripe = require('stripe')('sk_test_your\_key');
const uuid = require('uuid');

async function createCharge(amount, currency, source, description) {
  const idempotencyKey = uuid.v4(); // Generate a unique key
  
  try {
    const charge = await stripe.charges.create(
      {
        amount: amount,
        currency: currency,
        source: source,
        description: description
      },
      {
        idempotency\_key: idempotencyKey // Include the key in the request
      }
    );
    
    return charge;
  } catch (error) {
    console.error('Error creating charge:', error);
    throw error;
  }
}

 

Step 3: Store and Reuse Idempotency Keys

 

For improved reliability, store the idempotency key with the order information so you can use the same key for retries:


// Example with database storage
const db = require('./database'); // Your database module

async function processOrderPayment(orderId, amount, currency, source) {
  // Check if we already have an idempotency key for this order
  let idempotencyKey = await db.getIdempotencyKeyForOrder(orderId);
  
  if (!idempotencyKey) {
    // If no key exists, generate and store a new one
    idempotencyKey = uuid.v4();
    await db.saveIdempotencyKeyForOrder(orderId, idempotencyKey);
  }
  
  try {
    const charge = await stripe.charges.create(
      {
        amount: amount,
        currency: currency,
        source: source,
        description: `Payment for order ${orderId}`,
        metadata: { order\_id: orderId } // Good practice to include order ID in metadata
      },
      {
        idempotency\_key: idempotencyKey
      }
    );
    
    await db.markOrderAsPaid(orderId, charge.id);
    return charge;
  } catch (error) {
    console.error(`Error processing payment for order ${orderId}:`, error);
    throw error;
  }
}

 

Step 4: Implement Order Status Checking

 

Before attempting to charge a customer, check if the order has already been paid for:


async function safelyProcessPayment(orderId, amount, currency, source) {
  // First check if order exists and is not yet paid
  const order = await db.getOrder(orderId);
  
  if (!order) {
    throw new Error(`Order ${orderId} not found`);
  }
  
  if (order.paid) {
    console.log(`Order ${orderId} already paid, not charging again`);
    return { alreadyPaid: true, chargeId: order.chargeId };
  }
  
  // Proceed with payment processing
  return await processOrderPayment(orderId, amount, currency, source);
}

 

Step 5: Use Stripe's Payment Intents API for Modern Integration

 

For more complex payment flows, use Stripe's Payment Intents API which provides better handling of payment states:


async function createAndConfirmPaymentIntent(orderId, amount, currency, paymentMethodId) {
  const idempotencyKey = uuid.v4();
  
  try {
    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount: amount,
        currency: currency,
        payment\_method: paymentMethodId,
        confirmation\_method: 'manual',
        confirm: true, // Confirm the payment immediately
        metadata: { order\_id: orderId },
        description: `Payment for order ${orderId}`
      },
      {
        idempotency\_key: idempotencyKey
      }
    );
    
    await db.savePaymentIntentForOrder(orderId, paymentIntent.id, idempotencyKey);
    
    if (paymentIntent.status === 'succeeded') {
      await db.markOrderAsPaid(orderId, paymentIntent.id);
    }
    
    return paymentIntent;
  } catch (error) {
    console.error(`Error creating payment intent for order ${orderId}:`, error);
    throw error;
  }
}

 

Step 6: Implement Webhook Handling for Payment Status Updates

 

Set up webhooks to receive real-time updates about payment status changes:


// Express.js webhook handler example
const express = require('express');
const app = express();

// This is your Stripe CLI webhook secret for testing
const endpointSecret = 'whsec_your_secret';

app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  
  let event;
  
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    console.error(`Webhook Error: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  
  // Handle the event
  switch (event.type) {
    case 'payment\_intent.succeeded':
      const paymentIntent = event.data.object;
      const orderId = paymentIntent.metadata.order\_id;
      
      // Update order status in your database
      await db.markOrderAsPaid(orderId, paymentIntent.id);
      console.log(`PaymentIntent for order ${orderId} was successful!`);
      break;
      
    case 'payment_intent.payment_failed':
      const failedPaymentIntent = event.data.object;
      const failedOrderId = failedPaymentIntent.metadata.order\_id;
      
      await db.markOrderPaymentFailed(failedOrderId, failedPaymentIntent.id, 
                                      failedPaymentIntent.last_payment_error?.message);
      console.log(`Payment failed for order ${failedOrderId}: ${failedPaymentIntent.last_payment_error?.message}`);
      break;
      
    default:
      console.log(`Unhandled event type ${event.type}`);
  }
  
  // Return a 200 response to acknowledge receipt of the event
  res.send();
});

app.listen(3000, () => console.log('Running on port 3000'));

 

Step 7: Implement Client-Side Safeguards

 

Add front-end protection to prevent users from submitting payments multiple times:


// JavaScript for disabling payment button after click
document.getElementById('paymentForm').addEventListener('submit', function(event) {
  const submitButton = document.getElementById('submitPaymentButton');
  
  // Disable the button
  submitButton.disabled = true;
  submitButton.textContent = 'Processing...';
  
  // You can also use a flag in sessionStorage to prevent multiple submissions
  if (sessionStorage.getItem('paymentSubmitted') === 'true') {
    event.preventDefault();
    alert('Your payment is already being processed. Please wait.');
    return false;
  }
  
  sessionStorage.setItem('paymentSubmitted', 'true');
  
  // Optional: Set a timeout to re-enable the button if the request takes too long
  setTimeout(function() {
    submitButton.disabled = false;
    submitButton.textContent = 'Pay Now';
    sessionStorage.removeItem('paymentSubmitted');
  }, 30000); // 30 seconds timeout
});

 

Step 8: Implement Database Transactions and Locks

 

Use database transactions to ensure data consistency when updating order statuses:


// Example using a SQL database with transaction
async function processPaymentWithTransaction(orderId, amount, currency, source) {
  const connection = await db.getConnection();
  
  try {
    await connection.beginTransaction();
    
    // Get order with a lock to prevent concurrent processing
    const [orders] = await connection.query(
      'SELECT \* FROM orders WHERE id = ? FOR UPDATE', 
      [orderId]
    );
    
    if (orders.length === 0) {
      throw new Error(`Order ${orderId} not found`);
    }
    
    const order = orders[0];
    
    if (order.paid) {
      await connection.commit();
      return { alreadyPaid: true, chargeId: order.charge\_id };
    }
    
    // Get or create idempotency key
    let idempotencyKey = order.idempotency\_key;
    if (!idempotencyKey) {
      idempotencyKey = uuid.v4();
      await connection.query(
        'UPDATE orders SET idempotency\_key = ? WHERE id = ?',
        [idempotencyKey, orderId]
      );
    }
    
    // Process the payment with Stripe
    const charge = await stripe.charges.create(
      {
        amount: amount,
        currency: currency,
        source: source,
        description: `Payment for order ${orderId}`,
        metadata: { order\_id: orderId }
      },
      {
        idempotency\_key: idempotencyKey
      }
    );
    
    // Update order status
    await connection.query(
      'UPDATE orders SET paid = 1, charge\_id = ? WHERE id = ?',
      [charge.id, orderId]
    );
    
    await connection.commit();
    return charge;
  } catch (error) {
    await connection.rollback();
    console.error(`Error processing payment for order ${orderId}:`, error);
    throw error;
  } finally {
    connection.release();
  }
}

 

Step 9: Implement Retry Logic with Exponential Backoff

 

For handling temporary network issues, implement a retry mechanism with exponential backoff:


async function chargeWithRetry(chargeFunction, maxRetries = 3) {
  let retries = 0;
  let lastError;
  
  while (retries < maxRetries) {
    try {
      return await chargeFunction();
    } catch (error) {
      lastError = error;
      
      // Only retry on network errors or Stripe server errors (5xx)
      if (error.type === 'StripeConnectionError' || 
          (error.statusCode && error.statusCode >= 500)) {
        
        retries++;
        const waitTime = Math.pow(2, retries) \* 1000; // Exponential backoff
        console.log(`Retrying after ${waitTime}ms (attempt ${retries}/${maxRetries})`);
        
        // Wait before retrying
        await new Promise(resolve => setTimeout(resolve, waitTime));
      } else {
        // For other types of errors, don't retry
        break;
      }
    }
  }
  
  // If we get here, all retries failed
  console.error(`All ${maxRetries} retry attempts failed`);
  throw lastError;
}

// Usage example
async function processOrderWithRetries(orderId, amount, currency, source) {
  return await chargeWithRetry(() => {
    return processOrderPayment(orderId, amount, currency, source);
  });
}

 

Step 10: Implement Regular Reconciliation Processes

 

Set up a regular reconciliation process to catch any discrepancies between your system and Stripe:


async function reconcilePayments(startDate, endDate) {
  console.log(`Starting reconciliation for period ${startDate} to ${endDate}`);
  
  // Fetch all charges from Stripe for the time period
  let stripeCharges = [];
  let hasMore = true;
  let startingAfter = null;
  
  while (hasMore) {
    const chargesResponse = await stripe.charges.list({
      created: {
        gte: Math.floor(new Date(startDate).getTime() / 1000),
        lte: Math.floor(new Date(endDate).getTime() / 1000)
      },
      limit: 100,
      starting\_after: startingAfter
    });
    
    stripeCharges = stripeCharges.concat(chargesResponse.data);
    hasMore = chargesResponse.has\_more;
    
    if (hasMore && chargesResponse.data.length > 0) {
      startingAfter = chargesResponse.data[chargesResponse.data.length - 1].id;
    }
  }
  
  console.log(`Found ${stripeCharges.length} charges in Stripe`);
  
  // Fetch orders from your database
  const orders = await db.getOrdersInDateRange(startDate, endDate);
  console.log(`Found ${orders.length} orders in database`);
  
  // Map Stripe charges by order ID for easier lookup
  const stripeChargesByOrderId = {};
  stripeCharges.forEach(charge => {
    if (charge.metadata && charge.metadata.order\_id) {
      if (!stripeChargesByOrderId[charge.metadata.order\_id]) {
        stripeChargesByOrderId[charge.metadata.order\_id] = [];
      }
      stripeChargesByOrderId[charge.metadata.order\_id].push(charge);
    }
  });
  
  // Check for discrepancies
  const discrepancies = [];
  
  for (const order of orders) {
    // Skip orders that shouldn't be paid yet
    if (!order.should_be_paid) continue;
    
    const stripeChargesForOrder = stripeChargesByOrderId[order.id] || [];
    
    if (order.paid && stripeChargesForOrder.length === 0) {
      // Order marked as paid in your system but no charge in Stripe
      discrepancies.push({
        type: 'missing_stripe_charge',
        orderId: order.id,
        amount: order.amount
      });
    } else if (!order.paid && stripeChargesForOrder.length > 0) {
      // Order not marked as paid but has charges in Stripe
      discrepancies.push({
        type: 'missing_payment_record',
        orderId: order.id,
        stripeCharges: stripeChargesForOrder
      });
    } else if (stripeChargesForOrder.length > 1) {
      // Multiple charges for same order - potential duplicate
      discrepancies.push({
        type: 'multiple\_charges',
        orderId: order.id,
        stripeCharges: stripeChargesForOrder
      });
    } else if (stripeChargesForOrder.length === 1) {
      // Check amount matches
      const stripeAmount = stripeChargesForOrder[0].amount;
      if (stripeAmount !== order.amount) {
        discrepancies.push({
          type: 'amount\_mismatch',
          orderId: order.id,
          orderAmount: order.amount,
          stripeAmount: stripeAmount
        });
      }
    }
  }
  
  // Log results
  if (discrepancies.length > 0) {
    console.error(`Found ${discrepancies.length} discrepancies:`);
    console.error(JSON.stringify(discrepancies, null, 2));
    
    // You could send an alert email here
    await sendAlertEmail('Payment Reconciliation Discrepancies', 
                         `Found ${discrepancies.length} discrepancies in payment reconciliation.`,
                         { discrepancies });
  } else {
    console.log('No discrepancies found. All payments reconciled successfully.');
  }
  
  return {
    stripeChargesCount: stripeCharges.length,
    ordersCount: orders.length,
    discrepancies: discrepancies
  };
}

 

Conclusion

 

Preventing duplicate charges requires a multi-layered approach. By implementing idempotency keys, proper order status tracking, database locking, and regular reconciliation processes, you can minimize the risk of charging customers multiple times for the same purchase. These practices not only protect your customers but also save your business from the administrative overhead of processing refunds and addressing customer complaints.

Remember to thoroughly test your payment system in Stripe's test environment before deploying to production, and consider implementing logging of all payment-related operations for easier debugging.

Want to explore opportunities to work with us?

Connect with our team to unlock the full potential of no-code solutions with a no-commitment consultation!

Book a Free Consultation

Client trust and success are our top priorities

When it comes to serving you, we sweat the little things. That’s why our work makes a big impact.

Rapid Dev was an exceptional project management organization and the best development collaborators I've had the pleasure of working with. They do complex work on extremely fast timelines and effectively manage the testing and pre-launch process to deliver the best possible product. I'm extremely impressed with their execution ability.

CPO, Praction - Arkady Sokolov

May 2, 2023

Working with Matt was comparable to having another co-founder on the team, but without the commitment or cost. He has a strategic mindset and willing to change the scope of the project in real time based on the needs of the client. A true strategic thought partner!

Co-Founder, Arc - Donald Muir

Dec 27, 2022

Rapid Dev are 10/10, excellent communicators - the best I've ever encountered in the tech dev space. They always go the extra mile, they genuinely care, they respond quickly, they're flexible, adaptable and their enthusiasm is amazing.

Co-CEO, Grantify - Mat Westergreen-Thorne

Oct 15, 2022

Rapid Dev is an excellent developer for no-code and low-code solutions.
We’ve had great success since launching the platform in November 2023. In a few months, we’ve gained over 1,000 new active users. We’ve also secured several dozen bookings on the platform and seen about 70% new user month-over-month growth since the launch.

Co-Founder, Church Real Estate Marketplace - Emmanuel Brown

May 1, 2024 

Matt’s dedication to executing our vision and his commitment to the project deadline were impressive. 
This was such a specific project, and Matt really delivered. We worked with a really fast turnaround, and he always delivered. The site was a perfect prop for us!

Production Manager, Media Production Company - Samantha Fekete

Sep 23, 2022