# Nomba One — Elixir SDK
The official Elixir SDK for the [Nomba One](https://nombaone.xyz) 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.
```elixir
def deps do
[
{:nombaone, "~> 0.1.0"}
]
end
```
Requires Elixir 1.15+ / OTP 25+. One tiny dependency ([Jason](https://hex.pm/packages/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](https://app.nombaone.xyz), set it as `NOMBAONE_API_KEY`, and you are three objects away from a live subscription:
```elixir
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:
```elixir
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:
```elixir
# 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 `ArgumentError` **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.
## Pagination
Every `list` works three ways. The returned `Nombaone.Page` is `Enumerable` — iterating it walks every item across every page, threading cursors for you.
```elixir
# 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:
```elixir
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:
```elixir
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.
```elixir
# 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 *before* `Plug.Parsers` consumes it. Use a custom body reader that stashes them:
```elixir
# 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](https://docs.nombaone.xyz), 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.** `Nombaone.PaymentMethods.create_virtual_account/3` issues a NUBAN; collection completes when the transfer arrives and reconciles.
- **`past_due` is not canceled.** Read `Nombaone.Subscriptions.Dunning.retrieve/3` and honor `grace_access_until` before cutting anyone off.
## Configuration
```elixir
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/`](https://github.com/nombaone/nombaone-elixir/tree/main/examples) — quickstart, pagination, the subscription lifecycle, a webhook receiver, and a dunning rehearsal with the test clock:
```bash
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:
```bash
NOMBAONE_API_KEY=nbo_sandbox_… mix test --only integration
```
Refresh the conformance spec snapshot from a running API with:
```bash
curl -s https://sandbox.api.nombaone.xyz/v1/openapi.json -o spec/openapi.json
```
## Requirements & versioning
Elixir ≥ 1.15, Erlang/OTP ≥ 25. [Semantic versioning](https://semver.org); the API itself is versioned at `/v1` and additive changes never break you. MIT licensed.