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.
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.
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.
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.
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.
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.
Get these five right and a triple-fired callback is a non-event — which is the point.