Idempotency

A new idempotency key on every retry is a double charge

Your worker dies halfway through a charge. The retry library fires again. Whether that customer gets billed once or twice comes down to one string. Derive it from your own order or cycle ID, never from a fresh UUID, and put a second guard in the handler for the webhooks that follow. This is the idempotency material from chapters 4, 7 and 5.

2. Implement Idempotency

The same payment might arrive multiple times due to network issues:

def charge_card(order_id, amount, token):
    # Use order_id to prevent double charging
    idempotency_key = f"charge_order_{order_id}"

    return stripe.Charge.create(
        amount=amount,
        currency='usd',
        source=token,
        idempotency_key=idempotency_key  # Stripe won't double-charge
    )

Idempotency is what keeps a retried request from charging the same card twice. Always send an idempotency key. Most payment providers support them natively, but implement the check at your application level too.

The mistake, and the one-line fix. From chapter 7.

1. Always Use Idempotency Keys

Every mutating call to your processor (charge creation, subscription creation, refund, plan change) takes an idempotency key. Use it. The key should be deterministic from your domain identifier (the order ID, the subscription event ID), not a fresh UUID every retry. If you generate a new key on each retry, the API treats every call as new and you double-charge.

# WRONG - each retry creates a new charge
def renew(subscription):
    return stripe.Charge.create(
        amount=subscription.amount,
        customer=subscription.customer_id,
        idempotency_key=str(uuid.uuid4()),  # NEW KEY EVERY CALL
    )

# RIGHT - retries are deduplicated
def renew(subscription, cycle_number):
    return stripe.Charge.create(
        amount=subscription.amount,
        customer=subscription.customer_id,
        idempotency_key=f"sub_{subscription.id}_renew_{cycle_number}",
    )

The processor will return the original response on subsequent calls with the same key, even across retries spanning hours. This is the single most important pattern for not double-charging customers when your network blips, your worker dies mid-call, or your retry library is misconfigured. Idempotency-key TTLs are usually 24 hours (Stripe) to indefinite (Adyen) - check your provider.

Implementation: src/examples/7-subscriptions-recurring-billing/subscription_testing.py

The provider key covers the request you send. This covers the webhook that comes back. From chapter 5.

4. Handle Idempotency

Idempotency matters because webhooks get delivered more than once, and your handler has to be fine with that:

def fulfill_order(order_id):
    with transaction.atomic():
        order = Order.objects.select_for_update().get(id=order_id)

        if order.status == 'fulfilled':
            return  # Already processed this webhook

        # Fulfill the order
        order.status = 'fulfilled'
        order.fulfilled_at = datetime.now()
        order.save()

        # Send confirmation email, etc.
        send_order_confirmation(order)

Idempotency is non-negotiable in payment systems. Payment providers will retry webhooks, network issues can cause duplicate deliveries, and race conditions can trigger multiple handlers. Every webhook handler and payment operation must be safe to execute multiple times with the same result.

What the book adds

What chapters 4, 7 and 39 add

  • Key TTLs per provider, 24 hours to indefinite
  • Atomic claims, so two workers cannot race
  • The idempotency store in Redis and Postgres
  • Grace periods when a renewal fails
  • Retry backoff that does not stampede

30-day money-back · Instant PDF and EPUB

All 42 chapters