Why a Reactive Starter
Spring WebFlux applications run on a small, fixed thread pool managed by Reactor Netty. Blocking HTTP calls on these threads stall the entire application.
The blocking starters use RestClient
β which blocks a thread per request.
In a WebFlux app this would exhaust the event-loop
threads under load.
The reactive starter uses WebClient
which returns Mono<T> and never
blocks a thread. The event loop is free to handle
other requests while waiting for the gateway response.
The reactive starter uses the identical
nepalpay.* YAML configuration
as the blocking starters. Only the artifact name
and injected bean types differ.
How It Works
All validation is inside
Mono.defer().
This is the critical reactive rule β if validation
throws outside the pipeline, it bypasses
onErrorResume and all other reactive
error handlers. NepalPay wraps all validation,
RSA signing, and HMAC operations inside
Mono.defer() so every error is
emitted as a reactive signal.
// β
Correct β validation inside Mono.defer()
public Mono<KhaltiInitiateResponse> initiatePayment(
KhaltiInitiateRequest request) {
return Mono.defer(() -> {
try {
validateRequest(request); // throws inside defer
} catch (KhaltiException e) {
return Mono.error(e); // emitted as signal
}
return webClient.post()...;
});
}
// β Wrong β throws outside pipeline, bypasses all error handlers
public Mono<KhaltiInitiateResponse> initiatePayment(
KhaltiInitiateRequest request) {
validateRequest(request); // throws here β not reactive
return webClient.post()...;
}
Install
<!-- Reactive starter for Khalti, eSewa, ConnectIPS -->
<dependency>
<groupId>io.github.sujankim</groupId>
<artifactId>nepal-pay-spring-boot-reactive-starter</artifactId>
<version>1.2.0</version>
</dependency>
<!-- Add ONLY if you also need FonepayClient -->
<!-- Boot 3.2+ / Java 17+ -->
<dependency>
<groupId>io.github.sujankim</groupId>
<artifactId>nepal-pay-spring-boot-3-starter</artifactId>
<version>1.2.0</version>
</dependency>
<!-- OR Boot 4.x / Java 21+ -->
<dependency>
<groupId>io.github.sujankim</groupId>
<artifactId>nepal-pay-spring-boot-4-starter</artifactId>
<version>1.2.0</version>
</dependency>
// Reactive starter
implementation 'io.github.sujankim:nepal-pay-spring-boot-reactive-starter:1.2.1'
// Add blocking starter ONLY if you also need FonepayClient
implementation 'io.github.sujankim:nepal-pay-spring-boot-3-starter:1.2.1'
// Reactive starter
implementation("io.github.sujankim:nepal-pay-spring-boot-reactive-starter:1.2.1")
// Add blocking starter ONLY if you also need FonepayClient
implementation("io.github.sujankim:nepal-pay-spring-boot-3-starter:1.2.1")
Fonepay makes zero server-side HTTP calls (URL redirect only), so you only need a blocking starter alongside the reactive starter if you use Fonepay. Choose the blocking starter that matches your Spring Boot version:
-
Spring Boot 3.2+ / Java 17+ β
nepal-pay-spring-boot-3-starter -
Spring Boot 4.x / Java 21+ β
nepal-pay-spring-boot-4-starter
Both share the same nepalpay.* YAML β
no duplicate configuration needed.
Configure
Identical YAML to the blocking starters. No extra reactive-specific properties needed.
nepalpay:
khalti:
secret-key: ${KHALTI_SECRET_KEY}
return-url: ${KHALTI_RETURN_URL}
website-url: ${YOUR_WEBSITE_URL}
sandbox: true
retry:
enabled: true
max-attempts: 3
initial-delay-ms: 500
multiplier: 2.0
max-delay-ms: 5000
esewa:
secret-key: ${ESEWA_SECRET_KEY}
product-code: ${ESEWA_PRODUCT_CODE}
success-url: ${ESEWA_SUCCESS_URL}
failure-url: ${ESEWA_FAILURE_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}
pfx-password: ${CONNECTIPS_PFX_PASSWORD}
timeout-seconds: 30
sandbox: true
# Metrics and health β opt-out
metrics:
enabled: true
health:
enabled: true
Inject Clients
@Service
@RequiredArgsConstructor
public class ReactivePaymentService {
// From reactive starter β all return Mono<T>
private final KhaltiReactiveClient khaltiReactiveClient;
private final EsewaReactiveClient esewaReactiveClient;
private final ConnectIpsReactiveClient connectIpsReactiveClient;
// From blocking starter β Fonepay makes no HTTP calls
// Works safely in reactive apps β no event-loop blocking
private final FonepayClient fonepayClient;
}
Zero @Bean Β· Zero
@EnableNepalPay Β· Zero configuration
classes. Spring Boot auto-configures all clients
when credentials are present in
application.yml.
Khalti β Reactive
// Initiate payment
public Mono<String> initiateKhalti(
String orderId, long amountNPR) {
return khaltiReactiveClient.initiatePayment(
KhaltiInitiateRequest.builder()
.amount(amountNPR * 100L) // NPR β paisa
.purchaseOrderId(orderId)
.purchaseOrderName("Pro Plan")
.build()
)
// doOnNext for side effects β save pidx before continuing
.doOnNext(res -> orderRepo.savePidx(orderId, res.pidx()))
.map(KhaltiInitiateResponse::paymentUrl);
}
// Lookup and verify β with amount validation
public Mono<Boolean> verifyKhalti(String pidx) {
return khaltiReactiveClient.lookupPayment(pidx)
.flatMap(lookup -> {
if (!lookup.isPaymentSuccessful()) {
return Mono.just(false);
}
String orderId = orderRepo.findOrderByPidx(pidx);
long expectedPaisa = orderRepo.getAmountPaisa(orderId);
if (!lookup.isAmountValid(expectedPaisa)) {
log.error("Amount mismatch | pidx={}", pidx);
return Mono.just(false);
}
return orderRepo.markPaid(
orderId, lookup.transactionId()
).thenReturn(true);
});
}
// Full refund
public Mono<Boolean> refundKhalti(String transactionId) {
return khaltiReactiveClient.refundPayment(transactionId)
.map(KhaltiRefundResponse::isRefundSuccessful);
}
// Partial refund β NPR 50 = 5000 paisa
public Mono<Boolean> partialRefundKhalti(
String transactionId, long amountPaisa) {
return khaltiReactiveClient
.refundPayment(transactionId, amountPaisa)
.map(KhaltiRefundResponse::isRefundSuccessful);
}
eSewa β Reactive
// Build payload β synchronous (no HTTP call)
public EsewaFormPayload startEsewa(
String orderId, BigDecimal amount) {
String uuid = EsewaReactiveClient.generateTransactionUuid();
orderRepo.saveUuid(orderId, uuid); // save first!
// Returns payload synchronously β no Mono needed
return esewaReactiveClient.buildFormPayload(amount, uuid);
}
// Verify callback β reactive (3 steps: decode β HMAC β status API)
public Mono<Boolean> verifyEsewa(String encodedData) {
return esewaReactiveClient.verifyCallback(encodedData)
.map(EsewaReactiveClient.EsewaVerificationResult
::isPaymentSuccessful);
}
// Check status directly β reactive
public Mono<Boolean> checkEsewaStatus(
String uuid, String totalAmount) {
return esewaReactiveClient.checkStatus(uuid, totalAmount)
.map(EsewaStatusResponse::isPaymentSuccessful);
}
ConnectIPS β Reactive
// Build form 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()
);
}
// Validate β reactive (validation + RSA signing inside Mono.defer)
@GetMapping("/connectips/callback")
public Mono<ResponseEntity<String>> callback(
@RequestParam String txnId,
@RequestParam String referenceId,
@RequestParam long txnAmt) {
return connectIpsReactiveClient
.validateTransaction(txnId, referenceId, txnAmt)
.flatMap(res -> {
if (!res.isPaymentSuccessful()) {
return Mono.just(ResponseEntity.badRequest()
.body("Not confirmed: " + res.statusDesc()));
}
return orderRepo.markPaid(referenceId)
.thenReturn(ResponseEntity.ok(
"Payment confirmed"));
});
}
Fonepay in WebFlux
Fonepay makes zero server-side HTTP calls
β it is a URL redirect model with only local HMAC
computation. The blocking FonepayClient
is safe to use directly in WebFlux apps.
// Use blocking client directly β no event-loop impact
// (HMAC computation is CPU-only, no I/O)
public String buildFonepayRedirect(
String orderId, double amountNPR) {
String prn = "FP-" + orderId;
orderRepo.savePrn(orderId, prn);
return fonepayClient.buildRedirectParams(
FonepayPaymentRequest.builder()
.prn(prn)
.amount(amountNPR)
.remarks1("Pro Plan")
.build()
).redirectUrl();
}
// If you need Mono for pipeline composition:
// subscribeOn(boundedElastic) offloads HMAC off the event-loop
public Mono<Boolean> verifyFonepay(
FonepayCallbackResponse callback) {
return Mono.fromCallable(() ->
fonepayClient.verifyCallback(callback)
.isPaymentSuccessful())
.subscribeOn(
reactor.core.scheduler.Schedulers.boundedElastic());
}
Retry with Reactor Backoff
The reactive starter uses
Retry.backoff() from Project Reactor β
fully non-blocking exponential backoff with jitter.
No Thread.sleep().
nepalpay:
khalti:
retry:
enabled: true
max-attempts: 3
initial-delay-ms: 500
multiplier: 2.0
max-delay-ms: 5000
| Gateway | Methods | Retry? |
|---|---|---|
| Khalti |
initiatePayment()
lookupPayment()
refundPayment()
|
β Yes |
| eSewa | checkStatus() inside
verifyCallback() |
β Yes |
| ConnectIPS | validateTransaction() |
β Yes |
| Fonepay | URL redirect β no HTTP calls | β N/A |
Retries: 5xx server errors
and WebClientRequestException
(network failures).
Never retried: 4xx client errors
and signature validation failures.
Refund retry safety: verify status
with lookupPayment() before retrying
to avoid double-refunds.
Error Handling
All NepalPay reactive clients throw typed exceptions β
KhaltiException, EsewaException,
ConnectIpsException,
FonepayException β all extending
NepalPayException.
// Handle specific gateway exception
khaltiReactiveClient.initiatePayment(request)
.onErrorResume(KhaltiException.class, ex -> {
log.error("Khalti failed | status={} | body={}",
ex.httpStatus(), ex.responseBody());
return Mono.error(ex); // re-throw or return fallback
});
// Handle any NepalPay exception
esewaReactiveClient.verifyCallback(data)
.onErrorResume(NepalPayException.class, ex ->
Mono.just(createFailureResponse(ex.getMessage()))
);
// Handle signature failure specifically
esewaReactiveClient.verifyCallback(data)
.onErrorResume(EsewaException.class, ex -> {
if (ex.getMessage().contains("signature verification FAILED")) {
log.error("SECURITY: eSewa HMAC mismatch β possible fraud!");
}
return Mono.error(ex);
});
Testing Reactive Clients
Use StepVerifier from
reactor-test and
MockWebServer from OkHttp3.
// Success test
StepVerifier.create(
khaltiReactiveClient.lookupPayment(pidx))
.expectNextMatches(res ->
res.isPaymentSuccessful()
&& res.paymentStatus() == KhaltiPaymentStatus.COMPLETED)
.verifyComplete();
// Validation error β no HTTP call made
StepVerifier.create(
khaltiReactiveClient.lookupPayment(null))
.expectErrorMatches(err ->
err instanceof KhaltiException
&& err.getMessage().contains(
"pidx cannot be null or blank"))
.verify();
// Verify no HTTP request was made (validation rejected before HTTP)
assertThat(mockWebServer.getRequestCount()).isEqualTo(0);
Add reactor-test to your test
dependencies:
testImplementation("io.projectreactor:reactor-test")
Reactive Rules
| Concern | Blocking | Reactive |
|---|---|---|
| HTTP client | RestClient |
WebClient |
| Return type | Direct value | Mono<T> |
| Error handling | throw new XxxException() |
Mono.error() |
| Validation | Immediate β throws | Inside Mono.defer() |
| RSA signing | Synchronous | Inside Mono.defer() |
| Retry | Thread.sleep() loop |
Retry.backoff() |
| Metrics timing | Supplier<T> wrapper |
Timer.Sample + doOnSuccess/doOnError |
| Testing | AssertJ assertions | StepVerifier |
Blocking vs Reactive β Which to Choose
| Your app uses⦠| Use |
|---|---|
Spring MVC (@RestController returning values directly) |
Blocking starter β Boot 3 or Boot 4 |
Spring WebFlux (controllers returning Mono/Flux) |
Reactive starter |
| Spring WebFlux + you need Fonepay | Reactive starter + matching Boot 3 or Boot 4 blocking starter |
| Not sure / just getting started | Blocking starter β simpler to reason about |
You can always start with the blocking starter
and migrate to reactive later by changing only
the artifact name.
The nepalpay.* YAML configuration
is identical between all starters.