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)