Skip to main content

The official Python SDK for the NombaOne subscription-billing API — recurring billing for Nigeria.

Project description

nombaone

The official Python 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.

pip install nombaone

Requires Python 3.9+. Sync and async clients, fully typed (ships py.typed), two dependencies (httpx, pydantic).

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:

import os
from nombaone import Nombaone

nombaone = Nombaone(os.environ["NOMBAONE_API_KEY"])

plan = nombaone.plans.create(name="Pro")
price = nombaone.plans.prices.create(
    plan.id,
    unit_amount_in_kobo=250_000,  # ₦2,500.00 per month
    interval="month",
)
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,
)

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

Async

Every method has an async twin on AsyncNombaone with the identical surface:

import asyncio
from nombaone import AsyncNombaone

async def main():
    async with AsyncNombaone() as nombaone:
        customer = await nombaone.customers.create(email="ada@example.com", name="Ada")
        async for c in await nombaone.customers.list():
            print(c.email)

asyncio.run(main())

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)
print(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.

Pagination

Every list() works three ways:

# One page.
page = nombaone.invoices.list(status="open", limit=50)
page.data
page.pagination.has_more
page.pagination.next_cursor

# Manual paging (cursor threaded for you).
if page.has_next_page():
    nxt = page.next_page()

# Or let the SDK walk every page.
for invoice in nombaone.invoices.list(status="open"):
    ...  # every item across every page

# Async:
async for invoice in await nombaone.invoices.list(status="open"):
    ...

Errors are a feature

Failures raise typed exceptions 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:

from nombaone import NotFoundError, RateLimitError, ValidationError

try:
    nombaone.subscriptions.create(customer_id=cid, price_id=pid)
except ValidationError as err:
    print(err.fields)      # {"payment_method_id": ["..."]}
except RateLimitError as err:
    print(err.retry_after)  # seconds
except NotFoundError as err:
    print(err.code)         # "CUSTOMER_NOT_FOUND"
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 err.fields has the per-field messages
429 RateLimitError retry_after, limit, remaining
5xx ServerError safe to retry (the SDK already did)
APIConnectionError / APITimeoutError transport-level

All derive from NombaoneError, so except NombaoneError catches anything the SDK raises.

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:

from nombaone import RequestOptions

nombaone.settlements.create_payout(
    amount_in_kobo=5_000_000, bank_code="058", account_number="0123456789",
    options=RequestOptions(idempotency_key=f"payout-{my_payout.id}"),  # ⚠ doubles as the payout's durable merchantTxRef
)

Every method also accepts options=RequestOptions(timeout=…, max_retries=…, headers=…), and every returned object carries obj.last_response (request_id, status_code, headers, http_response).

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 (no API key):

from flask import Flask, request
from nombaone import webhooks  # standalone helper

app = Flask(__name__)

@app.post("/nombaone/webhooks")
def hook():
    event = webhooks.construct_event(
        request.get_data(),  # the RAW body — never re-serialize
        request.headers.get("x-nombaone-signature", ""),
        os.environ["NOMBAONE_WEBHOOK_SECRET"],  # shown once when you created the endpoint
    )

    if already_processed(event.event.id):  # at-least-once ⇒ dedupe on event.event.id
        return "", 200

    if event.type == "invoice.paid":
        unlock(event.data["reference"])
    elif event.type == "invoice.action_required":
        send(event.data["checkoutLink"])
    elif event.type == "invoice.payment_failed":
        note(event.data["reason"])
    return "", 200  # respond 2xx fast, work async

Feed it the raw request body (request.get_data() in Flask, await request.body() in FastAPI, request.body in Django) — json.loads + re-serialization changes bytes and breaks the signature. construct_event checks the X-Nombaone-Signature (t=<unix>,v1=<hex>, HMAC-SHA256 over f"{t}.{body}") in constant time, rejects stale timestamps (300s tolerance, configurable), and returns a parsed WebhookEvent. webhooks.generate_test_header(...) 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 subscriptions.dunning.retrieve() and honor grace_access_until before cutting anyone off.

Configuration

Nombaone(
    api_key,            # or NOMBAONE_API_KEY
    base_url=...,       # override the derived host
    timeout=30.0,       # per-attempt seconds
    max_retries=2,      # automatic retry budget
    default_headers=...,# sent on every request
    http_client=...,    # bring your own httpx.Client (tests, proxies)
)

Examples & development

Runnable scripts live in examples/ — quickstart, pagination, the subscription lifecycle, a webhook receiver, and a dunning rehearsal with the test clock. To develop the SDK itself:

uv sync --extra dev
uv run ruff check . && uv run mypy && uv run pytest    # lint, type-check, unit + conformance
NOMBAONE_INTEGRATION=1 NOMBAONE_API_KEY=nbo_sandbox_… uv run pytest tests/integration  # live suite

The conformance suite guards spec/openapi.json; refresh it with curl -s <host>/v1/openapi.json -o spec/openapi.json.

Requirements & versioning

Python ≥ 3.9. Semantic versioning; the API itself is versioned at /v1 and additive changes never break you. MIT licensed.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nombaone-0.1.0.tar.gz (47.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nombaone-0.1.0-py3-none-any.whl (62.6 kB view details)

Uploaded Python 3

File details

Details for the file nombaone-0.1.0.tar.gz.

File metadata

  • Download URL: nombaone-0.1.0.tar.gz
  • Upload date:
  • Size: 47.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for nombaone-0.1.0.tar.gz
Algorithm Hash digest
SHA256 252aa203a7f19b19f4e7e027efa2cfd7e69fc73376b5a339e91a510f732a6806
MD5 1387cdff3748bab48492bfb65e774b85
BLAKE2b-256 fb62edc3ba50b97f771842fbdffd4e118bda40fa4eb34fc1b383f8d3163fd73a

See more details on using hashes here.

File details

Details for the file nombaone-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: nombaone-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 62.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.27 {"installer":{"name":"uv","version":"0.11.27","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for nombaone-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 903e29e69a239b1528349df91f59d3ba9bb4877573b6e1861c8fac4764b08ff7
MD5 7e7b8f2bc98b5e89d43c88f876224a94
BLAKE2b-256 66381183d9030d03096b93b20da48031df0b9dc04909f7b1c59fe063a03618ef

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page