Requirements

Starter Spring Boot Java HTTP Client
nepal-pay-spring-boot-3-starter 3.2.x or higher 17 or higher RestClient (blocking)
nepal-pay-spring-boot-4-starter 4.0.x or higher 21 or higher RestClient (blocking)
nepal-pay-spring-boot-reactive-starter 3.2.x or higher 17 or higher WebClient (reactive)

Choose a Starter

ℹ️

Use the blocking starter if your app uses Spring MVC β€” controllers returning values directly. Use the reactive starter if your app uses Spring WebFlux β€” controllers returning Mono or Flux.

I am using… Use this starter
Spring MVC + Spring Boot 3.2+ nepal-pay-spring-boot-3-starter
Spring MVC + Spring Boot 4.x nepal-pay-spring-boot-4-starter
Spring WebFlux (reactive) nepal-pay-spring-boot-reactive-starter
βœ…

The same application.yml works for all three starters. Same nepalpay.* properties, same sandbox and production behaviour. Only the artifact name and injected bean types differ.

Install

βœ…

Available on Maven Central. No <repositories> block needed.

Spring Boot 3.2+ β€” Blocking:

<dependency>
    <groupId>io.github.sujankim</groupId>
    <artifactId>nepal-pay-spring-boot-3-starter</artifactId>
    <version>1.2.0</version>
</dependency>

Spring Boot 4.x β€” Blocking:

<dependency>
    <groupId>io.github.sujankim</groupId>
    <artifactId>nepal-pay-spring-boot-4-starter</artifactId>
    <version>1.2.0</version>
</dependency>

Spring WebFlux β€” Reactive (Boot 3.2+):

<dependency>
    <groupId>io.github.sujankim</groupId>
    <artifactId>nepal-pay-spring-boot-reactive-starter</artifactId>
    <version>1.2.0</version>
</dependency>

Spring Boot 3.2+:

implementation 'io.github.sujankim:nepal-pay-spring-boot-3-starter:1.2.1'

Spring Boot 4.x:

implementation 'io.github.sujankim:nepal-pay-spring-boot-4-starter:1.2.1'

Spring WebFlux β€” Reactive:

implementation 'io.github.sujankim:nepal-pay-spring-boot-reactive-starter:1.2.1'

Spring Boot 3.2+ β€” Blocking:

implementation("io.github.sujankim:nepal-pay-spring-boot-3-starter:1.2.1")

Spring Boot 4.x β€” Blocking:

implementation("io.github.sujankim:nepal-pay-spring-boot-4-starter:1.2.1")

Spring WebFlux β€” Reactive:

implementation("io.github.sujankim:nepal-pay-spring-boot-reactive-starter:1.2.1")

Configure

Add to application.yml. Only configure the gateways you use β€” beans are created conditionally when keys are present. The same YAML works for blocking and reactive starters.

nepalpay:

  khalti:
    secret-key:  ${KHALTI_SECRET_KEY}
    return-url:  ${KHALTI_RETURN_URL}
    website-url: ${YOUR_WEBSITE_URL}
    sandbox: true                # false for production

  esewa:
    secret-key:   ${ESEWA_SECRET_KEY}
    product-code: ${ESEWA_PRODUCT_CODE}
    success-url:  ${ESEWA_SUCCESS_URL}
    failure-url:  ${ESEWA_FAILURE_URL}
    sandbox: true

  fonepay:
    merchant-code: ${FONEPAY_MERCHANT_CODE}
    secret-key:    ${FONEPAY_SECRET_KEY}
    return-url:    ${FONEPAY_RETURN_URL}
    sandbox: true

  connectips:
    merchant-id:  ${CONNECTIPS_MERCHANT_ID}
    app-id:       ${CONNECTIPS_APP_ID}
    app-name:     ${CONNECTIPS_APP_NAME}
    app-password: ${CONNECTIPS_APP_PASSWORD}
    pfx-path:     ${CONNECTIPS_PFX_PATH}        # file:/app/CREDITOR.pfx
    pfx-password: ${CONNECTIPS_PFX_PASSWORD}
    timeout-seconds: 30                          # optional, default 30s
    sandbox: true

