Fintech ArchitectureDEC 20248 min read

Handling Race Conditions in ONDC Personal Loan Provisioning.

When you are the sole engineer building a live financial connector on the ONDC Beckn Protocol — with less documentation — race conditions are not a theoretical problem. They are a production incident waiting to happen. Here is exactly how I solved them.

Author

Faize Muhammed Basheer

Role

Sole Engineer — ONDC Connector

Context

Live Fintech Production

01 / Background

ONDC — the Open Network for Digital Commerce — is India's government-backed open protocol for digital commerce. The Beckn Protocol underneath it defines how Buyer Apps (BAPs) and Provider Apps (BPPs) communicate to execute transactions — including financial products like personal loans.

I joined the fintech company as the sole developer when ONDC had barely launched its financial services vertical. There were no tutorials, no SDKs, no Stack Overflow answers. Everything had to be reverse-engineered from the raw Beckn Protocol specification PDF and live network payload logs.

My task: build a complete BAP/BPP connector that could onboard credit providers and handle the full personal loan lifecycle — application, underwriting, approval, and disbursal — on the ONDC network.

02 / How ONDC Loan Flow Works

A personal loan on ONDC flows through a series of state transitions, each triggered by a webhook from the Beckn Gateway. The sequence looks roughly like this:

Customer applies ↓ BAP sends /search → BPP responds /on_search (loan offers) ↓ BAP sends /select → BPP responds /on_select (offer confirmation) ↓ BAP sends /init → BPP responds /on_init (KYC trigger) ↓ BAP sends /confirm → BPP responds /on_confirm (loan sanctioned) ↓ Gateway fires /on_update (disbursal status updates) ↓ Loan disbursed ✅

Each of these webhook calls arrives at your Node.js server from the ONDC Gateway. The problem is that the gateway does not guarantee exactly-once delivery. Under load — or during network hiccups — the same webhook fires multiple times within milliseconds.

03 / The Race Condition

Consider the /on_confirm webhook — the one that tells your BPP that the loan has been sanctioned and disbursal should begin.

Now imagine two identical /on_confirm payloads arrive at your server 40 milliseconds apart. Both carry the same transaction ID, same loan amount, same borrower. Your Node.js server receives both, spins up two async handlers simultaneously, and both race toward your PostgreSQL database to write the disbursal record.

What happens next depends entirely on whether you built the right defences. Without them — both handlers write successfully. The loan disburses twice. The borrower gets double the money. The lender loses it.

This is not a hypothetical. During load testing with the ONDC preprod environment, I saw the same webhook arrive 3 times in under 100ms. In a financial system, this is catastrophic.

04 / The Two-Layer Defence

I solved this with two independent layers of protection. The key insight is that no single layer is sufficient — you need defence in depth because different failure modes require different solutions.

Layer 1 — Idempotency at the API Gateway (Redis)

Every incoming webhook payload is hashed into a unique idempotency key using the transaction ID, the Beckn action type, and a hash of the payload body. Before the handler runs any business logic, it checks Redis for this key.

// Middleware — runs before any handler const idempotencyCheck = async (req, res, next) => { const key = generateIdempotencyKey( req.body.context.transaction_id, req.body.context.action, req.body ) const exists = await redis.get(key) if (exists) { // Already processed — return cached response return res.json(JSON.parse(exists)) } // Not seen before — process and cache result req.idempotencyKey = key next() } const generateIdempotencyKey = (txnId, action, body) => { const hash = crypto .createHash('sha256') .update(`${txnId}:${action}:${JSON.stringify(body)}`) .digest('hex') return `ondc:idem:${hash}` }

When the duplicate arrives 40ms after the first, Redis already has the idempotency key. The middleware returns the cached successful response in under 1ms — without touching PostgreSQL, without running business logic, without any side effects. From the gateway's perspective, both webhooks succeeded. Only one actually processed.

The Redis key has a TTL of 24 hours — long enough to catch any delayed retries, short enough that the key store doesn't grow indefinitely.

