Fintech features sound complicated. In practice, they're a chain of well-defined steps: verify identity, issue a session, track a balance, pay out. The complexity is in the details — handling failures gracefully, securing every layer, and never letting money go missing.
Here's how we built the complete auth-to-payout flow in SafaiPay, from a user's first tap to rupees in their UPI wallet.
Step 1: OTP Authentication
We chose phone-based OTP authentication for one reason: in India, everyone has a phone number. Email-based auth would exclude a significant portion of our target users.
Firebase Authentication handles OTP delivery and verification. The flow is straightforward:
- User enters phone number → Firebase sends OTP via SMS
- User enters OTP → Firebase verifies and returns a Firebase ID token
- Our Go backend verifies the Firebase ID token using the Firebase Admin SDK
- On successful verification, we issue our own JWT session token
Why issue our own JWT instead of using Firebase tokens everywhere? Firebase tokens expire in one hour and require constant refreshes. Our JWT gives us full control over session duration, claims, and revocation — and removes a Firebase dependency from every authenticated request to our Go backend.
Step 2: JWT Sessions
Our JWT payload contains three things: user ID, phone number, and expiry. We sign tokens with a secret key using HS256. Every protected endpoint in our Go backend validates the JWT before processing the request.
Token refresh happens silently: when a token is within 24 hours of expiry, the app requests a new one in the background. Users never see a login screen after their first auth.
Revocation — for logout or suspicious activity — is handled by storing a token_version integer per user in PostgreSQL. Every JWT includes the version at issuance; a version mismatch on validation invalidates the token instantly.
Step 3: Wallet and Balance
Every user has a wallet record in PostgreSQL. Points earned from civic actions credit the wallet; withdrawals debit it. Every transaction is recorded in an immutable ledger table — no balance is ever updated without a corresponding transaction record.
Critically, balance updates use PostgreSQL's FOR UPDATE row locking to prevent race conditions. If two point awards arrive simultaneously, the second waits for the first to complete — the balance is never double-counted or lost.
Step 4: UPI Payout via RazorpayX
When a user requests a withdrawal, we:
- Debit the wallet immediately (locking the funds)
- Create a payout via the RazorpayX API
- Store the RazorpayX payout ID and set status to
pending - Listen for the webhook from RazorpayX confirming settlement
- On success webhook: mark payout
completed - On failure webhook: reverse the wallet debit and notify the user
The webhook-driven approach is critical. Never mark a payout as complete based on the API response alone — network failures between your server and RazorpayX can cause the API to succeed on their end but fail to return a response to you. Webhooks confirm actual settlement.
In fintech, optimism is a bug. Assume every step can fail, and design the recovery path before the happy path.
The full stack — Firebase OTP, Go JWT backend, PostgreSQL ledger, RazorpayX payouts — took about three weeks to build and test properly. The testing took longer than the building. That's the right ratio for anything involving money.