Spring Boot auto-configures the client beans automatically when credentials are present. Zero @Bean methods. Zero configuration classes.

Optional β€” Metrics & Health (v1.2.0)

Add spring-boot-starter-actuator and everything registers automatically. Opt-out if needed:

nepalpay:
  metrics:
    enabled: true    # opt-out: set false to disable Micrometer metrics
  health:
    enabled: true    # opt-out: set false to disable /actuator/health indicators

Optional β€” Retry

nepalpay:
  khalti:
    retry:
      enabled: true          # disabled by default β€” opt-in
      max-attempts: 3
      initial-delay-ms: 500
      multiplier: 2.0
      max-delay-ms: 5000

See the full configuration reference β†’

Khalti Quickstart

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final KhaltiClient khaltiClient; // auto-injected

    // Step 1: Initiate payment
    public String startKhalti(String orderId, long amountNPR) {
        var res = khaltiClient.initiatePayment(
            KhaltiInitiateRequest.builder()
                .amount(amountNPR * 100L)  // NPR β†’ paisa
                .purchaseOrderId(orderId)
                .purchaseOrderName("Your Product")
                .build()
        );
        // βœ… ALWAYS save pidx to DB before redirecting
        orderRepo.savePidx(orderId, res.pidx());
        return res.paymentUrl();
    }

    // Step 2: Verify after callback β€” NEVER trust redirect alone
    public boolean verifyKhalti(String pidx) {
        KhaltiLookupResponse lookup = khaltiClient.lookupPayment(pidx);

        // Load order from DB using saved pidx
        String orderId = orderRepo.findOrderByPidx(pidx);
        long expectedPaisa = orderRepo.getAmountPaisa(orderId);

        // Verify amount was not tampered
        if (!lookup.isAmountValid(expectedPaisa)) {
            log.error("SECURITY: Amount mismatch for pidx={}", pidx);
            return false;
        }
        return lookup.isPaymentSuccessful();
    }

    // Step 3: Full refund (uses transactionId β€” NOT pidx)
    public boolean refundKhalti(String transactionId) {
        KhaltiRefundResponse refund =
            khaltiClient.refundPayment(transactionId);
        return refund.isRefundSuccessful();
    }

    // Step 3b: Partial refund
    public boolean partialRefundKhalti(
            String transactionId, long amountPaisa) {
        KhaltiRefundResponse refund =
            khaltiClient.refundPayment(transactionId, amountPaisa);
        return refund.isRefundSuccessful();
    }
}
⚠️

Amount is in Paisa, not NPR. NPR 100 β†’ send 10000L. Minimum payment is NPR 10 = 1000L paisa. Refunds require transactionId from lookupPayment() β€” not pidx.

See the full Khalti guide β†’

eSewa Quickstart

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final EsewaClient esewaClient; // auto-injected

    // Step 1: Build signed form payload
    public EsewaFormPayload startEsewa(
            String orderId, BigDecimal amountNPR) {

        String uuid = EsewaClient.generateTransactionUuid();
        // βœ… Save uuid BEFORE returning to frontend
        orderRepo.saveUuid(orderId, uuid);

        // eSewa uses NPR directly β€” NOT paisa like Khalti
        return esewaClient.buildFormPayload(amountNPR, uuid);
        // Frontend POSTs all payload fields to payload.formActionUrl()
    }

    // Step 2: Verify after callback
    // data = Base64-encoded JSON from eSewa redirect query param
    public boolean verifyEsewa(String encodedData) {
        EsewaClient.EsewaVerificationResult result =
            esewaClient.verifyCallback(encodedData);
        // verifyCallback: decodes β†’ verifies HMAC β†’ calls status API
        return result.isPaymentSuccessful();
    }
}
⚠️

eSewa uses NPR directly β€” not paisa. NPR 100 β†’ send new BigDecimal("100.00"). eSewa status is "COMPLETE" not "COMPLETED" β€” do not confuse the two.

See the full eSewa guide β†’

