kumail.in
Back to blog
System Design2 min read

Designing Idempotent Payment APIs

Why idempotency keys matter in payment systems and how to implement them correctly.

Payment APIs must be idempotent. Network failures, client retries, and webhook duplicates all create scenarios where the same payment intent gets submitted multiple times.

The Idempotency Key Pattern

Clients send a unique Idempotency-Key header with each payment request:

POST /v1/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

The server stores the key with the response. Subsequent requests with the same key return the cached response without reprocessing.

Implementation

type IdempotencyRecord struct {
    Key       string
    Response  []byte
    Status    int
    CreatedAt time.Time
}

func (s *PaymentService) ProcessPayment(ctx context.Context, key string, req PaymentRequest) (*PaymentResponse, error) {
    if record, err := s.store.Get(ctx, key); err == nil {
        return unmarshalResponse(record)
    }
    
    // Acquire lock to prevent race conditions
    lock, err := s.locker.Acquire(ctx, "idempotency:"+key, 30*time.Second)
    if err != nil {
        return nil, err
    }
    defer lock.Release()
    
    // Double-check after acquiring lock
    if record, err := s.store.Get(ctx, key); err == nil {
        return unmarshalResponse(record)
    }
    
    result, err := s.processPayment(ctx, req)
    s.store.Set(ctx, key, result, 24*time.Hour)
    return result, err
}

Storage Considerations

  • Store idempotency keys in Redis with TTL (24-72 hours)
  • Include request hash to detect key reuse with different payloads
  • Return 409 Conflict if the same key is used with different request bodies

Key Takeaways

  • Every mutating payment endpoint needs idempotency
  • Use distributed locks to handle concurrent retries
  • TTL your idempotency records appropriately
  • Log idempotency hits for debugging duplicate submissions