KrypticUPI: building an Android UPI payments app from scratch
UPI is India's real-time payment backbone. I built a client from scratch in Kotlin to understand how it actually works under the hood. Here is what I found.
KrypticUPI: building an Android UPI payments app from scratch
UPI (Unified Payments Interface) is India's real-time payment system. Google Pay, PhonePe, Paytm all run on it. Hundreds of millions of transactions per day. I built a UPI client from scratch in Kotlin because I wanted to understand how it actually works under the hood, not just call an SDK and move on.
Building in payments taught me things I could not have learned any other way.
what UPI integration actually means
The NPCI spec is thorough. Here is what integrating with it actually involves:
- UPI PIN generation: the user creates a 4-6 digit PIN. This PIN encrypts the private key on-device. It never touches the network. The bank has the corresponding public key.
- Virtual Payment Address registration: this is your UPI ID (user@bank). The app registers it with the NPCI mapper service, which maps it to the underlying bank account.
- Payment initiation: you construct a UPI URI, pass it to the payment handler, and wait for the callback.
- Callback handling: the bank calls your registered endpoint with success or failure. The app polls for final status as a backup because callbacks are not guaranteed.
- Transaction flow: intent, collect, execute, confirm. Each step has timeouts and retry logic.
The transaction flow sounds simple written out. In practice, each step has edge cases. Banks implement the timeouts differently. Some banks respond in 2 seconds, some take 30. Some do not send callbacks at all and rely entirely on polling.
the security model
Financial apps have a completely different security posture than anything else I have built. The requirements are not optional.
Biometric authentication gates every transaction. Not just app launch, every single transaction. Android Keystore stores private keys inside the Trusted Execution Environment using KeyGenParameterSpec with SECURITY_LEVEL_TRUSTED_ENVIRONMENT. Keys never leave the TEE.
Certificate pinning in OkHttp with a backup pin. If the certificate changes and does not match either pin, the connection drops. This protects against MITM even on compromised networks.
Every transaction is signed with the device private key. The bank verifies the signature server-side before processing. Screen overlay detection, because overlay attacks on Android are still a real threat. Play Integrity API on every launch to verify the app has not been tampered with.
This security surface is not over-engineering. It is the minimum for a payment app that handles real money.
the architecture
Clean Architecture with MVVM, pretty standard for Android:
Compose UI
ViewModels
Use Cases
Repository
UPI SDK / Network / SQLite
The interesting part is the transaction state machine. Each payment goes through states: initiated, processing, pending, confirmed, failed, refunded. The state transitions need to be idempotent because the bank might call your callback multiple times for the same transaction. We use a database-level unique constraint on transaction IDs and handle conflicts with ON CONFLICT IGNORE.
the callback reliability problem
The UPI callback mechanism is genuinely fragile. If your callback endpoint is down when the bank responds, the transaction stays in pending state. Forever, technically, unless you have a polling fallback.
We built a polling job that runs every 30 seconds for any transaction in pending state older than 10 seconds. The poll and the callback can both fire for the same transaction. The state machine handles this with idempotent state transitions: moving from pending to confirmed twice is fine, moving from confirmed back to pending is rejected.
You also need to handle the race where both arrive simultaneously. The database transaction ensures only one wins.
PIN entry UX matters more than you think
We went through five iterations on the PIN entry screen before landing on something that felt right. The constraints are odd: you want it to feel secure (custom keyboard, no Android autocomplete, nothing touching the clipboard), but you also want it to be fast and not frustrating for people who type it dozens of times per week.
The final design uses a custom PIN pad that looks nothing like the stock Android keyboard. Haptic feedback on each press. Auto-advance when the PIN length is reached. A brief animation before submission so the user has a chance to catch a wrong entry. Small decisions that add up to something that feels trustworthy.
what building payments taught me
Resilience patterns matter more here than in any other domain. Retries, idempotency, circuit breakers, dead letter queues for failed callbacks. These are not premature optimization in a payments context, they are table stakes.
The spec is not the implementation. "UPI compliant" means different things to different banks. You have to test against real bank sandboxes, not just validate against the spec document.
And security is not a feature you add at the end. It shapes every API design decision, every data model choice, every UX flow. Starting from a security-first mindset is much cheaper than retrofitting it later.