Back to blog
Article

Idempotent payment webhooks so retries don't double-charge inventory

Idempotent payment webhooks so retries don't double-charge inventory
S

StriveBit

3 min readE-commerce

Stopping retried payment webhooks from creating duplicate orders

Razorpay sends the `payment.captured` webhook. Your handler runs, marks the cart as paid, decrements stock, sends a confirmation email, and returns 200. Four minutes later Razorpay sends the same webhook again — the first response was slow, their retry window triggered — and your handler runs the whole sequence a second time. The customer now has two orders, inventory is off by one, and nobody notices until the stock count at month-end.

This is the standard failure mode for payment webhooks in Indian e-commerce. Razorpay, Cashfree, and PayU all retry. They retry because networks fail and they would rather deliver twice than not at all. Your handler has to treat every webhook as if it might be a duplicate.

The fix is an idempotency key table. Before doing anything else, the handler checks whether this specific event has already been processed.

CREATE TABLE processed_webhooks (
  provider        text NOT NULL,
  event_id        text NOT NULL,
  order_ref       text,
  processed_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);

Every gateway puts a unique event ID in the payload. Razorpay calls it `payload.payment.entity.id` for payment events. Cashfree calls it `data.payment.payment_id` combined with the event type. Whatever the field, it is the thing the gateway guarantees is unique per logical event.

The handler does an insert-first pattern, not a check-then-insert. Check-then-insert has a race condition when two deliveries arrive within milliseconds of each other.

@db.transaction
def handle_webhook(provider, event_id, payload):
    try:
        db.execute(
            "INSERT INTO processed_webhooks (provider, event_id) VALUES (?, ?)",
            [provider, event_id]
        )
    except UniqueViolation:
        return Response(status=200, body="already processed")

    order = process_payment(payload)
    db.execute(
        "UPDATE processed_webhooks SET order_ref = ? WHERE provider = ? AND event_id = ?",
        [order.id, provider, event_id]
    )
    return Response(status=200)

The insert is inside the same transaction as the order creation. If the order creation fails, the insert rolls back and the webhook will be retried — which is what you want. If the insert succeeds, the order is committed, and any retry hits the unique constraint and returns early.

One thing we do not do is rely on the gateway's `X-Razorpay-Signature` header for idempotency. The signature tells you the payload is authentic. It tells you nothing about whether you have seen this particular event before. You need both.

We also do not deduplicate on the order ID. A single order can have multiple payment attempts — a failed UPI retry, then a success. Deduplicating on order ID would cause you to miss the success event after processing the failure event.

The `order_ref` column in the table is there for debugging. When someone asks why an order exists, you can trace it back to the exact webhook event that created it. We keep the table for 90 days. After that the gateway will not retry, and the rows are just noise.

A note on partial failures: if your handler sends an email after committing the order and the email service is down, the webhook returns 200 anyway. The order is created, the customer is not double-charged, and the email can be retried from a queue. The webhook handler's job is to record the payment, not to guarantee every downstream side effect. Mixing those concerns is how you end up with the duplicate problem in the first place.

The pattern costs one table and one index. It saves you from the support ticket where a customer has two order numbers and one payment.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch