Project

nombaone

0.0
The project is in a healthy, maintained state
Recurring billing for Nigeria over card, direct debit, bank transfer, and more — with dunning that recovers, idempotency that can never double-charge, and a ledger that never loses a kobo. Zero runtime dependencies.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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_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. payment_methods.create_virtual_account issues a NUBAN; collection completes when the transfer arrives and reconciles.
  • past_due is not canceled. Read nombaone.subscriptions.dunning.retrieve(id) and honor grace_access_until before cutting anyone off. Involuntary churn is status: "canceled" with cancellation_reason: "involuntary" (and a subscription.churned event).
  • 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.