My previous article ended with NepalPay being published to Maven Central.
Khalti Refund API ✅ v0.5.0
Retry with Backoff ✅ v0.6.0
Maven Central ✅ v1.0.0
The library worked.
Tests passed.
Developers could install it with a single dependency.
But there was still one major problem.
What happens when something goes wrong in production?
Not whether a payment succeeds—that's what lookupPayment() is for.
I mean:
- Is the gateway configured correctly?
- Is it running against Sandbox or Production?
- How long are API calls taking?
- How often are retries actually happening?
- Are callback signature failures increasing?
Until now, NepalPay couldn't answer any of those questions.
Version 1.2.0 changes that.
Micrometer Metrics ✅
Health Indicators ✅
Reactive Improvements ✅
400+ Tests ✅
The Problem
Imagine a Khalti payment suddenly starts failing.
Without observability you only know one thing:
The payment failed.
You don't know:
- whether it timed out
- whether retry fired
- how long it took
- whether 1 request failed or every request failed
Production systems need answers.
Micrometer Metrics
NepalPay now records metrics automatically whenever
spring-boot-starter-actuator is present.
No configuration required.
Every gateway records operation-specific timers.
nepalpay.khalti.payment.initiate.duration
nepalpay.khalti.payment.lookup.duration
nepalpay.khalti.payment.refund.duration
nepalpay.esewa.callback.verify.duration
nepalpay.esewa.status.check.duration
nepalpay.connectips.validate.duration
Each metric is tagged with
- gateway
- sandbox/production
- success/error
That means Grafana can immediately answer questions like:
What is the P99 latency for Khalti payment initiation?
histogram_quantile(
0.99,
rate(nepalpay_khalti_payment_initiate_duration_seconds_bucket[5m])
)
Retry Counters
Retries are now measurable too.
nepalpay.khalti.retry.attempts
One interesting bug appeared during development.
Originally all reactive retry paths shared one helper:
metrics.incrementInitiateRetry();
That meant:
- lookup retries incremented initiate
- refund retries incremented initiate
The metrics were wrong.
CodeRabbit spotted it during review.
The fix was simple:
Pass a retry callback into every operation instead of hardcoding one counter.
Security Metrics
Signature verification failures are now tracked.
nepalpay.esewa.callback.signature.failed
nepalpay.fonepay.callback.signature.failed
Suddenly these become security alerts instead of silent failures.
Example Grafana alert:
rate(nepalpay_esewa_callback_signature_failed_total[5m]) > 5
Actuator Health Indicators
Every configured gateway automatically registers its own health component.
GET /actuator/health
Example:
{
"status": "UP",
"components": {
"nepalpayKhalti": {
"status": "UP",
"details": {
"gateway": "Khalti",
"mode": "SANDBOX"
}
},
"nepalpayConnectIps": {
"status": "UP",
"details": {
"pfxLoaded": true
}
}
}
}
Notice something:
There is no HTTP ping.
That was intentional.
Sandbox APIs often rate limit.
Health checks should verify configuration—not internet connectivity.
Reactive Starter Improvements
The reactive starter shipped in v1.1.0.
Version 1.2.0 hardened it.
Every validation step now lives inside Mono.defer().
Instead of throwing exceptions immediately:
validateRequest(request);
everything now becomes a proper reactive error signal:
return Mono.defer(() -> {
validateRequest(request);
return webClient.post()...
});
This keeps operators like:
onErrorResume()onErrorReturn()retryWhen()
working correctly.
Reactive Timing
Micrometer's traditional timing API is blocking.
Reactive applications require a different pattern.
Timer.Sample sample = Timer.start();
return source
.doOnSuccess(v -> sample.stop(...))
.doOnError(e -> sample.stop(...));
No blocking.
No scheduler switching.
Pure Reactor.
What CodeRabbit Found
I use CodeRabbit on every PR.
For v1.2.0 it found 19 issues.
The most important ones:
Retry counters attributed to the wrong operation
Every retry became an initiate retry.
Fixed.
Missing timer inside verifyCallback()
Internal calls bypassed the public timed method.
Status metrics disappeared.
Fixed.
Logging decoded callback JSON
Originally:
log.debug(jsonString);
That JSON is attacker-controlled.
Removed.
Transport failures skipped retry
Network failures were wrapped as generic exceptions.
Retries never happened.
Now transport failures are caught separately.
Constant-time signature comparison
Replaced
String.equals()
with
MessageDigest.isEqual()
to avoid timing attacks.
Multi-Module Challenge
One design problem surprised me.
Where should the metrics classes live?
Originally they lived inside the Boot 3 starter.
The reactive starter depended on Boot 3.
Spring Boot then reported:
Duplicated prefix 'nepalpay'
The solution:
Move all metrics classes into
nepal-pay-core
Every starter already depends on it.
No duplicate configuration.
No circular dependencies.
Spring Boot 4.1.0 Health API
Boot 4.1.0 moved health APIs from
org.springframework.boot.actuate.health
to
org.springframework.boot.health.contributor
and split them into a dedicated module.
Boot 4 therefore requires:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-health</artifactId>
</dependency>
Zero Configuration
Simply add:
spring-boot-starter-actuator
Everything else configures automatically.
Disable if desired:
nepalpay:
metrics:
enabled: false
health:
enabled: false
What's Next
Upcoming roadmap:
- ConnectIPS configurable timeout
- Kotlin examples
- eSewa Refund API
- Webhook support
GitHub
https://github.com/sujankim/nepal-pay-spring-boot-starter
Documentation
https://sujankim.github.io/nepal-pay-spring-boot-starter/
Maven Central
https://central.sonatype.com/search?q=nepal-pay
If NepalPay saves you time, consider giving the project a ⭐ on GitHub.
It helps more Nepali developers discover the library.
Top comments (0)