Fonepay Quickstart

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final FonepayClient fonepayClient; // auto-injected
    private final OrderRepository orderRepo;

   // Step 1: Build signed redirect URL
    public String startFonepay(String orderId, double amountNPR) {
        String prn = "FP-" + orderId; // max 25 chars
        // βœ… Save prn BEFORE returning to frontend
        orderRepo.savePrn(orderId, prn);

        FonepayRedirectParams params = fonepayClient.buildRedirectParams(
            FonepayPaymentRequest.builder()
                .prn(prn)
                .amount(amountNPR)   // NPR directly β€” not paisa
                .remarks1("Your Product")
                .build()
        );
        // Frontend: window.location.href = this URL
        return params.redirectUrl();
    }

    // Step 2: Verify after callback
    public boolean verifyFonepay(FonepayCallbackResponse callback) {
        FonepayClient.FonepayVerificationResult result =
            fonepayClient.verifyCallback(callback);
        // verifyCallback: re-computes HMAC-SHA512, compares DV,
        // THEN checks PS=success β€” never trust PS alone
        return result.isPaymentSuccessful();
    }
}
}
ℹ️

Fonepay makes zero server-to-server HTTP calls β€” it is a URL redirect model. No retry configuration applies. The blocking FonepayClient works perfectly inside WebFlux applications.

See the full Fonepay guide β†’

ConnectIPS Quickstart

⚠️

Requires NCHL merchant registration. You will receive a merchant ID, app credentials, and a CREDITOR.pfx certificate file. Contact connectips@nchl.com.np .

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final ConnectIpsClient connectIpsClient; // auto-injected

    // Step 1: Build RSA-signed form payload
    public ConnectIpsFormPayload startConnectIps(
            String orderId, long amountNPR) {

        String txnId = "TXN-" + orderId + "-"
                + System.currentTimeMillis();
        // βœ… Save txnId BEFORE returning to frontend
        orderRepo.saveTxnId(orderId, txnId);

        return connectIpsClient.buildFormPayload(
            ConnectIpsPaymentRequest.builder()
                .txnId(txnId)
                .amountNPR(amountNPR)   // auto-converts to paisa Γ—100
                .referenceId(orderId)
                .remarks("Payment for " + orderId)
                .build()
        );
        // Frontend POSTs UPPERCASE form fields to payload.formActionUrl()
    }

    // Step 2: Validate after callback β€” NEVER trust redirect
    public boolean verifyConnectIps(
            String txnId, String referenceId, long txnAmtPaisa) {

        ConnectIpsValidateResponse res =
            connectIpsClient.validateTransaction(
                txnId, referenceId, txnAmtPaisa);
        return res.isPaymentSuccessful();
    }
}

See the full ConnectIPS guide β†’

Reactive (WebFlux) Quickstart

ℹ️

The reactive starter returns Mono<T> for all HTTP-calling methods. buildFormPayload() methods are synchronous β€” no Mono needed there. All validation is inside Mono.defer() so errors are always emitted as reactive signals, never thrown.

Inject reactive clients

@Service
@RequiredArgsConstructor
public class ReactivePaymentService {

    // Auto-injected from reactive starter
    private final KhaltiReactiveClient    khaltiReactiveClient;
    private final EsewaReactiveClient     esewaReactiveClient;
    private final ConnectIpsReactiveClient connectIpsReactiveClient;

    // FonepayClient from blocking starter β€” no HTTP calls,
    // works in reactive apps without any changes
    private final FonepayClient fonepayClient;
}

Khalti β€” Reactive

// Step 1: Initiate
public Mono<String> startKhalti(String orderId, long amountNPR) {
    return khaltiReactiveClient.initiatePayment(
        KhaltiInitiateRequest.builder()
            .amount(amountNPR * 100L)
            .purchaseOrderId(orderId)
            .purchaseOrderName("Your Product")
            .build()
    )
    .doOnNext(res -> orderRepo.savePidx(orderId, res.pidx()))
    .map(KhaltiInitiateResponse::paymentUrl);
}

// Step 2: Verify after callback
public Mono<Boolean> verifyKhalti(String pidx) {
    return khaltiReactiveClient.lookupPayment(pidx)
        .flatMap(lookup -> {
            String orderId = orderRepo.findOrderByPidx(pidx);
            long expectedPaisa = orderRepo.getAmountPaisa(orderId);
            if (!lookup.isAmountValid(expectedPaisa)) {
                return Mono.just(false);
            }
            return Mono.just(lookup.isPaymentSuccessful());
        });
}

