Webhook retries are expected whenever a response is lost, delayed, or non-2xx. The correct response is not to disable retries; it is to make your consumer idempotent. VyaparGateway includes an event_id that remains stable across attempts for the same logical event.
Insert that event ID under a database unique constraint inside the same transaction that updates the order. If the insert conflicts, acknowledge the already-processed event and do nothing else. Inventory, email, wallet credit, or subscription activation should be queued only after the transaction commits.
Design principles
The boundaries that keep this flow reliable
Database constraint, not memory
An in-memory set disappears on restart and does not coordinate multiple application instances. Enforce uniqueness in the database.
One transaction
Event receipt and order state transition must commit together. Otherwise a crash can record one without the other.
Idempotent downstream jobs
Use a stable fulfilment key even after webhook deduplication. Queue redelivery should not send goods or credit twice.
Implementation workflow
From request to a durable result
- 1
Authenticate first
Verify timestamp and HMAC before trusting event_id or any payload field.
- 2
Begin a transaction
Insert event_id into webhook_receipts with a unique index and lock the mapped local order.
- 3
Validate and transition
Compare reference and amount, then move only from an allowed current state to paid.
- 4
Publish after commit
Create an outbox or queued fulfilment record with its own stable key, commit, and acknowledge 2xx.
Atomic event receipt pattern
sqlBEGIN;
INSERT INTO webhook_receipts (tenant_id, event_id, received_at)
VALUES (:tenant_id, :event_id, NOW())
ON CONFLICT (tenant_id, event_id) DO NOTHING;
-- Continue only when one row was inserted.
UPDATE orders
SET status = 'paid', paid_at = NOW()
WHERE tenant_id = :tenant_id
AND client_txn_id = :client_txn_id
AND status = 'payment_pending'
AND expected_amount_paise = :amount_paise;
INSERT INTO fulfilment_outbox (dedupe_key, order_id, event_type)
VALUES (:event_id, :order_id, 'order.paid');
COMMIT; Production checklist
Verify before going live
- Unique index covers tenant_id and event_id
- Signature validation precedes deduplication
- Order transition checks current state and expected amount
- Fulfilment has its own stable dedupe key
- Duplicate receipt returns 2xx without repeating side effects
Failure recovery
Why duplicates still happen
Check then insert race
Two workers both see no receipt before inserting. Let a unique constraint arbitrate instead of relying on a separate existence query.
Email sent before commit
The process crashes after email but before database commit, then repeats. Queue side effects through an outbox after durable state change.
Order state updated unconditionally
A repeated or stale event overwrites an already-final state. Include the allowed previous state in the update condition.
FAQ
Questions developers ask
Why does the gateway send duplicate webhooks?
A delivery may be retried when your endpoint times out, returns non-2xx, or the acknowledgement is lost. Retries improve reliability.
Is client_txn_id enough for deduplication?
Use event_id for event receipt and client_txn_id for the order relationship. They represent different identities.
Should duplicates return an error?
No. If the event is authentic and already processed, return 2xx so the sender knows no more retry is needed.