In most apps, a duplicate webhook means a duplicate email. In a lending app, it can mean sending the same borrower the same loan twice. The approval event arrives, your handler starts the payout, the response is slow, the sender retries, and a second payout leaves your account before anyone notices.
This post walks through a small, tested pattern that makes the approval-to-payout path safe to retry. It is the same thinking we apply in loan lending app development work, reduced to about 70 lines of Python and SQLite so you can run it locally.
Quick Answer: Treat every webhook as "at least once". Record each event ID under a unique constraint, allow only one disbursement row per loan, send the same idempotency key to the payout provider on every retry, and only let payout status move forward. Together these four rules stop double disbursals even when events repeat, race or arrive out of order.
Why Do Webhook Retries Cause Double Disbursals?
Webhook providers promise delivery at least once, not exactly once. Stripe's documentation, for example, warns that endpoints may receive duplicate events and retries failed deliveries with backoff for up to three days in live mode. Other providers follow the same model with different schedules.
In a lending flow, duplicates come from three places:
• Plain retries: your handler took too long or returned an error, so the same event ID is sent again.
• Races: a retry lands while the first delivery is still running, so two workers see the same event at the same moment.
• Upstream re-emits: the loan origination system fires a second "approved" event with a new event ID, for example after its own retry or a manual re-approval.
Deduplicating on event ID handles the first two. Only a business rule, "one disbursal per loan", handles the third. Payout status webhooks add a fourth problem: they can arrive late or out of order.
What Does a Safe Approval-to-Payout Flow Look Like?
The flow below crosses two systems. Approval lives in loan origination, while payout and repayment tracking live in loan servicing, and the difference between loan origination and loan management software is exactly where most duplicate-payout bugs hide.
• 1. Receive the approval webhook and record its event ID in the same transaction that creates the disbursement row.
• 2. Create at most one disbursement per loan, with a fixed idempotency key.
• 3. A worker sends pending disbursements to the payout provider, reusing that key on every attempt.
• 4. Payout status webhooks can only move the status forward: pending, sent, then paid or failed.
How Do You Store Events So Retries Are Harmless?
Two tables carry the whole design. The primary key on event_id rejects repeat deliveries, and the primary key on loan_id rejects a second disbursal for the same loan, whatever event triggered it.
import sqlite3, uuid SCHEMA = """ CREATE TABLE IF NOT EXISTS webhook_events ( event_id TEXT PRIMARY KEY, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS disbursements ( loan_id TEXT PRIMARY KEY, -- one disbursal per loan, ever amount_cents INTEGER NOT NULL, status TEXT NOT NULL, -- pending -> sent -> paid | failed idempotency_key TEXT NOT NULL UNIQUE, provider_ref TEXT ); """ How Do You Turn One Approval Into Exactly One Payout?
The handler writes the event ID and the disbursement row inside one transaction. If the event ID already exists, it returns 200 so the sender stops retrying. If the loan already has a disbursement, the insert is ignored. BEGIN IMMEDIATE takes the write lock up front in SQLite; in Postgres, use INSERT ... ON CONFLICT DO NOTHING inside a normal transaction to get the same effect.
ORDER = {"pending": 0, "sent": 1, "paid": 2, "failed": 2} def connect(path): db = sqlite3.connect(path, isolation_level=None, timeout=10, check_same_thread=False) db.executescript(SCHEMA) return db def handle_loan_approved(db, event): """Webhook from the loan origination system. Safe to call any number of times.""" db.execute("BEGIN IMMEDIATE") try: try: db.execute("INSERT INTO webhook_events(event_id) VALUES (?)", (event["id"],)) except sqlite3.IntegrityError: db.execute("ROLLBACK") return 200, "duplicate event" loan_id = event["data"]["loan_id"] cur = db.execute( "INSERT OR IGNORE INTO disbursements(loan_id, amount_cents, status, idempotency_key) " "VALUES (?, ?, 'pending', ?)", (loan_id, event["data"]["amount_cents"], f"disburse-{loan_id}")) db.execute("COMMIT") except Exception: db.execute("ROLLBACK") raise return 200, "queued" if cur.rowcount == 1 else "loan already has a disbursal" Notice that the handler never calls the payout API. Keeping network calls out of the webhook request means the response is fast, which cuts down on timeouts and therefore on retries.
What Happens If the Payout API Times Out?
This is the nastiest case. The provider may have created the payout and the response was lost on the way back. If the worker retries with a new request, the borrower gets paid twice. Most mature payment gateway software and payout APIs accept an idempotency key for this reason, so the worker sends the same key on every attempt and the provider returns the original payout instead of creating a new one. Check your provider's docs for how long it keeps keys.
def send_pending(db, provider): """Worker: push pending disbursals to the payout provider, reusing the same key on every retry.""" rows = db.execute("SELECT loan_id, amount_cents, idempotency_key FROM disbursements " "WHERE status = 'pending'").fetchall() for loan_id, amount, key in rows: try: ref = provider.create_payout(amount, idempotency_key=key) except TimeoutError: continue # stay pending, next run retries with the SAME key db.execute("UPDATE disbursements SET status='sent', provider_ref=? " "WHERE loan_id=? AND status='pending'", (ref, loan_id)) How Do You Handle Payout Webhooks That Arrive Out of Order?
A "failed" event that arrives after "paid" must not reopen the loan for another payout. A small ordering map makes status transitions forward-only. Unknown payout IDs return 404 so the provider retries later, which covers the case where the status webhook beats your own database update.
def handle_payout_update(db, event): """Webhook from the payout provider. Events may arrive late or out of order.""" new = event["data"]["status"] # 'paid' or 'failed' row = db.execute("SELECT status FROM disbursements WHERE provider_ref=?", (event["data"]["payout_id"],)).fetchone() if row is None: return 404, "unknown payout" # non-2xx so the provider retries later if ORDER[new] <= ORDER[row[0]]: return 200, f"ignored {new}, already {row[0]}" db.execute("UPDATE disbursements SET status=? WHERE provider_ref=? AND status=?", (new, event["data"]["payout_id"], row[0])) return 200, f"moved to {new}" Does It Hold Up Under Tests?
The test file uses a fake payout provider that honors idempotency keys and can simulate a lost response. Five scenarios cover the failure modes above:
1 retries of same event -> 1 payout: [(200, 'queued'), (200, 'duplicate event'), (200, 'duplicate event')] 2 concurrent deliveries -> queued once, payouts: 1 3 new event id, same loan -> (200, 'loan already has a disbursal') 4 timeout then retry -> payouts: 1 status: sent 5 late 'failed' after 'paid' -> (200, 'ignored failed, already paid') | final: paid ALL TESTS PASSED Ten threads delivering the same event at once still produce one queued disbursal and one payout, and a lost provider response is repaired by the retry rather than duplicated.
What Else Should a Production Lender Add?
• Keep event IDs longer than the retry window. If a provider retries for three days and you purge after one, a day-two retry looks new.
• Verify signatures first. Reject unsigned or stale webhooks before touching the database.
• Reconcile daily. Compare your disbursement table with the provider's payout list and alert on any mismatch or on rows stuck in pending.
• Use integer cents. Never store loan amounts as floats.
Payout safety also depends on what happens before the money moves. Velocity checks, device signals and account-change alerts belong in front of the disbursal step, and real-time fraud monitoring for fintech is the layer that pauses a suspicious payout before the worker ever picks it up.
After disbursal, the same idempotent thinking carries into repayments, late fees and collections, which a loan management software development guide covers in more depth.
Which Companies Build Reliable Loan Lending Platforms?
Figures below come from each company's own website and are self-reported, so confirm them directly before signing anything.
- Dev Technosys Dev Technosys is a loan lending app development company for lenders who want the money-moving parts built with the same care as the borrower screens. Its teams design approval-to-payout flows, repayment schedules, KYC steps and payout integrations as one system, so duplicate events and failed transfers are handled in the architecture rather than patched later. With 250+ in-house professionals, an 89% project success rate and most new work arriving through client referrals, it fits teams that want one partner from first release through growth and scale. Projects start from $10,000.
- DataArt DataArt, founded in 1997, runs a finance practice that lists digital lending, onboarding and KYC, payments and reporting data platforms among its banking work, and it describes itself as a Stripe partner for payouts, billing and identity. The company says it has more than 1,200 finance industry specialists, which signals depth in regulated delivery and governance. That governance-heavy model can feel formal for a small team shipping a first lending product on a short runway. DataArt suits established lenders and payment firms modernizing a platform under strict internal review.
- Luxoft Luxoft, part of DXC Technology, builds financial software for banks and works with Finastra across commercial, mortgage, syndicated and consumer lending, including loan origination and Loan IQ migration and support. It also offers EarlyResolution, a SaaS default management platform for lending operations, alongside core banking modernization services. Its strength sits with large institutions running established platforms, so a fintech startup wanting a fully custom mobile lending app may find the model oversized. Luxoft suits banks that need lending systems integrated, migrated or supported around Finastra and other core platforms.
- Zensar Technologies Zensar Technologies, headquartered in Pune and part of the RPG Group, runs a lending practice across commercial lending, mortgages, auto loans and specialty lending. Its lending page lists digital onboarding with KYC, loan origination system consolidation, embedded credit and BNPL APIs, and an agentic debt recovery product. Everest Group named it a Major Contender and Star Performer in its 2025 Banking IT Services assessment. Its case studies mostly describe anonymized UK and global banks, so ask for references that match your market. Zensar Technologies suits banks modernizing lending workflows.
- CI&T CI&T, listed on the NYSE, is a digital transformation company with a financial services practice covering banking platforms, payments, data and AI, and lending modernization. Its published work includes projects with Itaú, B3, Alelo and Planet Payment, and it acquired NTERSOL, an Irvine-based firm, in 2022 to grow its North American financial services team. Its lending page is brief, so buyers should ask for lending-specific examples rather than general banking work. CI&T suits banks and payment companies that want product design, engineering and AI adoption delivered together by one team. “A lending app is judged by what happens on its worst day: the timeout, the retry storm, the event that arrives twice. If your payout path is safe to run twice, you can sleep through most incidents,” said Tarun Nagar, Founder and CEO of Dev Technosys. Frequently Asked Questions Why do webhooks get delivered more than once? Providers guarantee at-least-once delivery. If your endpoint times out, returns an error or the network drops the response, the provider sends the same event again, sometimes for days. Is deduplicating on event ID enough for a lending app? No. Event IDs stop repeats of the same event, but the upstream system can send a new event for the same loan. A unique constraint on loan ID, or on loan ID plus disbursement number for multi-tranche loans, closes that gap. What is an idempotency key in a payout API? It is a unique value you send with a request so the provider can recognize a retry. If the key was already used, the provider returns the original result instead of creating a second payout. Should a webhook handler call the payout API directly? It is safer not to. Record the intent in your database, return 200 quickly, and let a worker make the payout call with retries and a fixed idempotency key. How long should processed event IDs be kept? At least as long as the longest retry window of any provider you use, with a buffer. Many teams keep them for 30 to 90 days because the rows are tiny. How do you handle out-of-order payout status events? Store a status order and only allow forward moves. Late events for an earlier state are acknowledged with 200 and ignored, so they never reopen a finished payout.

