PaymentsApr 2026 · 1 min read

UPI redirect flows and the failure cases that bite in production

The happy path of a UPI redirect flow is easy: user pays, gateway redirects back, you mark the order paid. Production is the other 20% — and that's where money goes missing.

The redirect is not the source of truth

The user's browser coming back to your return_url means almost nothing. They might have paid and the redirect got lost. They might have closed the app mid-payment. The redirect is a hint; the webhook and a status API poll are the truth.

Treat the redirect as "the user is probably back" and reconcile the real state server-side.

The states people forget

  • Paid, but no redirect — user completed payment, then closed the tab. Only the webhook tells you.
  • Redirect, but not yet paid — bank is still processing. Status lags the callback by seconds to minutes.
  • Pending forever — the payment never resolves. You need a timeout and a sweep job.
  • Double submit — user hits pay twice. Idempotency on the order, not just the webhook.

Reconcile, don't guess

Run a background job that polls the gateway's status API for anything stuck in pending past a threshold:

async def reconcile_pending(db: AsyncSession) -> None:
    stale = await get_pending_orders(db, older_than=minutes(10))
    for order in stale:
        status = await gateway.fetch_status(order.gateway_ref)
        await apply_status(order, status, db)

The rule

Show the user an optimistic "we're confirming your payment" screen on redirect, but never mark money moved until the webhook or a status poll confirms it. The redirect is UX; the server-side reconcile is truth.

← All writingGet in touch