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