eSewa β€” Reactive

// Step 1: Build payload β€” synchronous (no HTTP)
public EsewaFormPayload startEsewa(
        String orderId, BigDecimal amount) {
    String uuid = EsewaReactiveClient.generateTransactionUuid();
    orderRepo.saveUuid(orderId, uuid); // save first!
    return esewaReactiveClient.buildFormPayload(amount, uuid);
}

// Step 2: Verify callback β€” reactive
public Mono<Boolean> verifyEsewa(String encodedData) {
    return esewaReactiveClient.verifyCallback(encodedData)
        .map(EsewaReactiveClient.EsewaVerificationResult
                ::isPaymentSuccessful);
}

ConnectIPS β€” Reactive

// Step 1: Build payload β€” synchronous (RSA signing, no HTTP)
public ConnectIpsFormPayload startConnectIps(
        String orderId, long amountNPR) {
    String txnId = "TXN-" + orderId + "-"
            + System.currentTimeMillis();
    orderRepo.saveTxnId(orderId, txnId);
    return connectIpsReactiveClient.buildFormPayload(
        ConnectIpsPaymentRequest.builder()
            .txnId(txnId)
            .amountNPR(amountNPR)
            .referenceId(orderId)
            .build()
    );
}

// Step 2: Validate β€” reactive (validation + RSA inside Mono.defer)
public Mono<Boolean> verifyConnectIps(
        String txnId, String referenceId, long txnAmtPaisa) {
    return connectIpsReactiveClient
        .validateTransaction(txnId, referenceId, txnAmtPaisa)
        .map(ConnectIpsValidateResponse::isPaymentSuccessful);
}

Fonepay in Reactive Apps

// FonepayClient from blocking starter β€” no HTTP calls,
// no event-loop blocking. Use directly.
public String startFonepay(String orderId, double amountNPR) {
    String prn = "FP-" + orderId;
    orderRepo.savePrn(orderId, prn);
    return fonepayClient.buildRedirectParams(
        FonepayPaymentRequest.builder()
            .prn(prn)
            .amount(amountNPR)
            .remarks1("Your Product")
            .build()
    ).redirectUrl();
}

// If you need to wrap in Mono for pipeline composition:
public Mono<Boolean> verifyFonepay(FonepayCallbackResponse callback) {
    return Mono.fromCallable(() ->
        fonepayClient.verifyCallback(callback)
                     .isPaymentSuccessful())
        .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
}

See the full Reactive guide β†’

Sandbox Credentials

eSewa Sandbox

Official sandbox credentials from developer.esewa.com.np:

FieldValue
eSewa ID9806800001
PasswordNepal@123
MPIN1122
Token123456
Secret Key8gBm/:&EnhH.1/q
Product CodeEPAYTEST

Khalti Sandbox

Get your test secret key from test-admin.khalti.com . The sandbox API base URL is https://dev.khalti.com/api/v2.

Fonepay Sandbox

Sandbox gateway: https://dev.fonepay.com/api/merchantRequest
Credentials are provided by Fonepay after registration via your bank. Set sandbox: true in application.yml.

ConnectIPS Sandbox (UAT)

UAT gateway: https://uat.connectips.com
Requires NCHL merchant registration. Contact connectips@nchl.com.np to obtain your UAT credentials and CREDITOR.pfx file.

⚠️

Never commit credentials to Git. Even sandbox credentials should be in environment variables or a local .env file added to .gitignore.

Amount Units Per Gateway

The most common source of integration bugs. Each gateway uses different units and Java types. The same units apply for both blocking and reactive clients.

Khalti
Paisa
NPR 100 β†’ 10000L
eSewa
NPR
NPR 100 β†’ BigDecimal("100.00")
Fonepay
NPR
NPR 100 β†’ 100.0
ConnectIPS
Paisa
NPR 100 β†’ 10000L
ℹ️

Use .amountNPR(100L) on ConnectIpsPaymentRequest.builder() for automatic NPR β†’ paisa conversion. Or use .txnAmtPaisa(10000L) if you already have paisa.