- Published on
Idempotency Issues in Payment APIs — When Retries Charge Customers Twice
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Your payment API times out at 29 seconds. The client retries at 30 seconds. The first request actually succeeded at 28 seconds. Now the customer is charged twice — and your support team spends a week on refunds.
Idempotency is non-negotiable for any API that creates, charges, or modifies financial state.
- The Double-Charge Scenario
- Fix 1: Client-Generated Idempotency Keys
- Fix 2: Server-Side Idempotency with Redis
- Fix 3: Database-Level Idempotency (Stronger Guarantee)
- Fix 4: Idempotency for Multi-Step Operations
- Testing Idempotency
- Conclusion
The Double-Charge Scenario
Client Network Payment API Stripe
| | | |
|-- POST /charge --------->| | |
| |-- request ------>| |
| | |-- charge -------->|
| | |<-- success -------|
| | | |
| | <TIMEOUT> | |
|<-- timeout error --------| | |
| | | |
|-- POST /charge (RETRY) ->| | |
| |-- request ------>| |
| | |-- charge -------->| 💸 DOUBLE CHARGE
| | |<-- success -------|
|<-- 200 OK ---------------| | |
Fix 1: Client-Generated Idempotency Keys
// Client: generate a unique key per logical operation
import { v4 as uuidv4 } from 'uuid'
class PaymentClient {
// Store the idempotency key for the duration of a payment attempt
// Same key = same logical payment, no matter how many retries
async chargeCustomer(customerId: string, amount: number, currency: string) {
// Generate once, retry with same key
const idempotencyKey = uuidv4()
return this.retryWithSameKey(idempotencyKey, async () => {
return fetch('/api/payments/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ customerId, amount, currency }),
})
})
}
private async retryWithSameKey(key: string, fn: () => Promise<Response>) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fn()
if (response.status !== 503) return response // Don't retry non-transient
await sleep(200 * Math.pow(2, attempt))
} catch (err) {
if (attempt === 2) throw err
await sleep(200 * Math.pow(2, attempt))
}
}
}
}
Fix 2: Server-Side Idempotency with Redis
import { Redis } from 'ioredis'
interface IdempotencyRecord {
status: 'processing' | 'completed' | 'failed'
response?: any
createdAt: number
}
class IdempotencyMiddleware {
constructor(
private redis: Redis,
private ttlSeconds: number = 86400 // 24 hours
) {}
middleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const key = req.headers['idempotency-key'] as string
if (!key) {
// For mutation endpoints, require idempotency key
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
return res.status(400).json({
error: 'Idempotency-Key header required for mutation requests'
})
}
return next()
}
const redisKey = `idempotency:${key}`
const existing = await this.redis.get(redisKey)
if (existing) {
const record: IdempotencyRecord = JSON.parse(existing)
if (record.status === 'processing') {
// Another request is currently processing this key
return res.status(409).json({
error: 'Request with this idempotency key is already being processed',
retryAfter: 5,
})
}
if (record.status === 'completed') {
// Return cached response — identical to original
console.log(`Idempotency hit for key: ${key}`)
return res.status(200).json(record.response)
}
if (record.status === 'failed') {
// Previous attempt failed — allow retry
await this.redis.del(redisKey)
}
}
// Mark as processing (lock)
await this.redis.set(
redisKey,
JSON.stringify({ status: 'processing', createdAt: Date.now() }),
'EX',
60 // 60s lock — long enough for any request to complete
)
// Override res.json to capture response
const originalJson = res.json.bind(res)
res.json = (body: any) => {
if (res.statusCode < 400) {
// Success — cache the response
this.redis.set(
redisKey,
JSON.stringify({
status: 'completed',
response: body,
createdAt: Date.now(),
}),
'EX',
this.ttlSeconds
)
} else {
// Failure — delete lock so client can retry
this.redis.del(redisKey)
}
return originalJson(body)
}
next()
}
}
}
// Apply to payment routes
const idempotency = new IdempotencyMiddleware(redis)
app.post('/api/payments/charge',
idempotency.middleware(),
async (req, res) => {
const { customerId, amount, currency } = req.body
const charge = await stripe.charges.create({
amount,
currency,
customer: customerId,
})
res.json({ chargeId: charge.id, status: charge.status })
// Second request with same key → returns this cached response
}
)
Fix 3: Database-Level Idempotency (Stronger Guarantee)
// For financial operations: store idempotency key in the same DB transaction
// This survives Redis failures and ensures atomicity
async function chargeWithDbIdempotency(
key: string,
customerId: string,
amount: number
) {
return db.transaction(async (trx) => {
// Try to insert idempotency record atomically
// If key already exists → unique constraint violation → duplicate detected
const existing = await trx('idempotency_keys')
.where({ key })
.first()
if (existing) {
if (existing.status === 'completed') {
// Return the original result — no new charge
return existing.result
}
throw new Error('Request already in progress')
}
// Insert lock record
await trx('idempotency_keys').insert({
key,
status: 'processing',
created_at: new Date(),
})
// Execute the actual charge
const charge = await stripe.charges.create({ amount, currency: 'usd', customer: customerId })
// Update record with result (within same transaction)
await trx('idempotency_keys')
.where({ key })
.update({
status: 'completed',
result: JSON.stringify({ chargeId: charge.id }),
completed_at: new Date(),
})
return { chargeId: charge.id }
})
}
Fix 4: Idempotency for Multi-Step Operations
// When a charge involves multiple steps, track each step separately
interface ChargeState {
step: 'pending' | 'stripe_charged' | 'db_recorded' | 'email_sent' | 'completed'
stripeChargeId?: string
orderId?: string
}
async function idempotentCheckout(idempotencyKey: string, order: OrderData) {
const state = await getChargeState(idempotencyKey)
// Resume from wherever the previous attempt left off
let chargeId = state?.stripeChargeId
if (!state || state.step === 'pending') {
// Step 1: Charge Stripe (use Stripe's own idempotency key)
const charge = await stripe.charges.create(
{ amount: order.total, currency: 'usd', customer: order.customerId },
{ idempotencyKey } // Stripe deduplicates on their end too
)
chargeId = charge.id
await saveChargeState(idempotencyKey, { step: 'stripe_charged', stripeChargeId: chargeId })
}
let orderId = state?.orderId
if (!state || state.step === 'stripe_charged') {
// Step 2: Record in DB
const dbOrder = await db.order.create({
stripeChargeId: chargeId,
...order,
})
orderId = dbOrder.id
await saveChargeState(idempotencyKey, { step: 'db_recorded', stripeChargeId: chargeId, orderId })
}
if (!state || state.step === 'db_recorded') {
// Step 3: Send confirmation email (idempotent — email service deduplicates)
await emailService.sendOrderConfirmation(orderId!, { idempotencyKey: `email:${idempotencyKey}` })
await saveChargeState(idempotencyKey, { step: 'completed', stripeChargeId: chargeId, orderId })
}
return { chargeId, orderId }
}
Testing Idempotency
describe('Payment idempotency', () => {
it('should return same result for duplicate requests', async () => {
const key = uuidv4()
const [response1, response2] = await Promise.all([
// Simulate two concurrent requests with same key
request(app).post('/api/charge').set('Idempotency-Key', key).send(payload),
request(app).post('/api/charge').set('Idempotency-Key', key).send(payload),
])
// One succeeds, one returns 409 (concurrent) or same 200
const successes = [response1, response2].filter(r => r.status === 200)
expect(successes).toHaveLength(1) // Exactly one charge
})
it('should return cached result on retry', async () => {
const key = uuidv4()
const first = await request(app).post('/api/charge').set('Idempotency-Key', key).send(payload)
const retry = await request(app).post('/api/charge').set('Idempotency-Key', key).send(payload)
expect(retry.body.chargeId).toBe(first.body.chargeId) // Same charge ID
expect(stripeMock.charges.create).toHaveBeenCalledTimes(1) // Only one Stripe call
})
})
Conclusion
Double charges are caused by retries without idempotency. The fix has three layers: clients generate a stable idempotency key per logical operation and reuse it on all retries; servers check Redis or a database for duplicate keys before processing; and external payment processors like Stripe accept their own idempotency key to deduplicate on their side too. For multi-step operations, track which steps completed so retries resume rather than restart. With this in place, a payment can be retried safely 100 times — the customer is only ever charged once.