A few days ago, I wrote about building NepalPay β an open-source Spring Boot starter for Nepal's payment gateways (Khalti, eSewa, ConnectIPS, and Fonepay).
That post ended with a roadmap:
Khalti Refund API π² Planned
Retry with Backoff π² Planned
Maven Central π² Planned
All three are now done.
Khalti Refund API β
v0.5.0
Retry with Backoff β
v0.6.0
Maven Central β
v1.0.0
This post is the honest account of how I got there β including every mistake, every failed deployment, and what I wish someone had told me before I started.
NepalPay Is Now on Maven Central
<dependency>
<groupId>io.github.sujankim</groupId>
<artifactId>nepal-pay-spring-boot-3-starter</artifactId>
<version>1.0.0</version>
</dependency>
No repositories block.
No JitPack.
Just:
mvn dependency:resolve
and you're done.
π³ The Thing Nobody Told Me About Khalti Refunds
When I started building the refund API, I assumed it would use pidx β the identifier I already stored from payment initiation.
It does not.
Khalti refunds use transaction_id.
A completely different identifier.
One that only exists after a payment reaches Completed status.
You get it from:
lookupPayment(pidx).transactionId()
What you might try first:
khaltiClient.refundPayment(pidx); // β WRONG
What you actually need:
KhaltiLookupResponse lookup =
khaltiClient.lookupPayment(pidx);
khaltiClient.refundPayment(
lookup.transactionId()
); // β
CORRECT
Then I found the second surprise.
The refund endpoint has a completely different URL path:
Initiate: https://dev.khalti.com/api/v2/epayment/initiate/
Lookup: https://dev.khalti.com/api/v2/epayment/lookup/
Refund: https://dev.khalti.com/api/merchant-transaction/{transaction_id}/refund/
Notice the refund path has no /api/v2.
It is a different API tree entirely.
I ended up adding:
private final String baseUrl;
private final String baseDomain;
just to construct refund URLs correctly.
NepalPay now supports both:
// Full refund
khaltiClient.refundPayment(
lookup.transactionId()
);
// Partial refund
khaltiClient.refundPayment(
lookup.transactionId(),
5000L
); // NPR 50
π Why I Added Retry β and Why It Defaults to Off
nepalpay:
khalti:
retry:
enabled: true
max-attempts: 3
initial-delay-ms: 500
multiplier: 2.0
max-delay-ms: 5000
With this configuration:
- Wait 500ms
- Retry
- Wait 1000ms
- Retry
- Wait 2000ms
- Throw an exception
I made retry opt-in deliberately.
Libraries should not silently change the response-time characteristics of existing applications.
If retry was enabled automatically, upgrading NepalPay could suddenly make API calls take several seconds longer.
Opt-in means developers decide when they are ready.
I also had to deal with a distributed systems problem called the thundering herd.
A thousand clients retrying at exactly the same millisecond can keep a failing gateway permanently overloaded.
The fix is jitter.
public static long jitter(long delayMs) {
if (delayMs <= 0) return 0;
long range = (long) (delayMs * 0.1);
long offset =
(long) ((Math.random() * 2 * range) - range);
return Math.max(0, delayMs + offset);
}
500ms becomes somewhere between:
450ms <-> 550ms
All clients retry at slightly different times.
The gateway gets a spread of traffic instead of a spike.
It can recover.
Never retry 4xx errors.
A 401 Unauthorized means your secret key is wrong.
Retrying it three times changes nothing.
Only:
- 5xx server errors
- Network timeouts
are retried.
Fonepay has no retry at all.
It makes zero server-to-server HTTP calls.
There is nothing to retry.
π¦ Maven Central: Five Failed Deployments
Getting onto Maven Central was much harder than I expected.
Mistake #1 β OSSRH Is Dead
Every guide from 2022 told me to use:
nexus-staging-maven-plugin
It failed immediately.
OSSRH was sunset on June 30, 2025.
The new world is:
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
</plugin>
Any guide older than mid-2025 is outdated.
Mistake #2 β The Parent POM Chicken and Egg
nepal-pay-parent
βββ nepal-pay-core
βββ boot3-starter
βββ boot4-starter
Maven Central tried to resolve the parent POM.
But the parent had never been published.
Result:
Failed to associate file with coordinates...
Twenty-four times.
The fix:
Make nepal-pay-core completely standalone.
Mistake #3 β The Effective POM Is Not Your pom.xml
I kept looking at my source POM.
That wasn't the file being published.
Maven publishes the effective POM.
The fix:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.6.0</version>
</plugin>
flattenMode=ossrh solved the problem immediately.
Mistake #4 β GitHub Actions Credential Injection
I accidentally overwrote a perfectly valid settings.xml.
I also tried:
${env.CENTRAL_TOKEN_USERNAME}
inside the file.
Those placeholders stayed as literal strings.
Every deployment returned:
401 Unauthorized
The fix:
Let actions/setup-java handle everything.
Mistake #5 β GPG Import with echo Loses Newlines
echo "${{ secrets.GPG_PRIVATE_KEY }}" |
gpg --batch --import
Result:
gpg: no valid OpenPGP data found.
The key was corrupted.
The fix:
gpg-private-key:
${{ secrets.GPG_PRIVATE_KEY }}
inside actions/setup-java.
No manual import.
No corruption.
π The Result
Today NepalPay has:
- β Khalti
- β eSewa
- β ConnectIPS
- β Fonepay
- β Refund support
- β Retry with exponential backoff
- β Spring Boot 3.2+
- β Spring Boot 4.x
- β Maven Central publishing
- β 350+ tests
And developers can integrate Nepal payments with:
KhaltiInitiateResponse response =
khaltiClient.initiatePayment(request);
return response.paymentUrl();
instead of hundreds of lines of HTTP and cryptography boilerplate.
π Links
GitHub
https://github.com/sujankim/nepal-pay-spring-boot-starter
Maven Central
https://central.sonatype.com/search?q=nepal-pay
Documentation
https://sujankim.github.io/nepal-pay-spring-boot-starter/
If NepalPay saves you time, a β on GitHub helps other Nepali developers discover it.
Found a bug? Open an issue.
Want to contribute? Open a PR.
Built with β€οΈ for Nepal's developer community π³π΅
Top comments (0)