Skip to content

nombaone/nombaone-java

Repository files navigation

NombaOne Java SDK

The official Java SDK for the Nomba One subscription-billing API — recurring billing for Nigeria over card, direct debit, and bank transfer, with dunning that recovers and a ledger that never loses a kobo.

<dependency>
  <groupId>xyz.nombaone</groupId>
  <artifactId>nombaone</artifactId>
  <version>0.1.0</version>
</dependency>
// Gradle
implementation("xyz.nombaone:nombaone:0.1.0")

Requires Java 17+. One runtime dependency (Jackson); transport is the JDK's java.net.http.HttpClient. Thread-safe — build one Nombaone and reuse it.

Quickstart

Grab a sandbox key (nbo_sandbox_…) from the dashboard, set it as NOMBAONE_API_KEY, and you are a few objects away from a live subscription:

import xyz.nombaone.Nombaone;
import xyz.nombaone.customers.*;
import xyz.nombaone.plans.*;
import xyz.nombaone.prices.*;
import xyz.nombaone.sandbox.*;
import xyz.nombaone.subscriptions.*;

Nombaone nombaone = new Nombaone(System.getenv("NOMBAONE_API_KEY"));

var plan = nombaone.plans().create(PlanCreateParams.builder().name("Pro").build());
var price = nombaone.plans().prices().create(
    plan.id(),
    PriceCreateParams.builder()
        .unitAmountInKobo(250_000)          // ₦2,500.00 per month
        .interval(PriceInterval.MONTH)
        .build());
var customer = nombaone.customers().create(
    CustomerCreateParams.builder().email("ada@example.com").name("Ada Lovelace").build());

// Sandbox: mint a deterministic test card, then subscribe.
var method = nombaone.sandbox().createPaymentMethod(
    SandboxPaymentMethodParams.builder().customerId(customer.id()).build());
var subscription = nombaone.subscriptions().create(
    SubscriptionCreateParams.builder()
        .customerId(customer.id())
        .priceId(price.id())
        .paymentMethodId(method.id())
        .build());

System.out.println(subscription.status()); // ACTIVE

The client derives its host from your key prefix — nbo_sandbox_… talks to https://sandbox.api.nombaone.xyz, nbo_live_… to https://api.nombaone.xyz. Server-side only; there is no publishable key to leak.

Sandbox first

The sandbox runs the real billing engine. nombaone.sandbox() gives you the levers to make a month happen in a second:

// A card that declines like a thin balance does — "not yet", not "no".
nombaone.sandbox().createPaymentMethod(
    SandboxPaymentMethodParams.builder()
        .customerId(customer.id())
        .behavior(SandboxPaymentMethodBehavior.DECLINE_INSUFFICIENT_FUNDS)
        .build());

// The test clock: force the next billing cycle through the real engine.
var cycle = nombaone.sandbox().advanceCycle(subscription.id());
System.out.println(cycle.outcome()); // "paid" | "past_due" | …

// Fire a real, signed webhook at your registered endpoints.
nombaone.sandbox().simulateWebhook(
    SandboxSimulateWebhookParams.builder().type("invoice.payment_failed").build());

Every sandbox() method throws locally, before any network call, if used with a live key.

Money is integer kobo

Every amount in the API is an integer number of kobo (long): ₦1.00 = 100. 250_000 is ₦2,500 — not ₦250,000. No floats, no BigDecimal, no decimal strings; currency is always "NGN". Multiply naira by 100 exactly once, at the edge of your system; every money field is named …InKobo so a mixup is hard to type.

Pagination

Every list(...) works three ways:

// One page.
Page<Invoice> page = nombaone.invoices().list(
    InvoiceListParams.builder().status(InvoiceStatus.OPEN).limit(50).build());
page.data();
page.pagination().hasMore();
page.pagination().nextCursor();

// Manual paging.
if (page.hasNextPage()) {
  Page<Invoice> next = page.nextPage();
}

// Or let the SDK thread the cursors — over every item, across every page.
for (Invoice invoice : nombaone.invoices().list().autoPager()) { ... }
nombaone.invoices().list().stream().filter(i -> i.amountDueInKobo() > 0).forEach(...);

Errors are a feature

Failures throw typed unchecked exceptions carrying everything the API said — the stable code() to branch on, a hint() telling you exactly what to do next (folded into the message), a docUrl() into the error reference, per-field details on validation failures, and the requestId() to quote to support:

import xyz.nombaone.error.*;

try {
  nombaone.subscriptions().create(params);
} catch (ValidationException e) {
  e.fields().forEach((field, messages) -> log.warn("{}: {}", field, messages)); // 422 details
} catch (RateLimitException e) {
  e.retryAfter().ifPresent(seconds -> sleep(seconds));                          // seconds
} catch (NotFoundException e) {
  log.info(e.code());                                                           // "CUSTOMER_NOT_FOUND"
} catch (NombaoneException e) {
  log.error("request {}: {}", e instanceof ApiException a ? a.requestId() : "-", e.getMessage());
}
Status Exception Notes
400 BadRequestException malformed request
401 AuthenticationException missing/invalid/wrong-environment key
403 PermissionDeniedException missing scope, foreign resource
404 NotFoundException wrong id or wrong environment
409 ConflictException state conflicts, idempotency reuse
422 ValidationException e.fields() has the per-field messages
429 RateLimitException retryAfter(), limit(), remaining()
5xx ServerException safe to retry (the SDK already did)
ConnectionException / TimeoutException transport-level
WebhookVerificationException webhook signature/timestamp failure

All extend NombaoneException. Branch on the subclass or on code() — never on the message text.

