The problem — A network timeout leaves you unsure if a charge happened
You've built a payment flow. A customer clicks "Pay now," your backend calls the Stripe API to create a payment intent, and then... the network drops. Your code times out. You retry the request a moment later, but now you face a question that keeps you awake at night: did the first request succeed before the connection died, or did it genuinely fail?
Without idempotency, retrying that request is dangerous. A second identical call to paymentIntents.create() will create a second payment intent, potentially charging the customer twice. You might catch it manually during reconciliation, or worse, you might not. The customer sees two charges, support tickets flood in, and your fraud rate spikes.
This scenario is why Stripe idempotency keys for Payments & Stripe developers exist. They're not optional niceties—they're a core safety mechanism that lets you retry failed requests without duplicating charges, subscriptions, or refunds. In production, network timeouts, DNS failures, and connection resets happen regularly. Your payment system needs to handle them gracefully.
Send a key — The same key returns the same result
An idempotency key is a unique string you provide when making a Stripe API call. If you send the same key twice, Stripe returns the same result both times, even if the second request arrives moments later.
Here's how it works in practice:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const orderId = 'order-12345';
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2999, // $29.99 in cents
currency: 'usd',
customer: 'cus_abc123',
metadata: { orderId },
},
{
idempotencyKey: `order-${orderId}`,
}
);
console.log(paymentIntent.id); // pi_abc123xyz
The first time you send this request with the key order-12345, Stripe creates the payment intent and caches the result. If your network dies mid-response and your client retries with the same key, Stripe instantly returns the cached payment intent with ID pi_abc123xyz. No new charge. No duplicate record. The same result, every time.
This works across all major Stripe operations: payment intents, charges, refunds, subscriptions, customers, and invoice line items. Anywhere mutation matters, idempotency keys matter.
In Node.js with the Stripe SDK, you pass the key in a second parameter. In REST calls, it goes in the Idempotency-Key header:
// Using curl or raw HTTP
curl https://api.stripe.com/v1/payment_intents \
-u sk_test_abc123: \
-H "Idempotency-Key: order-12345" \
-d amount=2999 \
-d currency=usd
Choose keys carefully — Tie them to your business operation, not a random value per retry
This is where many teams stumble. An idempotency key must be stable across retries. If you generate a new random UUID for every retry attempt, you defeat the entire purpose. Each retry will look like a new request, and Stripe will execute it again.
Instead, tie your key to the business operation itself. Use your order ID, invoice ID, or subscription ID—something that stays constant for the entire lifecycle of that operation.
| Operation | Good idempotency key | Bad idempotency key |
|---|---|---|
| Create payment for an order | order-${orderId} |
uuid.v4() (new on every retry) |
| Refund a charge | refund-${chargeId}-${reason} |
Math.random() |
| Create a subscription | subscription-${customerId}-${planId} |
timestamp() (may vary slightly) |
| Issue an invoice | invoice-${invoiceNumber} |
null or omitted |
Let's look at a more complete example handling a real retry scenario:
async function createPaymentWithRetry(
orderId: string,
amount: number,
customerId: string,
maxRetries: number = 3
) {
const idempotencyKey = `order-${orderId}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency: 'usd',
customer: customerId,
metadata: { orderId, attempt },
},
{
idempotencyKey, // Same key every retry
}
);
console.log(`Payment created on attempt ${attempt + 1}:`, paymentIntent.id);
return paymentIntent;
} catch (error) {
lastError = error as Error;
console.error(`Attempt ${attempt + 1} failed:`, lastError.message);
// Exponential backoff: 100ms, 200ms, 400ms
const delay = Math.pow(2, attempt) * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error(
`Failed to create payment after ${maxRetries} attempts: ${lastError?.message}`
);
}
Notice the key is computed once, before the retry loop. Every attempt uses the same idempotencyKey. If the first attempt times out and succeeds server-side, the second attempt will return that same payment intent. Your code remains safe.
This pattern also helps with your database consistency. Store the idempotency key alongside your order record. Later, if you need to replay or debug a payment, you know exactly which key was used and can query Stripe's logs by that key.
Keys expire — Stripe keeps them for at least 24 hours
Idempotency keys aren't permanent. Stripe retains them for at least 24 hours from the time of the request. After that window, Stripe may forget the cached result, and a request with the same key will be treated as a new operation.
In practice, this means:
- For immediate retries: You're protected. Retry within seconds or minutes with confidence.
- For delayed retries: If you retry a payment intent creation 25 hours later with the same key, Stripe may process it as a new request.
- For manual recovery: Don't assume you can replay a payment from 48 hours ago and get the same result.
The 24-hour window is designed to cover most transient network issues: brief outages, DNS glitches, and temporary connection drops. It's not meant to be a long-term deduplication system.
If you need to handle retries beyond 24 hours, implement a separate deduplication layer in your database. Before calling Stripe, check if you've already processed this operation and have a result cached locally:
async function safeCreatePayment(orderId: string, params: any) {
// Check our database first
const existingPayment = await db.payments.findOne({ orderId });
if (existingPayment) {
console.log('Payment already processed for this order:', existingPayment.stripeId);
return existingPayment;
}
// Stripe API call with idempotency key (short-term protection)
const paymentIntent = await stripe.paymentIntents.create(params, {
idempotencyKey: `order-${orderId}`,
});
// Store in our database (long-term protection)
await db.payments.insertOne({
orderId,
stripeId: paymentIntent.id,
amount: paymentIntent.amount,
createdAt: new Date(),
});
return paymentIntent;
}
This two-layer approach protects you both within the 24-hour window (via Stripe's idempotency) and beyond (via your own database record).
Common mistakes
- Generating a new key per retry: The most dangerous mistake. It turns idempotency off entirely and re-enables duplicate charges.
- Using timestamps as keys: Timestamps may vary slightly between client and server, or due to clock skew. Use stable identifiers instead.
- Forgetting keys for refunds: Developers often add keys to payment creation but skip them for refunds. Use idempotency keys everywhere you mutate state.
- Sharing keys across orders: Each order should have a unique key. Reusing the same key for multiple orders breaks deduplication.
- Assuming keys last forever: They don't. After 24 hours, you're relying on your own database checks.
- Not handling the case where Stripe returns a cached result: If your first attempt timed out server-side but succeeded, the retry returns the same object. Ensure your code handles duplicate handling gracefully (e.g., check the database before inserting).
Key takeaways
- Idempotency keys make retries safe by returning the same result for the same key, preventing duplicate charges and subscriptions.
- Always tie keys to your business operation (order ID, customer ID, invoice number) rather than generating random values per retry.
- Pass the key in the second parameter to Stripe SDK calls or in the
Idempotency-Keyheader for REST requests. - Stripe caches keys for at least 24 hours; for longer protection, maintain your own database record of processed operations.
- Use the same key across all retry attempts for a single operation—generate it once, reuse it always.
- Extend idempotency keys to refunds, subscriptions, and all stateful Stripe operations, not just payment creation.






Comments
Loading comments…