Nomba One — Elixir SDK

View Source

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 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.

# 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
StatusStructNotes
400Nombaone.BadRequestErrormalformed request
401Nombaone.AuthenticationErrormissing/invalid/wrong-environment key
403Nombaone.PermissionDeniedErrormissing scope, foreign resource
404Nombaone.NotFoundErrorwrong id or wrong environment
409Nombaone.ConflictErrorstate conflicts, idempotency reuse
422Nombaone.ValidationErrorerror.fields has the per-field errors
429Nombaone.RateLimitErrorretry_after, limit, remaining
5xxNombaone.ServerErrorsafe to retry (the SDK already did)
Nombaone.ConnectionError / Nombaone.TimeoutErrortransport-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 before Plug.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_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

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.