Integrating Nepali Payment Gateways: A Practical Guide for Developers
Nepal's payment ecosystem is more mature than most people expect. eSewa has millions of active users. Khalti's API is genuinely well-designed. Fonepay sits quietly underneath a huge portion of bank-to-bank transactions in the country. If you're building anything that collects money in Nepal, you'll need to work with at least one of these — and the experience of actually integrating them is a bit different from what the documentation suggests.
This isn't a replacement for the official docs. It's the things that sit alongside them — the parts that aren't written down, the gotchas that surface once you're past the happy path, the decisions you'll wish someone had told you about earlier.
The four gateways and when you'd use each
Khalti is where you want to start if you're building something new. The API design is clean, the sandbox actually behaves like production (which is rarer than it should be), and their ePay v2 documentation is the best written of the lot. The lookup key system is straightforward once you understand it.
eSewa is non-negotiable for consumer-facing apps — it has the largest wallet user base in Nepal by a significant margin. The developer experience is rougher, but the integration docs cover what you need. Budget extra time for this one. The sandbox behaves differently from production in a few ways that will surprise you.
Fonepay is primarily a QR and interbank transfer gateway. It's less relevant for typical checkout flows but genuinely important for utility payments, B2B contexts, and anything where the user prefers to pay directly from a bank account rather than a wallet. The public documentation is limited, and you'll likely need to loop in their technical team at some point.
ConnectIPS is NRB-regulated and designed for higher-value bank account transfers. If you're building anything in financial services or need to comply with NRB guidelines, this is part of the picture. It's also the most involved integration of the four.
For most consumer products, you'll want at minimum Khalti and eSewa. That covers the majority of users who prefer digital wallets. Adding Fonepay extends your reach to users who bank more than they wallet.
The one rule that applies to all of them, without exception
Never trust the redirect.
Every Nepali payment gateway follows roughly the same flow: your server initiates the payment, the user is sent to the gateway's interface to authenticate and approve, and then the gateway redirects back to your success URL. That redirect is not confirmation of payment. It's notification that the user completed some interaction on the gateway's side — which could include a successful payment, but also an error, a cancelled session, or someone who just typed your success URL directly.
If your backend marks an order as paid based on the URL parameters from the redirect, you have a vulnerability. Someone can navigate to /payment/success?pidx=anything and your system will process it as a successful transaction. This is not theoretical. The correct pattern is: redirect happens, your backend receives it, your backend immediately calls the gateway's verification endpoint with the transaction reference, and only if the gateway confirms the payment is your database updated.
Every gateway has this verification step. Use it every time.
What Khalti's docs don't make obvious
The amount must be in paisa, not rupees. 1 NPR equals 100 paisa, so a payment of Rs. 500 gets sent as 50000. The docs mention this, but it's easy to skim past, and the failure mode is silent and embarrassing — your user sees "Rs. 5" on the Khalti confirmation screen and the payment technically succeeds for the wrong amount.
The purchase_order_id you send at initiation must be unique per payment attempt, not just per order. If a user's payment fails and they try again, you need to generate a new purchase_order_id for the retry. Reusing the previous one can cause Khalti to treat it as a duplicate and reject it.
In production, your return_url must be HTTPS. In sandbox, HTTP works fine, which is how developers test successfully and then hit a wall the moment they go live. The error you get isn't always clearly explained. If payments are initiating but the redirect is failing in production, this is the first thing to check.
There's also a version confusion that catches people: Khalti's older API (v1) uses a token as the payment identifier. The current API (v2) uses a pidx. The docs cover both, and if you're following an older tutorial or Stack Overflow answer, you might end up on the wrong one. The verification endpoints are different for each version. Make sure you know which version your integration is using before debugging a mismatch.
What eSewa's docs don't make obvious
eSewa's sandbox and production environments have different product_code values. In sandbox, it's EPAYTEST. In production, it's your merchant code. This is documented, but it's one of the most common reasons integrations that work perfectly in testing silently break in production. Make this an environment variable, not a constant.
eSewa's v2 API uses HMAC-SHA256 signature verification. The signature is generated from a specific concatenation of total_amount, transaction_uuid, and product_code, joined by commas. The order of these fields matters. Getting the order wrong gives you a signature mismatch error, and eSewa's error response isn't always explicit about what's wrong. If you're seeing Authentication failed or similar on payment initiation, this is usually the culprit.
The callback response from eSewa is base64-encoded. When the payment completes and your success URL receives the callback, the data comes in a single query parameter called data, which is a base64-encoded JSON string. People frequently try to read individual fields from the URL and find nothing there. You need to decode data first, then parse the JSON inside it.
// The callback URL will look like:
// /payment/success?data=eyJ0cmFuc2FjdGlvbl9jb2RlI...
const data = searchParams.get('data');
const decoded = JSON.parse(Buffer.from(data, 'base64').toString('utf-8'));
// Now decoded.transaction_code, decoded.status, etc. are accessible
Also worth knowing: eSewa's sandbox URL is rc-epay.esewa.com.np, not epay.esewa.com.np. One character difference, easy to miss, and the error you get when you hit the wrong endpoint isn't always clearly identified.
The "user pressed back" problem
There's a scenario that every payment integration eventually faces and that no documentation covers well: the user initiates a payment, lands on the gateway's page, and then presses the Android back button or closes the tab. Your order is now in a pending state indefinitely.
This matters more than it might seem. In Nepal, on mid-range Android devices with variable connectivity, users accidentally close tabs, get interrupted by calls, or lose their connection mid-flow more frequently than you'd expect on a fast, stable connection. You need a strategy for pending orders — either a background job that times them out and releases any held inventory after a window, or a payment status check that runs when the user returns to your app. Leaving orders in perpetual pending state creates inventory and reporting problems that compound over time.
Working with amounts — always use paisa internally
This applies to all the gateways: do your arithmetic in the smallest unit (paisa) as integers, not in rupees as floating point numbers. Floating point arithmetic on money amounts produces rounding errors. Rs. 99.99 should live in your system as 9999 paisa. Convert to a display format only when showing to the user.
For displaying NPR amounts, the Nepali number formatting convention uses lakh grouping, not the Western thousands grouping. Intl.NumberFormat with the ne-NP locale handles this:
new Intl.NumberFormat('ne-NP', { style: 'currency', currency: 'NPR' }).format(1234567)
// Outputs: NPR 12,34,567.00
The honest picture on refunds
Khalti has a programmatic refund endpoint — you send the original pidx and the refund is processed. This works reliably in testing and production.
eSewa currently does not have a proper refund API. Refunds go through the merchant portal, handled manually. If your product has a return or cancellation flow, this is important to know before you build the happy path — because the unhappy path on eSewa will always involve your team doing something manually, and that process needs to be designed for.
Fonepay and ConnectIPS refund processes both involve working with their merchant support directly. Plan for this in your operational design, not just your technical design.
A note on calling these APIs from the browser
Payment initiation and verification endpoints on all four gateways need to be called from your backend, not directly from the browser. CORS headers block browser-to-gateway calls, and your secret keys should never be in client-side code anyway. If you're making these calls from React or a mobile app directly, that's the architecture that needs to change first.
The flow is always: browser talks to your server, your server talks to the gateway, your server tells the browser what happened. The gateway never talks directly to the browser except through the user-facing redirect flow.
Getting the integration right the first time mostly comes down to reading the docs carefully, testing every failure scenario in sandbox before going live, and not trusting the redirect. Everything else is details.
If you need a team that has already solved these integration hurdles for enterprise-grade platforms, explore our Web Application Engineering capabilities or reach out for a technical consultation.
Official documentation links: Khalti · eSewa · Fonepay · ConnectIPS