How Khalti Works
Your Backend ββPOSTβββΆ Khalti API
β
βΌ
returns { pidx, payment_url }
β
β redirect user to payment_url
β
user pays on Khalti
β
Khalti redirects to your return_url
β
β οΈ NEVER trust this redirect alone!
β
Your Backend ββPOSTβββΆ Khalti Lookup (pidx)
β
{ status: "Completed" }
β
β
mark order as paid
initiatePayment()
KhaltiInitiateResponse response = khaltiClient.initiatePayment(
KhaltiInitiateRequest.builder()
.amount(10000L) // NPR 100 = 10000 paisa
.purchaseOrderId("ORD-001") // your unique order ID
.purchaseOrderName("Pro Plan") // shown to user on Khalti
.returnUrl("https://yourapp.com/callback") // optional override
.customerInfo( // optional
"Sujan Lamichhane",
"sujan@example.com",
"9800000001"
)
.build()
);
// IMPORTANT: store pidx before redirecting user
orderRepo.savePidx(orderId, response.pidx());
// Redirect user
return redirect(response.paymentUrl());
β οΈ Amount is in Paisa, not NPR.
NPR 100 = send 10000. NPR 10 minimum = send 1000.
lookupPayment()
// Call this AFTER receiving the Khalti redirect
// The redirect URL parameters can be faked β always verify server-side!
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isPaymentSuccessful()) {
return "Payment failed: " + lookup.status();
}
// Optional but recommended: verify amount was not tampered
if (!lookup.isAmountValid(expectedPaisa)) {
log.error("Amount mismatch β possible tampering!");
return "Payment error";
}
// β
Safe to mark order as paid
orderRepo.markPaid(orderId, lookup.transactionId());
refundPayment()
β οΈ Refund uses transaction_id, not pidx.
First call
lookupPayment(pidx), then use
lookup.transactionId() for the refund.
Khalti refunds are available for completed payments. NepalPay supports both full and partial refunds.
Full refund
// Step 1: Lookup payment using pidx
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isPaymentSuccessful()) {
throw new IllegalStateException("Only completed payments can be refunded");
}
// Step 2: Get transactionId from lookup response
String transactionId = lookup.transactionId();
// Step 3: Refund full amount
KhaltiRefundResponse refund =
khaltiClient.refundPayment(transactionId);
if (refund.isRefundSuccessful()) {
orderRepo.markRefunded(orderId);
}
Partial refund
// Refund NPR 50 from a completed transaction
// Amount is in PAISA: NPR 50 = 5000 paisa
KhaltiRefundResponse refund =
khaltiClient.refundPayment(transactionId, 5000L);
if (refund.isRefundSuccessful()) {
orderRepo.markPartiallyRefunded(orderId);
}
Important refund rules
| Rule | Explanation |
|---|---|
Use transactionId |
Refund API uses Khalti's internal transaction ID,
not the pidx from payment initiation.
|
| Get ID from lookup |
Call lookupPayment(pidx) and read
lookup.transactionId().
|
| Only completed payments | Pending, expired, canceled, or failed payments cannot be refunded. |
| Amount is paisa |
Partial refund amount is in paisa.
NPR 50 β 5000L.
|
| Full refund |
Use refundPayment(transactionId)
with no amount.
|
π΄ Do not pass pidx into refundPayment().
It expects
transactionId. Passing pidx will fail.
β
After refund succeeds, update your order/payment status
in your own database to
REFUNDED or
PARTIALLY_REFUNDED.
Payment Statuses
| Status | isSuccess() | isTerminalFailure() | Action |
|---|---|---|---|
COMPLETED |
β true | false | Mark order as paid |
REFUNDED |
false | false | Order was paid, then refunded. Mark as refunded. |
PENDING |
false | false | Poll again later |
USER_CANCELED |
false | β true | Offer retry |
CANCELED |
false | β true | Offer retry |
EXPIRED |
false | β true | Generate new payment |
FAILED |
false | β true | Offer retry |
UNKNOWN |
false | false | Contact Khalti support |
Security Rules
π΄ Never trust redirect URL parameters alone.
Always call
lookupPayment(pidx) to confirm.
π΄ Never hardcode your secret key.
Use environment variables.
β
Always store
pidx in your database
before redirecting the user.
β
Always call
isAmountValid() to detect
tampered amounts.