nombaone
The official Ruby SDK for the Nomba One subscription-billing API — recurring billing for Nigeria over card, direct debit, bank transfer, and more, with dunning that recovers and a ledger that never loses a kobo.
gem install nombaone
# or add to your Gemfile:
gem "nombaone"
Requires Ruby 3.1+. Zero runtime dependencies (built on the standard library). Ships RBS type signatures.
Quickstart
Grab a sandbox key (nbo_sandbox_…) from the dashboard, set it as NOMBAONE_API_KEY, and you are three objects away from a live subscription:
require "nombaone"
nombaone = Nombaone.new(ENV["NOMBAONE_API_KEY"])
plan = nombaone.plans.create(name: "Pro")
price = nombaone.plans.prices.create(plan.id, unit_amount_in_kobo: 250_000, interval: "month") # ₦2,500/mo
customer = nombaone.customers.create(email: "ada@example.com", name: "Ada Lovelace")
# Sandbox: mint a deterministic test card, then subscribe.
method = nombaone.sandbox.create_payment_method(customer_id: customer.id)
subscription = nombaone.subscriptions.create(
customer_id: customer.id,
price_id: price.id,
payment_method_id: method.id,
)
subscription.status # => "active"
The client derives the 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.create_payment_method(
customer_id: customer.id,
behavior: "decline_insufficient_funds", # or success | requires_otp | decline_expired_card | decline_do_not_honor
)
# The test clock: force the next billing cycle through the real engine.
cycle = nombaone.sandbox.advance_cycle(subscription.id)
cycle.outcome # => "paid" | "past_due" | …
# Fire a real, signed webhook at your registered endpoints.
nombaone.sandbox.simulate_webhook(type: "invoice.payment_failed")
These methods raise locally (before any network call) if used with a live key.
Money is integer kobo
Every amount in the API is an integer in kobo: ₦1.00 = 100. 250_000 is ₦2,500 — not ₦250,000. No floats, no decimal strings, currency is always "NGN". Multiply naira by 100 exactly once, at the edge of your system; every money field is suffixed _in_kobo so a mixup is hard to type.
Response objects
Every result is a lightweight object with snake_case readers derived from the wire's camelCase, plus bracket access and a to_h escape hatch:
sub = nombaone.subscriptions.retrieve(id)
sub.status # => "active"
sub.current_period_end # from "currentPeriodEnd"
sub.items.first.price_id
sub[:latestInvoiceId] # bracket access (snake or wire, String or Symbol)
sub.to_h # the raw wire Hash
sub.request_id # the meta.requestId for this call
Pagination
Every list works three ways:
# One page.
page = nombaone.invoices.list(status: "open", limit: 50)
page.data # the items on this page
page.has_more?
page.next_cursor
# Manual paging.
page = page.next_page if page.next_page?
# Or let the SDK thread the cursors — a Page is Enumerable, and iteration is
# lazy (first(3) only fetches what it needs):
nombaone.invoices.list(status: "open").each do |invoice|
# every item across every page
end
recent = nombaone.customers.list.first(3)
Errors are a feature
Failures raise typed errors carrying everything the API said — the stable code to branch on, a hint telling you exactly what to do next, a doc_url into the error reference, per-field details on validation failures, and the request_id to quote to support:
begin
nombaone.subscriptions.create(customer_id: customer_id, price_id: price_id)
rescue Nombaone::ValidationError => e
e.fields # { "paymentMethodId" => [...] }
rescue Nombaone::RateLimitError => e
e.retry_after # seconds
rescue Nombaone::NotFoundError => e
e.code # "CUSTOMER_NOT_FOUND"
end
| Status | Class | Notes |
|---|---|---|
| 400 | BadRequestError |
malformed request |
| 401 | AuthenticationError |
missing/invalid/wrong-environment key |
| 403 | PermissionDeniedError |
missing scope, foreign resource |
| 404 | NotFoundError |
wrong id or wrong environment |
| 409 | ConflictError |
state conflicts, idempotency reuse |
| 422 | ValidationError |
error.fields has the per-field messages |
| 429 | RateLimitError |
retry_after, limit, remaining |
| 5xx | ServerError |
safe to retry (the SDK already did) |
| — | ConnectionError / TimeoutError |
transport-level |
All of them descend from Nombaone::Error, and webhook failures raise Nombaone::WebhookVerificationError. Branch on e.code (stable) or the class; the error codes are available as constants (Nombaone::ErrorCode::CUSTOMER_NOT_FOUND), and unknown future codes still parse.
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.create_payout(
amount_in_kobo: 5_000_000, bank_code: "058", account_number: "0123456789",
request_options: { idempotency_key: "payout-#{my_payout.id}" }, # ⚠ doubles as the payout's durable merchantTxRef
)
Every method also accepts request_options: with :idempotency_key, :headers, :timeout, :max_retries, and :cancel_when (a callable checked before each attempt — a caller cancel is never retried).
Webhooks
Verify before you parse, and dedupe on the event id — delivery is at-least-once, never exactly-once. Verification needs only the signing secret, never an API key, so Nombaone.webhooks works in a receiver that never builds a client.
Feed it the raw request body — parsing and re-serializing JSON changes bytes and breaks the signature.
# Rails
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
event = Nombaone.webhooks.construct_event(
request.raw_post, # the RAW body — never re-serialize
request.headers["X-Nombaone-Signature"],
ENV.fetch("NOMBAONE_WEBHOOK_SECRET"), # shown once when you created the endpoint
)
return head(:ok) if already_processed?(event.event.id) # at-least-once ⇒ dedupe on event.event.id
case event.type
when Nombaone::WebhookEventType::INVOICE_PAID then unlock(event.data.reference)
when Nombaone::WebhookEventType::INVOICE_ACTION_REQUIRED then email(event.data.checkout_link)
when Nombaone::WebhookEventType::INVOICE_PAYMENT_FAILED then note(event.data.reason)
end
head :ok # respond 2xx fast; do heavy work async
rescue Nombaone::WebhookVerificationError
head :bad_request
end
end
In Sinatra/Rack, the raw body is request.body.read and the header is request.env["HTTP_X_NOMBAONE_SIGNATURE"]. construct_event checks the X-Nombaone-Signature (t=<unix>,v1=<hex>, HMAC-SHA256 over "#{t}.#{body}") in constant time, rejects stale timestamps (300s tolerance, configurable), and returns a typed event. Nombaone.webhooks.generate_test_header(payload:, secret:) lets you unit-test your handler. Manage endpoints via nombaone.webhook_endpoints (create/rotate 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 · payment_methods (hosted-checkout cards, virtual accounts) · mandates (NIBSS direct debit) · settlements (escrow, refunds, payouts) · webhook_endpoints (+deliveries, replay) · events (+catalog) · organization (+billing policy) · metrics · sandbox — 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.
payment_methods.create_virtual_accountissues a NUBAN; collection completes when the transfer arrives and reconciles. past_dueis not canceled. Readnombaone.subscriptions.dunning.retrieve(id)and honorgrace_access_untilbefore cutting anyone off. Involuntary churn isstatus: "canceled"withcancellation_reason: "involuntary"(and asubscription.churnedevent).- Prices are immutable; plans archive, never delete.
Configuration
Nombaone.new(
api_key, # defaults to ENV["NOMBAONE_API_KEY"]
base_url: nil, # override the derived host
timeout: 30, # per-attempt seconds
max_retries: 2, # automatic retry budget
default_headers: {}, # sent on every request
http: nil, # inject a connection (tests, proxies)
)
Read-only nombaone.mode ("sandbox"/"live", derived from the key) and nombaone.base_url are exposed.
Examples & development
Runnable scripts live in examples/ — quickstart, pagination, the subscription lifecycle, a webhook receiver, and a dunning rehearsal with the test clock:
NOMBAONE_API_KEY=nbo_sandbox_… ruby -Ilib examples/01_quickstart.rb
To develop the SDK itself: bundle install && rake (RuboCop + RBS validate + specs). The live suite is opt-in:
NOMBAONE_INTEGRATION=1 NOMBAONE_API_KEY=nbo_sandbox_… \
NOMBAONE_BASE_URL=https://sandbox.api.nombaone.xyz \
bundle exec rspec spec/integration
To prove the whole surface against the sandbox — every method called, with a plain per-method result and a DEFECTS 0 verdict:
NOMBAONE_API_KEY=nbo_sandbox_… ruby -Ilib scripts/verify.rb
Releases are automated and merge-triggered — see PUBLISHING.md.
Requirements & versioning
Ruby ≥ 3.1. Semantic versioning; the API itself is versioned at /v1 and additive changes never break you. MIT licensed.