Idempotency

Process webhook retries once, without double fulfilment

Prevent duplicate payment effects with event_id uniqueness, database transactions, one-way order states, and after-commit fulfilment workers.

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

01

Database constraint, not memory

An in-memory set disappears on restart and does not coordinate multiple application instances. Enforce uniqueness in the database.

02

One transaction

Event receipt and order state transition must commit together. Otherwise a crash can record one without the other.

03

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. 1

    Authenticate first

    Verify timestamp and HMAC before trusting event_id or any payload field.

  2. 2

    Begin a transaction

    Insert event_id into webhook_receipts with a unique index and lock the mapped local order.

  3. 3

    Validate and transition

    Compare reference and amount, then move only from an allowed current state to paid.

  4. 4

    Publish after commit

    Create an outbox or queued fulfilment record with its own stable key, commit, and acknowledge 2xx.

Atomic event receipt pattern

sql
BEGIN;

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.

Build the real flow

Move from guide to a verified ₹1 payment

The dashboard checklist takes you through merchant connection, payment verification, webhook testing, and go-live readiness.

Start setup