Skip to main content

README.md

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