Published on

Idempotency Issues in Payment APIs — When Retries Charge Customers Twice

Authors

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

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.