Idempotency & retries

The SDK auto-generates an Idempotency-Key for every POST and reuses it across its automatic retries (network failures, timeouts, 408/429/5xx — 2 retries by default, honoring Retry-After), so a blip can never double-charge. Pass your own key when the operation must stay idempotent across process restarts:

nombaone.settlements().createPayout(
    PayoutCreateParams.builder().amountInKobo(5_000_000).bankCode("058").accountNumber("0123456789").build(),
    RequestOptions.builder().idempotencyKey("payout-" + myPayout.id()).build()); // ⚠ doubles as the payout's durable merchantTxRef

Every method also accepts a trailing RequestOptions (idempotencyKey, headers, timeout, maxRetries, and an onResponse callback for the raw ResponseInfo — request id, status, headers). A caller can cancel a blocking call by interrupting the thread; a cancellation is never retried.

Webhooks

Verify before you parse, and dedupe on the event id — delivery is at-least-once, never exactly-once. The helper lives in the dependency-free xyz.nombaone.webhook package and needs only the signing secret, never an API key — a receiver can depend on it alone.

Feed it the raw request bytes. Re-serializing JSON reorders keys and breaks the signature. Capture the body before any framework parses it — in Spring, a @RequestBody byte[]; in a servlet, request.getInputStream().

import xyz.nombaone.webhook.*;

var webhooks = nombaone.webhooks(); // or: new xyz.nombaone.webhook.Webhooks();

WebhookEvent event = webhooks.constructEvent(
    rawBody,                                          // the RAW body — never re-serialize
    request.getHeader("X-Nombaone-Signature"),
    System.getenv("NOMBAONE_WEBHOOK_SECRET"));        // shown once when you created the endpoint

if (alreadyProcessed(event.event().id())) return;     // at-least-once ⇒ dedupe on event().id()

switch (event.type()) {
  case WebhookEventType.INVOICE_PAID -> unlock(((GenericEvent) event).reference());
  default -> {}
}
if (event instanceof InvoiceActionRequiredEvent e) {  // typed payloads for the events that carry one
  email(customer, e.data().checkoutLink());
} else if (event instanceof InvoicePaymentFailedEvent e) {
  note(e.data().reason());
}

constructEvent checks the X-Nombaone-Signature (t=<unix>,v1=<hex>, HMAC-SHA256 over "{t}.{body}") in constant time, tolerates multiple v1= pairs during secret rotation, rejects stale timestamps (300s tolerance, configurable), and returns a sealed WebhookEvent — the eight events with a non-trivial payload have their own typed record; every other type (including one added tomorrow) arrives as a GenericEvent, so the union stays open. webhooks.generateTestHeader(...) lets you unit-test your handler. Manage endpoints via nombaone.webhookEndpoints() (create/rotateSecret return the secret exactly once).

The full surface

customers() (+credit, discount) · plans() (+nested prices()) · prices() · subscriptions() (pause/resume/cancel/resubscribe/change, schedule(), dunning(), upcoming invoice, events) · invoices() · coupons() · paymentMethods() (hosted-checkout cards, virtual accounts) · mandates() (NIBSS direct debit) · settlements() (escrow, refunds, payouts) · webhookEndpoints() (+deliveries(), replay) · events() (+catalog) · organization() (+billing()) · metrics() · sandbox() · webhooks() — every operation in the API reference, 1:1.

Worth knowing:

  • Mandates are asynchronous. They start consent_pending and activate when the customer's bank confirms — listen for payment_method.updated, don't poll, don't charge early.
  • Bank transfer is a push rail. paymentMethods().createVirtualAccount(...) issues a NUBAN; collection completes when the transfer arrives and reconciles.
  • past_due is not canceled. Read subscriptions().dunning().retrieve(...) and honor graceAccessUntil() before cutting anyone off.
  • mandates().retrieve(...) returns a PaymentMethod, not a mandate object.
  • invoices().voidInvoice(...) is named to avoid the void reserved word.

Configuration

Nombaone nombaone = new Nombaone(
    apiKey,                                  // or NOMBAONE_API_KEY
    ClientOptions.builder()
        .baseUrl("https://sandbox.api.nombaone.xyz") // override the derived host
        .timeout(Duration.ofSeconds(30))             // per-attempt timeout
        .maxRetries(2)                               // automatic retry budget
        .httpTransport(myTransport)                  // bring your own transport (tests, proxies)
        .defaultHeader("X-My-App", "acme/1.0")       // sent on every request
        .build());

nombaone.mode();     // SANDBOX or LIVE, derived from the key
nombaone.baseUrl();  // the API origin in use

Examples & development

Runnable programs live in examples/ — quickstart, pagination, the subscription lifecycle, a self-contained webhook receiver, and a sandbox dunning rehearsal with the test clock. Run one:

mvn -q install                                                   # install the SDK locally
cd examples && NOMBAONE_API_KEY=nbo_sandbox_… \
  mvn -q compile exec:java -Dexec.mainClass=xyz.nombaone.examples.Quickstart

To develop the SDK itself:

mvn verify              # format-check + compile + unit & conformance tests
mvn spotless:apply      # auto-format
mvn test                # tests only

# Live integration suite (opt-in; needs a sandbox key):
NOMBAONE_INTEGRATION=1 NOMBAONE_API_KEY=nbo_sandbox_… mvn -Dtest=LiveSandboxIntegrationTest test

Requirements & versioning

Java 17+ (built on the JDK HttpClient). Semantic versioning; the API itself is versioned at /v1 and additive changes never break you. MIT licensed — see LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages