Nomba One — Elixir SDK
The official Elixir 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.
def deps do
[
{:nombaone, "~> 0.1.0"}
]
end
Requires Elixir 1.15+ / OTP 25+. One tiny dependency (Jason); HTTP is handled by Erlang's built-in :httpc, with TLS verified against your OS trust store.
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:
client = Nombaone.new()
{:ok, plan} = Nombaone.Plans.create(client, %{name: "Pro"})
{:ok, price} =
Nombaone.Plans.Prices.create(client, plan.id, %{
unit_amount_in_kobo: 250_000, # ₦2,500.00 per month
interval: "month"
})
{:ok, customer} =
Nombaone.Customers.create(client, %{email: "ada@example.com", name: "Ada Lovelace"})
# Sandbox: mint a deterministic test card, then subscribe.
{:ok, method} = Nombaone.Sandbox.create_payment_method(client, %{customer_id: customer.id})
{:ok, subscription} =
Nombaone.Subscriptions.create(client, %{
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.
Two return styles
Every method returns {:ok, result} or {:error, %Nombaone.Error{}}, and has a raising ! variant that returns the value directly:
case Nombaone.Customers.retrieve(client, id) do
{:ok, customer} -> customer
{:error, %Nombaone.NotFoundError{}} -> nil
end
# Or, when you would rather let it raise:
customer = Nombaone.Customers.retrieve!(client, id)
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(client, %{
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.
{:ok, cycle} = Nombaone.Sandbox.advance_cycle(client, subscription.id)
cycle.outcome # => "paid" | "past_due" | …
# Fire a real, signed webhook at your registered endpoints.
Nombaone.Sandbox.simulate_webhook(client, %{type: "invoice.payment_failed"})
These functions raise ArgumentErrorlocally, 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.
Pagination
Every list works three ways. The returned Nombaone.Page is Enumerable — iterating it walks every item across every page, threading cursors for you.
# One page.
{:ok, page} = Nombaone.Invoices.list(client, %{status: "open", limit: 50})
page.data
page.pagination.has_more
page.pagination.next_cursor
# Manual paging.
if Nombaone.Page.has_next_page?(page) do
{:ok, next} = Nombaone.Page.next_page(page)
end
# Or let the SDK thread the cursors — for/Enum/Stream all auto-paginate.
for invoice <- page do
IO.inspect({invoice.id, invoice.amount_due_in_kobo})
end
Filter names mirror the wire exactly, quirks included: subscriptions and invoices filter by :customer_id, payment methods by :customer_ref, prices by :plan_ref.
Errors are a feature
Failures resolve to 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:
case Nombaone.Subscriptions.create(client, params) do
{:ok, subscription} ->
subscription
{:error, %Nombaone.ValidationError{fields: fields}} ->
fields # %{"paymentMethodId" => ["is required"]}
{:error, %Nombaone.RateLimitError{retry_after: seconds}} ->
seconds
{:error, %{code: "SUBSCRIPTION_PAYMENT_METHOD_REQUIRED"}} ->
attach_a_card()
end
| Status | Struct | Notes |
|---|---|---|
| 400 | Nombaone.BadRequestError | malformed request |
| 401 | Nombaone.AuthenticationError | missing/invalid/wrong-environment key |
| 403 | Nombaone.PermissionDeniedError | missing scope, foreign resource |
| 404 | Nombaone.NotFoundError | wrong id or wrong environment |
| 409 | Nombaone.ConflictError | state conflicts, idempotency reuse |
| 422 | Nombaone.ValidationError | error.fields has the per-field errors |
| 429 | Nombaone.RateLimitError | retry_after, limit, remaining |
| 5xx | Nombaone.ServerError | safe to retry (the SDK already did) |
| — | Nombaone.ConnectionError / Nombaone.TimeoutError | transport-level |
Every error is also an Elixir exception — Exception.message/1 renders the message with the hint appended, and ! variants raise it.
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(
client,
%{amount_in_kobo: 5_000_000, bank_code: "058", account_number: "0123456789"},
idempotency_key: "payout-#{my_payout.id}" # ⚠ doubles as the payout's durable merchant_tx_ref
)
Every method accepts per-call options as its last argument: :idempotency_key, :headers, :timeout (ms), :max_retries, and :with_response (wraps the result in a Nombaone.Response exposing the request_id and raw headers).
Webhooks
Verify before you parse, and dedupe on the event id — delivery is at-least-once, never exactly-once. The webhooks helper is pure crypto: no API key or client needed, only the signing secret.
# Feed it the RAW request body — never a re-encoded map (that reorders keys
# and breaks the signature).
case Nombaone.Webhooks.construct_event(raw_body, signature_header, secret) do
{:ok, event} ->
unless already_processed?(event.event.id) do # at-least-once ⇒ dedupe on event.event.id
case event.type do
"invoice.paid" -> unlock(event.data["reference"])
"invoice.action_required" -> email(event.data["checkoutLink"])
"invoice.payment_failed" -> note(event.data["reason"])
_ -> :ok
end
end
{:error, %Nombaone.WebhookVerificationError{} = error} ->
Logger.warning(Exception.message(error))
end
construct_event/4 checks the X-Nombaone-Signature (t=<unix>,v1=<hex>, HMAC-SHA256 over "{t}.{raw_body}") in constant time, rejects stale timestamps (300s tolerance, configurable via tolerance:), accepts multiple v1= pairs during secret rotation, and returns a Nombaone.WebhookEvent. generate_test_header/3 lets you test your handler. Manage endpoints via Nombaone.WebhookEndpoints (create/rotate return the secret exactly once).
Capturing the raw body
Phoenix / Plug — the body must be read as raw bytes beforePlug.Parsers consumes it. Use a custom body reader that stashes them:
# endpoint.ex
plug Plug.Parsers,
parsers: [:json],
body_reader: {MyApp.CacheBodyReader, :read_body, []},
json_decoder: Jason
# cache_body_reader.ex
defmodule MyApp.CacheBodyReader do
def read_body(conn, opts) do
{:ok, body, conn} = Plug.Conn.read_body(conn, opts)
{:ok, body, Plug.Conn.assign(conn, :raw_body, body)}
end
end
Then in your controller, verify conn.assigns.raw_body against the x-nombaone-signature header.
The full surface
Nombaone.Customers (+ credit, discount) · Nombaone.Plans (+ nested Nombaone.Plans.Prices) · Nombaone.Prices · Nombaone.Subscriptions (pause/resume/cancel/resubscribe/change, Nombaone.Subscriptions.Schedule, Nombaone.Subscriptions.Dunning, upcoming invoice, events) · Nombaone.Invoices · Nombaone.Coupons · Nombaone.PaymentMethods (hosted-checkout cards, virtual accounts) · Nombaone.Mandates (NIBSS direct debit) · Nombaone.Settlements (escrow, refunds, payouts) · Nombaone.WebhookEndpoints (+ Nombaone.WebhookEndpoints.Deliveries, replay) · Nombaone.Events (+ catalog) · Nombaone.Organization (+ Nombaone.Organization.Billing) · Nombaone.Metrics · Nombaone.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.
Nombaone.PaymentMethods.create_virtual_account/3issues a NUBAN; collection completes when the transfer arrives and reconciles. past_dueis not canceled. ReadNombaone.Subscriptions.Dunning.retrieve/3and honorgrace_access_untilbefore cutting anyone off.
Configuration
Nombaone.new(api_key,
base_url: "https://…", # override the derived host
timeout: 30_000, # per-attempt milliseconds (default 30_000)
max_retries: 2, # automatic retry budget (default 2)
transport: MyTransport, # a Nombaone.Transport module (default: :httpc)
transport_options: opts, # opaque term passed to the transport
default_headers: %{} # sent on every request
)
The HTTP back-end is pluggable via the Nombaone.Transport behaviour — swap in a Finch or Req adapter, or a recording double for tests.
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_… mix run examples/01_quickstart.exs
To develop the SDK: mix deps.get && mix test. The full quality gate is mix check (format + credo --strict + tests). The live integration suite is opt-in:
NOMBAONE_API_KEY=nbo_sandbox_… mix test --only integration
Refresh the conformance spec snapshot from a running API with:
curl -s https://sandbox.api.nombaone.xyz/v1/openapi.json -o spec/openapi.json
Requirements & versioning
Elixir ≥ 1.15, Erlang/OTP ≥ 25. Semantic versioning; the API itself is versioned at /v1 and additive changes never break you. MIT licensed.