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.