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:
| Field | Value |
|---|---|
| eSewa ID | 9806800001 |
| Password | Nepal@123 |
| MPIN | 1122 |
| Token | 123456 |
| Secret Key | 8gBm/:&EnhH.1/q |
| Product Code | EPAYTEST |
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.
Use .amountNPR(100L) on
ConnectIpsPaymentRequest.builder()
for automatic NPR β paisa conversion.
Or use .txnAmtPaisa(10000L) if
you already have paisa.