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.
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()); // ACTIVEThe 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.
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.
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.
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(...);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.
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 merchantTxRefEvery 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.
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).
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_pendingand activate when the customer's bank confirms — listen forpayment_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_dueis not canceled. Readsubscriptions().dunning().retrieve(...)and honorgraceAccessUntil()before cutting anyone off.mandates().retrieve(...)returns aPaymentMethod, not a mandate object.invoices().voidInvoice(...)is named to avoid thevoidreserved word.
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 useRunnable 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.QuickstartTo 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 testJava 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.