How Khalti Works

Your Backend: initiatePayment() โ†’ POST /api/v2/epayment/initiate/ โ†’ { pidx, payment_url } โœ… Save pidx to DB FIRST โ€” before redirecting Frontend: redirect user to payment_url โ†“ User pays on Khalti gateway โ†“ Khalti redirects to your return_url ?pidx=xxx&status=Completed&purchase_order_id=ORD-001 โš ๏ธ NEVER trust ?status=Completed alone โ€” it can be faked! Your Backend: lookupPayment(pidx) โ†’ POST /api/v2/epayment/lookup/ โ†’ { status: "Completed", total_amount: 10000 } โœ… Verify isAmountValid(expectedPaisa) โœ… Mark order as paid only when status == "Completed"
โ„น๏ธ

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.