Idempotency Issues in Payment APIs — When Retries Charge Customers Twice
Advertisement
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.
Advertisement