PaymentsJun 2026 · 2 min read

Idempotent webhooks: payment callbacks that survive retries

Every payment gateway retries webhooks. Razorpay, Stripe, Cashfree — they all assume your endpoint might be down, slow, or return a 500, so they fire the same event again. And again. If your handler isn't built for that, a single successful payment can credit a wallet twice, ship an order twice, or send three "payment received" emails.

The fix isn't clever. It's boring, and boring is exactly what you want touching money.

The one rule

Process each event at most once, no matter how many times it arrives.

Everything below is just a way to make that rule true under retries, concurrency, and partial failures.

Give every event a stable identity

Most gateways send a unique event ID on the payload. Use it — not the payment ID, not a timestamp. The event ID is what stays constant across retries of the same delivery.

Persist it before you do any work:

async def handle_webhook(event: WebhookEvent, db: AsyncSession) -> None:
    # Insert-or-ignore on a unique event_id. If the row already exists,
    # this event has been seen — stop here.
    stmt = (
        insert(ProcessedEvent)
        .values(event_id=event.id, type=event.type)
        .on_conflict_do_nothing(index_elements=["event_id"])
    )
    result = await db.execute(stmt)
    if result.rowcount == 0:
        return  # already processed — silently ack
 
    await apply_effect(event, db)
    await db.commit()

The ON CONFLICT DO NOTHING plus a unique index is doing the heavy lifting. Two concurrent deliveries race to insert the same event_id; the database lets exactly one win.

Draw the transaction boundary correctly

The dedup insert and the side effect must live in the same transaction. If you commit the "seen" marker first and then crash before applying the effect, you've now permanently swallowed a real event. If you apply the effect first and crash before marking it seen, the retry double-applies.

One transaction, both writes, commit once. That's the whole trick.

Verify the signature first

Before any of this, check the HMAC signature the gateway sends. An unauthenticated webhook endpoint is a way for anyone to credit their own account.

expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature_header):
    raise HTTPException(status_code=400, detail="bad signature")

Use compare_digest, not ==, so you're not leaking timing information.

Always return 200 fast

Acknowledge quickly, then do slow work (emails, third-party calls) on a queue. If you block the webhook response on a flaky downstream call, the gateway times out, marks the delivery failed, and retries — which is the exact storm you're trying to avoid.

The checklist

  • Unique index on the gateway's event ID
  • Signature verified before any processing
  • Dedup marker and side effect in one transaction
  • Fast 200, slow work deferred to a queue
  • Retries are expected, not exceptional

Get these five right and a triple-fired callback is a non-event — which is the point.

← All writingGet in touch