Learn how to prevent duplicate charges with Stripe API using idempotency keys, order checks, webhooks, and best practices for secure, reliable payment processing.
Book a call with an Expert
Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.
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.
When it comes to serving you, we sweat the little things. That’s why our work makes a big impact.