DEV Community

Sujan Lamichhane
Sujan Lamichhane

Posted on

I Published Nepal's First Java Payment Library to Maven Central β€” Here's What Broke


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
Enter fullscreen mode Exit fullscreen mode

All three are now done.

Khalti Refund API    βœ… v0.5.0
Retry with Backoff   βœ… v0.6.0
Maven Central        βœ… v1.0.0
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

No repositories block.

No JitPack.

Just:

mvn dependency:resolve
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

What you might try first:

khaltiClient.refundPayment(pidx); // ❌ WRONG
Enter fullscreen mode Exit fullscreen mode

What you actually need:

KhaltiLookupResponse lookup =
    khaltiClient.lookupPayment(pidx);

khaltiClient.refundPayment(
    lookup.transactionId()
); // βœ… CORRECT
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ” 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
Enter fullscreen mode Exit fullscreen mode

With this configuration:

  1. Wait 500ms
  2. Retry
  3. Wait 1000ms
  4. Retry
  5. Wait 2000ms
  6. 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);
}
Enter fullscreen mode Exit fullscreen mode

500ms becomes somewhere between:

450ms <-> 550ms
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Maven Central tried to resolve the parent POM.

But the parent had never been published.

Result:

Failed to associate file with coordinates...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

inside the file.

Those placeholders stayed as literal strings.

Every deployment returned:

401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

The fix:

Let actions/setup-java handle everything.


Mistake #5 β€” GPG Import with echo Loses Newlines

echo "${{ secrets.GPG_PRIVATE_KEY }}" |
gpg --batch --import
Enter fullscreen mode Exit fullscreen mode

Result:

gpg: no valid OpenPGP data found.
Enter fullscreen mode Exit fullscreen mode

The key was corrupted.

The fix:

gpg-private-key:
  ${{ secrets.GPG_PRIVATE_KEY }}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)