How Khalti Works
Khalti uses two different URL prefixes.
Initiate and lookup use
/api/v2/epayment/....
Refund uses
/api/merchant-transaction/{id}/refund/
โ no /v2 segment.
NepalPay handles this automatically via a separate
baseDomain field.
Configure
nepalpay:
khalti:
secret-key: ${KHALTI_SECRET_KEY}
return-url: ${KHALTI_RETURN_URL}
website-url: ${YOUR_WEBSITE_URL}
sandbox: true # false for production
# Optional retry
retry:
enabled: true
max-attempts: 3
initial-delay-ms: 500
multiplier: 2.0
max-delay-ms: 5000
| Property | Required | Default | Description |
|---|---|---|---|
secret-key |
โ | โ | Khalti secret key. Bean created when present. |
return-url |
โ | โ | URL Khalti redirects to after payment. |
website-url |
โ | โ | Your merchant website URL. |
sandbox |
โ | true |
true = dev.khalti.com ยท false = khalti.com |
timeout-seconds |
โ | 10 |
HTTP connect and read timeout in seconds. |
Get your sandbox secret key from
test-admin.khalti.com
.
Never hardcode it โ use
${KHALTI_SECRET_KEY}.
initiatePayment()
KhaltiInitiateResponse response =
khaltiClient.initiatePayment(
KhaltiInitiateRequest.builder()
.amount(10000L) // NPR 100 in PAISA
.purchaseOrderId("ORD-001")
.purchaseOrderName("Pro Plan")
.build()
);
// โ
Save pidx to DB BEFORE redirecting user
orderRepo.savePidx(orderId, response.pidx());
// Then redirect user
return response.paymentUrl();
Always save pidx before
redirecting. If the user closes their
browser mid-payment, you need the
pidx to verify later.
Save it to your database first โ every time.
KhaltiInitiateRequest fields
| Method | Required | Description |
|---|---|---|
.amount(long) |
Required | Amount in PAISA. NPR ร 100. Minimum NPR 10 = 1000 paisa. |
.purchaseOrderId(String) |
Required | Your unique order ID. |
.purchaseOrderName(String) |
Required | Human-readable product name shown to user on Khalti page. |
.returnUrl(String) |
Optional | Override the global return-url for this payment. |
.websiteUrl(String) |
Optional | Override the global website-url for this payment. |
.customerInfo(name, email, phone) |
Optional | Pre-fill customer info on Khalti page. Improves UX. |
Handle the Callback
After payment, Khalti redirects to your
return-url with query parameters.
Never trust these parameters alone.
// Khalti redirects to:
// GET /api/khalti/callback
// ?pidx=bZQLD9wRVWo4CdESSfuSsB
// &status=Completed
// &purchase_order_id=ORD-001
// &transaction_id=GFq9DrfGSZQKjsj
@GetMapping("/khalti/callback")
public ResponseEntity<String> callback(
@RequestParam String pidx,
@RequestParam(required = false) String status) {
// โ ๏ธ Ignore ?status=Completed from URL โ it can be faked
// โ
Always verify server-side
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isPaymentSuccessful()) {
return ResponseEntity.badRequest()
.body("Payment not confirmed: " + lookup.status());
}
// Load order using saved pidx
String orderId = orderRepo.findOrderByPidx(pidx);
// Verify amount was not tampered
long expectedPaisa = orderRepo.getAmountPaisa(orderId);
if (!lookup.isAmountValid(expectedPaisa)) {
log.error("SECURITY: Amount mismatch | pidx={} | expected={} | got={}",
pidx, expectedPaisa, lookup.totalAmount());
return ResponseEntity.badRequest().body("Amount mismatch");
}
// Safe to mark as paid
orderRepo.markPaid(orderId, lookup.transactionId());
return ResponseEntity.ok("Payment confirmed");
}
lookupPayment()
KhaltiLookupResponse lookup =
khaltiClient.lookupPayment(pidx);
if (lookup.isPaymentSuccessful()) {
// status == "Completed" โ safe to mark as paid
}
// Get the typed status enum
KhaltiPaymentStatus status = lookup.paymentStatus();
// COMPLETED, PENDING, USER_CANCELED, CANCELED,
// EXPIRED, FAILED, REFUNDED, UNKNOWN
KhaltiLookupResponse fields
| Method | Returns | Description |
|---|---|---|
isPaymentSuccessful() |
boolean |
true only when status is Completed |
isAmountValid(long) |
boolean |
Compare totalAmount with your expected paisa value |
isRefunded() |
boolean |
true when payment was refunded |
paymentStatus() |
KhaltiPaymentStatus |
Typed enum โ never null |
status() |
String |
Raw status string from API e.g. "Completed" |
pidx() |
String |
Payment identifier |
transactionId() |
String |
Khalti internal ID โ use this for refunds, NOT pidx |
totalAmount() |
Long |
Amount in paisa โ verify this matches your order |
fee() |
Long |
Transaction fee in paisa |
isAmountValid() โ Tamper Protection
Always verify the amount returned by Khalti matches the amount in your database. A malicious actor could pay a different amount and try to claim a product was paid for.
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
if (!lookup.isPaymentSuccessful()) {
return ResponseEntity.badRequest().body("Not paid");
}
// Load expected amount from YOUR database โ not from redirect URL
long expectedPaisa = orderRepo.getAmountPaisa(orderId);
if (!lookup.isAmountValid(expectedPaisa)) {
log.error(
"SECURITY ALERT: Amount mismatch | orderId={}" +
" | expected={}paisa | received={}paisa",
orderId, expectedPaisa, lookup.totalAmount()
);
// Do NOT fulfil the order โ investigate this
return ResponseEntity.badRequest().body("Amount mismatch");
}
// Both checks passed โ safe to fulfil order
orderRepo.markPaid(orderId, lookup.transactionId());
refundPayment()
Refunds require the transactionId
returned by lookupPayment() โ NOT
the pidx. The transactionId
only exists after a payment reaches
Completed status.
Full Refund
// Get transactionId from lookupPayment() first
KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);
String transactionId = lookup.transactionId();
KhaltiRefundResponse refund =
khaltiClient.refundPayment(transactionId);
if (refund.isRefundSuccessful()) {
orderRepo.markRefunded(orderId);
}
Partial Refund
// Refund NPR 50 = 5000 paisa
KhaltiRefundResponse refund =
khaltiClient.refundPayment(transactionId, 5000L);
if (refund.isRefundSuccessful()) {
orderRepo.markPartiallyRefunded(orderId, 5000L);
}
KhaltiRefundResponse fields
| Method | Returns | Description |
|---|---|---|
isRefundSuccessful() |
boolean |
Uses refunded boolean field โ primary source of truth |
refunded() |
Boolean |
Raw boolean from Khalti API |
transactionId() |
String |
Khalti transaction ID |
pidx() |
String |
Original payment identifier |
status() |
String |
May be null on partial refunds โ use refunded() instead |
Refund retry safety:
If you enable retry and a refund HTTP call times out,
always call lookupPayment(pidx) to check
the current refund status before retrying.
Khalti's refund API is not guaranteed idempotent โ
retrying without checking may cause a double-refund.
Security Rules
Never trust
?status=Completed in the redirect
URL. Anyone can craft a URL with
?status=Completed&pidx=anything.
Always call lookupPayment(pidx)
from your backend.
Always call
isAmountValid(expectedPaisa).
A user could initiate a payment for NPR 1
and try to claim a product worth NPR 1000.
Verify the amount from YOUR database.
Always save pidx to your database
immediately after initiatePayment()
โ before redirecting the user. This ensures
you can verify even if the browser crashes.
# Correct flow โ never skip any step:
1. initiatePayment() โ save pidx to DB โ redirect user
2. User returns โ ignore URL params (except pidx)
3. lookupPayment(pidx) โ verify on your server
4. isAmountValid(paisa) โ compare with DB amount
5. isPaymentSuccessful() โ then and only then mark paid
Reactive Usage
Use KhaltiReactiveClient from the
reactive starter. All validation is inside
Mono.defer() โ errors are always
emitted as reactive signals, never thrown.
@Service
@RequiredArgsConstructor
public class ReactivePaymentService {
private final KhaltiReactiveClient khaltiReactiveClient;
// Initiate
public Mono<String> initiate(String orderId, long amountNPR) {
return khaltiReactiveClient.initiatePayment(
KhaltiInitiateRequest.builder()
.amount(amountNPR * 100L)
.purchaseOrderId(orderId)
.purchaseOrderName("Pro Plan")
.build()
)
.doOnNext(res -> orderRepo.savePidx(orderId, res.pidx()))
.map(KhaltiInitiateResponse::paymentUrl);
}
// Lookup + amount validation
public Mono<Boolean> verify(String pidx) {
return khaltiReactiveClient.lookupPayment(pidx)
.flatMap(lookup -> {
if (!lookup.isPaymentSuccessful()) {
return Mono.just(false);
}
String orderId = orderRepo.findOrderByPidx(pidx);
long expected = orderRepo.getAmountPaisa(orderId);
if (!lookup.isAmountValid(expected)) {
log.error("Amount mismatch | pidx={}", pidx);
return Mono.just(false);
}
return orderRepo.markPaid(
orderId, lookup.transactionId()
).thenReturn(true);
});
}
// Full refund
public Mono<Boolean> refund(String transactionId) {
return khaltiReactiveClient.refundPayment(transactionId)
.map(KhaltiRefundResponse::isRefundSuccessful);
}
// Partial refund
public Mono<Boolean> partialRefund(
String transactionId, long amountPaisa) {
return khaltiReactiveClient
.refundPayment(transactionId, amountPaisa)
.map(KhaltiRefundResponse::isRefundSuccessful);
}
}
Payment Status Reference
| Status | isPaymentSuccessful() | isTerminalFailure() | Meaning |
|---|---|---|---|
Completed |
โ true | false | Payment successful โ safe to fulfil order |
Pending |
false | false | Not yet completed โ poll again later |
Refunded |
false | false | Was paid, then reversed โ use isRefunded() |
User canceled |
false | true | User closed the payment page |
Canceled |
false | true | Payment was canceled |
Expired |
false | true | Payment link expired (60 min in production) |
Failed |
false | true | Payment failed |
Use paymentStatus().isTerminalFailure()
to stop polling and offer the user a new payment link.