Back to Payments

Payments · Lesson 2

Idempotency Keys in Payment APIs

The single parameter that separates "this charge ran once" from "this charge ran twice because my Sidekiq worker retried." Worth a lesson because the bug it prevents is invisible until it isn't.

The retry problem

Network failures don't tell you what happened. A timeout could mean "the request never arrived" or "the request was processed and the response was lost." If you retry on timeout without protection, you might create two charges for one order. The user sees a duplicate billing. Your refund process kicks in. The customer trust kicks out.

Payment providers solve this with idempotency keys. You send a key with each create request. The provider stores the first response keyed by that value, and if the same key arrives again, returns the cached response without doing the work twice.

The shape

# Generate a stable key tied to the operation you're attempting:
key = "create-intent-order-#{order.id}"

intent = Stripe::PaymentIntent.create(
  { amount: order.total_cents, currency: "usd" },
  { idempotency_key: key }
)

# Now if your job retries:
#   - The first call created an intent and returned intent_1.
#   - The retry sends the same key.
#   - Stripe returns intent_1 again, without creating a new one.
#   - Your code sees the same response. No duplicate.

Where the key should come from

The key has to be the same across retries of the same operation, and different across different operations. Two safe patterns:

  • Tie it to the resource. "create-intent-order-42." If you retry creating the intent for order 42, you want the same key. If a new order is being created, you want a new key. This is the most common shape.
  • Tie it to the job invocation. Pass the key as a job argument: ChargeJob.perform_later(order_id, idempotency_key: SecureRandom.uuid). The retry uses the same arguments, so the same key.

What you don't want: a fresh SecureRandom.uuid generated at the moment of the API call. Every retry generates a new key, so every retry creates a new charge.

How long the server remembers

Idempotency-key storage is bounded. Stripe keeps keys for 24 hours, then forgets them. After that window, sending the same key creates a new charge. In practice: jobs that retry within minutes are safe; long-delayed retries (e.g., a Sidekiq job that sat in a dead queue for 48 hours) are not.

The defense: check your own database first. If you already have a Payment record for this order with a non-pending state, skip the API call entirely. The idempotency key is the second line of defense; the first is "we already did this."

class ChargeOrderJob < ApplicationJob
  retry_on Stripe::APIConnectionError, wait: :exponentially_longer

  def perform(order_id)
    order = Order.find(order_id)
    return if order.payment&.succeeded?    # already done, skip

    intent = Stripe::PaymentIntent.create(
      { amount: order.total_cents, currency: "usd" },
      { idempotency_key: "create-intent-order-#{order.id}" }
    )
    order.payment&.update!(intent_id: intent.id) || order.create_payment!(intent_id: intent.id)
  end
end

Webhooks need idempotency too

The same providers that accept idempotency keys also send webhooks at-least-once. When your webhook handler processes the same event ID twice, the result has to be the same as processing it once. That's the topic of the next lesson.