Khalti's web checkout is the friendliest of Nepal's wallet APIs to build against: plain JSON over HTTPS, amounts in paisa, and one lookup call that tells you whether the money arrived. It still has a handful of details that turn into support tickets if you miss them, from a field Khalti will not accept blank to a test wallet that is empty because everyone else used it first.
This guide covers Khalti's ePayment flow end to end, the way we run it in production.
Last checked: 22 October 2026, against the Khalti integration PayBridgeNP runs for live merchants. Need the merchant account itself first? That is in how to get a Khalti merchant account.
How a Khalti payment works
- Your server starts the payment with a call to Khalti's initiate endpoint. Khalti answers with a
pidx(its ID for this payment) and apayment_url. - The customer pays on Khalti's page at that URL, with their Khalti wallet, and is sent back to your
return_url. - Your server confirms the payment with Khalti's lookup endpoint, using the
pidx, before you mark the order paid.
| Test (sandbox) | Live | |
|---|---|---|
| API base | https://dev.khalti.com/api/v2 | https://khalti.com/api/v2 |
| Start a payment | POST /epayment/initiate/ | POST /epayment/initiate/ |
| Confirm a payment | POST /epayment/lookup/ | POST /epayment/lookup/ |
| Authorisation header | Key <your test secret key> | Key <your live secret key> |
Note the header format: the word Key, a space, then your secret key. Not Bearer.
Step 1: start the payment
Send JSON to /epayment/initiate/ from your server:
{
"return_url": "https://yourstore.com/order/confirm",
"website_url": "https://yourstore.com",
"amount": 10000,
"purchase_order_id": "order-1042",
"purchase_order_name": "Order 1042"
}
| Field | What goes in it |
|---|---|
amount | In paisa: 10000 is Rs 100 |
purchase_order_id | Your order or attempt ID |
purchase_order_name | A short label the customer sees. Keep it short and never empty |
return_url | Where Khalti sends the customer after paying or cancelling |
website_url | Your site's address. Required, and must not be blank |
customer_info | Optional name, email and phone, shown to the customer on Khalti's page |
The response carries pidx and payment_url. Store the pidx with the order, then send the customer to payment_url.
Step 2: the return URL
When the customer finishes, Khalti redirects them to your return_url with query parameters including pidx, status, transaction_id, amount and purchase_order_id.
A status of User canceled means the customer backed out. For anything else, do not trust the URL: anyone can open it with any values they like. Check that its pidx is the one you stored for this order, then ask Khalti about that stored pidx. A pidx you did not issue for this order must never confirm it.
Step 3: confirm with lookup
Post the pidx to /epayment/lookup/ with the same Key header:
{ "pidx": "the-pidx-from-initiate" }
Only Completed with refunded false means paid. Check that total_amount in the answer is the amount you asked for, and fulfil each order once, even if the same pidx is confirmed again. The statuses you will meet:
| Status | What it means for the order |
|---|---|
Completed | Paid, unless refunded is true. Check the amount, then fulfil once |
Pending, Initiated | Not finished. Check again later; do not fail the order |
User canceled | The customer cancelled |
Expired | The payment link ran out before the customer paid |
Refunded, Partially Refunded | Refunded in full or in part (Khalti's docs spell the partial one with both capital and small r) |
If the lookup itself times out or Khalti answers with a server error, the customer may still have paid. Treat it as "not known yet" and ask again, never as a failed payment.
The traps that cause most Khalti bugs
website_url cannot be blank. Khalti rejects the initiate call with a 400 if it is empty. This bites flows that deliberately have no return page, such as payment links and invoices: send your site's address anyway.
Amounts are paisa, unlike eSewa. Khalti takes 10000 for Rs 100. If you integrate eSewa as well, which takes rupees as 100.00, convert at each provider's edge and test both with an amount that has paisa in it.
Do not trust the return URL. It is a convenience for the customer, not proof of payment. Confirm with lookup, or from a signed webhook if you use a service that sends one.
The shared test wallets run dry. Every developer in Nepal tests against the same Khalti test accounts, so some of them are often empty or busy. If you see "insufficient balance" or "similar request already being processed" in the sandbox, that is the sandbox, not your code: switch test accounts.
Refunds go through Khalti. Khalti supports refunds through its merchant dashboard and API. Decide early who in your team issues them and how the customer hears about it.
Testing in the sandbox
Get test keys from Khalti's test merchant setup (the Khalti docs walk through it), point your code at https://dev.khalti.com/api/v2, and pay on the test page with a shared test account:
| Field | Value |
|---|---|
| Khalti ID | 9800000005 (the most reliable of the shared accounts) |
| MPIN | 1111 |
| OTP | 987654 |
Run the success path, a cancellation, and a lookup on a pidx you never paid, so you have seen Pending and Expired before a customer shows them to you.
The shorter path: one API for Khalti, eSewa and Fonepay
Khalti on its own is a manageable integration. Most Nepali shops also want eSewa and Fonepay, which sign, redirect and confirm in completely different ways.
With PayBridgeNP you connect your own Khalti merchant account once and create one checkout session for any provider:
import { PayBridgeNP } from "@paybridge-np/sdk";
const paybridge = new PayBridgeNP({ apiKey: process.env.PAYBRIDGENP_API_KEY! });
// Send the customer straight to Khalti
const session = await paybridge.checkout.create({
amount: 10000, // NPR 100.00, in paisa
currency: "NPR",
provider: "khalti",
flow: "redirect",
returnUrl: "https://yourstore.com/order/confirm",
customer: { name: "Hari Gurung", email: "hari@example.com" },
});
// Send the customer to session.checkout_url
// Confirm the payment from the webhook, never from the return URL
console.log(session.checkout_url);
The initiate call, the lookup against the stored pidx and the amount check are handled for you, and your server gets one signed payment.succeeded webhook whichever provider the customer used. Leave out both provider and flow and the customer picks Khalti, eSewa or Fonepay on a hosted page; each provider needs your own merchant account with it. Payments settle straight into your own Khalti merchant account; PayBridgeNP never holds the money, and refunds are a button in the dashboard.
Next Steps
- Set up Khalti through PayBridgeNP: the Khalti page, or check live Khalti status.
- Need the merchant account first? Read how to get a Khalti merchant account.
- Adding eSewa too? See the eSewa API integration guide.
- Confirm payments the safe way: verify webhooks, not the redirect.
- Get free sandbox keys in the PayBridgeNP dashboard.