Layer 2 — Database Row Locks (PostgreSQL)

Redis is fast but it is not a transaction system. Under extreme load, two handlers could theoretically both pass the Redis check before either has written the key. This is the TOCTOU problem — Time Of Check, Time Of Use.

For any operation that mutates financial state — writing a disbursal, updating a loan status, recording a transaction — I wrap the entire operation in a PostgreSQL transaction with a row-level lock.

const processDisbursal = async (txnId, amount, borrowerId) => { const client = await pool.connect() try { await client.query('BEGIN') // Lock the loan record — second thread will block here const { rows } = await client.query( 'SELECT * FROM loans WHERE transaction_id = $1 FOR UPDATE', [txnId] ) const loan = rows[0] // Check current state — idempotent guard at DB level if (loan.status === 'disbursed') { await client.query('ROLLBACK') return { already_processed: true } } // Safe to disburse — no other thread can touch this row await client.query( 'UPDATE loans SET status = $1, disbursed_at = NOW() WHERE id = $2', ['disbursed', loan.id] ) await client.query( 'INSERT INTO transactions (loan_id, amount, type) VALUES ($1, $2, $3)', [loan.id, amount, 'disbursal'] ) await client.query('COMMIT') return { success: true } } catch (err) { await client.query('ROLLBACK') throw err } finally { client.release() } }

The SELECT ... FOR UPDATE acquires a row-level lock. If a second thread reaches this line while the first still holds the lock, it blocks — it cannot read the row, cannot write to it, cannot proceed. When the first thread commits and releases the lock, the second thread reads the now-updated status (disbursed) and exits cleanly without writing a duplicate record.

Even if Redis completely failed, this layer would catch the duplicate. Even if both threads passed Redis, this layer would catch the duplicate. Two independent mechanisms, both protecting the same critical section.

05 / Why Both Layers Are Necessary

A natural question: if PostgreSQL row locks are so reliable, why bother with Redis at all?

Because database locks are expensive under load. Every SELECT ... FOR UPDATEholds a connection from the pool. If 50 duplicate webhooks arrive simultaneously, 49 of them block on the database lock — holding 49 connections, adding latency, degrading throughput for every other query running simultaneously.

Redis eliminates 99% of duplicates before they reach the database — in under 1ms, with no connection pool pressure. PostgreSQL handles the remaining 1% edge case where Redis was somehow bypassed. Both layers together give you speed and correctness.

Duplicate webhook arrives ↓ Redis check (< 1ms) ↓ [Seen before?] ──YES──→ Return cached response. Done. ↓ NO Business logic runs ↓ PostgreSQL transaction + FOR UPDATE lock ↓ [Status = disbursed?] ──YES──→ Rollback. Done. ↓ NO Write disbursal. Commit. Cache in Redis.

06 / What Building on ONDC Taught Me

ONDC's financial services vertical had almost no developer documentation when I built this. The Beckn Protocol spec is dense and abstract. Real payload structures only became clear by intercepting live traffic from the preprod gateway and logging everything.

Three things I learned that apply beyond ONDC:

01

Read the protocol spec, not the tutorial

Tutorials are interpretations that can be wrong or outdated. The raw protocol specification is the ground truth. It is harder to read but it never lies.

02

Design for failure at every network boundary

Any external system — gateway, payment provider, RTA — can and will send you the same event multiple times. Idempotency is not optional in financial systems. It is the default.

03

Two independent defences beat one perfect defence

Redis could fail. PostgreSQL locks can be misconfigured. A single layer of protection has a single point of failure. Two layers, each solving the same problem differently, give you real safety.

07 / Current Status

The personal loan connector is live on the ONDC network. Purchase Finance and Working Capital connectors are built on the same idempotency infrastructure and going live next.

The same two-layer pattern — Redis idempotency key + PostgreSQL row lock — is now the standard for every state-mutating operation across the entire fintech platform. What started as a solution to one race condition became the architectural pattern for the whole system.