nomba-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomba_python/__init__.py +40 -0
- nomba_python/client.py +161 -0
- nomba_python/concurrency.py +54 -0
- nomba_python/data/__init__.py +0 -0
- nomba_python/data/nomba_openapi.json +13321 -0
- nomba_python/exceptions.py +49 -0
- nomba_python/flows/__init__.py +3 -0
- nomba_python/flows/card_payment.py +204 -0
- nomba_python/http.py +418 -0
- nomba_python/models.py +749 -0
- nomba_python/pagination.py +111 -0
- nomba_python/py.typed +0 -0
- nomba_python/resources/__init__.py +33 -0
- nomba_python/resources/accounts.py +379 -0
- nomba_python/resources/airtime_data.py +252 -0
- nomba_python/resources/cabletv.py +173 -0
- nomba_python/resources/charge.py +410 -0
- nomba_python/resources/checkout.py +239 -0
- nomba_python/resources/electricity.py +204 -0
- nomba_python/resources/terminals.py +184 -0
- nomba_python/resources/transactions.py +460 -0
- nomba_python/resources/transfers.py +298 -0
- nomba_python/resources/virtual_accounts.py +230 -0
- nomba_python/validation.py +97 -0
- nomba_python/webhooks.py +190 -0
- nomba_python-0.1.0.dist-info/METADATA +312 -0
- nomba_python-0.1.0.dist-info/RECORD +29 -0
- nomba_python-0.1.0.dist-info/WHEEL +4 -0
- nomba_python-0.1.0.dist-info/licenses/LICENSE +21 -0
nomba_python/webhooks.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook signature verification, per Nomba's documented scheme
|
|
3
|
+
(https://developer.nomba.com/products/webhooks/signature-verification-new
|
|
4
|
+
and .../webhooks/introduction):
|
|
5
|
+
|
|
6
|
+
Headers sent with every webhook POST:
|
|
7
|
+
nomba-signature base64-encoded HMAC
|
|
8
|
+
nomba-signature-algorithm always "HmacSHA256"
|
|
9
|
+
nomba-signature-version currently "1.0.0"
|
|
10
|
+
nomba-timestamp RFC-3339 timestamp the payload was sent at
|
|
11
|
+
|
|
12
|
+
Signing scheme (confirmed from Nomba's own sample code):
|
|
13
|
+
hashing_payload = ":".join([
|
|
14
|
+
event_type,
|
|
15
|
+
requestId,
|
|
16
|
+
data.merchant.userId,
|
|
17
|
+
data.merchant.walletId,
|
|
18
|
+
data.transaction.transactionId,
|
|
19
|
+
data.transaction.type,
|
|
20
|
+
data.transaction.time,
|
|
21
|
+
data.transaction.responseCode,
|
|
22
|
+
])
|
|
23
|
+
message = f"{hashing_payload}:{timestamp}"
|
|
24
|
+
signature = base64(HMAC-SHA256(key=signature_key, msg=message))
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import hmac
|
|
30
|
+
import json
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from hashlib import sha256
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .exceptions import NombaValidationError
|
|
36
|
+
|
|
37
|
+
SIGNATURE_HEADER = "nomba-signature"
|
|
38
|
+
ALGORITHM_HEADER = "nomba-signature-algorithm"
|
|
39
|
+
VERSION_HEADER = "nomba-signature-version"
|
|
40
|
+
TIMESTAMP_HEADER = "nomba-timestamp"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_path(payload: dict[str, Any], *path: str, default: str = "") -> str:
|
|
44
|
+
node: Any = payload
|
|
45
|
+
for key in path:
|
|
46
|
+
if not isinstance(node, dict):
|
|
47
|
+
return default
|
|
48
|
+
node = node.get(key)
|
|
49
|
+
return "" if node is None else str(node)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_rfc3339(timestamp: str) -> datetime:
|
|
53
|
+
# Python's fromisoformat doesn't accept a trailing "Z" before 3.11;
|
|
54
|
+
# normalize it to an explicit UTC offset for broad compatibility.
|
|
55
|
+
ts = timestamp.strip()
|
|
56
|
+
if ts.endswith("Z"):
|
|
57
|
+
ts = ts[:-1] + "+00:00"
|
|
58
|
+
return datetime.fromisoformat(ts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_timestamp_freshness(timestamp: str, *, max_age_seconds: float) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Raise NombaValidationError if `timestamp` (the nomba-timestamp header,
|
|
64
|
+
RFC-3339) is older than `max_age_seconds` compared to now, or is in the
|
|
65
|
+
future by more than the same window (clock skew allowance). This guards
|
|
66
|
+
against replay attacks: a captured, validly-signed webhook being resent
|
|
67
|
+
later. Nomba's signature alone does not protect against replay -- it
|
|
68
|
+
only proves the payload+timestamp pair was signed with your key.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
sent_at = _parse_rfc3339(timestamp)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise NombaValidationError(f"Invalid nomba-timestamp format: {timestamp!r}") from exc
|
|
74
|
+
|
|
75
|
+
if sent_at.tzinfo is None:
|
|
76
|
+
sent_at = sent_at.replace(tzinfo=timezone.utc)
|
|
77
|
+
|
|
78
|
+
age = (datetime.now(timezone.utc) - sent_at).total_seconds()
|
|
79
|
+
if age > max_age_seconds:
|
|
80
|
+
raise NombaValidationError(
|
|
81
|
+
f"Webhook timestamp is {age:.0f}s old, exceeding max_age_seconds={max_age_seconds} "
|
|
82
|
+
"(possible replay attack, or a webhook retried long after the original send)"
|
|
83
|
+
)
|
|
84
|
+
if age < -max_age_seconds:
|
|
85
|
+
raise NombaValidationError(
|
|
86
|
+
f"Webhook timestamp is {-age:.0f}s in the future, exceeding max_age_seconds="
|
|
87
|
+
f"{max_age_seconds} (check for clock skew, or treat with suspicion)"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def compute_signature(signature_key: str, payload: dict[str, Any], timestamp: str) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Compute the base64 HMAC-SHA256 signature Nomba expects for a webhook
|
|
94
|
+
payload + timestamp, per their documented scheme.
|
|
95
|
+
"""
|
|
96
|
+
hashing_payload = ":".join(
|
|
97
|
+
[
|
|
98
|
+
_get_path(payload, "event_type"),
|
|
99
|
+
_get_path(payload, "requestId"),
|
|
100
|
+
_get_path(payload, "data", "merchant", "userId"),
|
|
101
|
+
_get_path(payload, "data", "merchant", "walletId"),
|
|
102
|
+
_get_path(payload, "data", "transaction", "transactionId"),
|
|
103
|
+
_get_path(payload, "data", "transaction", "type"),
|
|
104
|
+
_get_path(payload, "data", "transaction", "time"),
|
|
105
|
+
_get_path(payload, "data", "transaction", "responseCode"),
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
message = f"{hashing_payload}:{timestamp}"
|
|
109
|
+
mac = hmac.new(signature_key.encode("utf-8"), message.encode("utf-8"), sha256).digest()
|
|
110
|
+
return base64.b64encode(mac).decode("ascii")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verify_webhook_signature(
|
|
114
|
+
signature_key: str,
|
|
115
|
+
payload: dict[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
signature: str,
|
|
118
|
+
timestamp: str,
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Verify a webhook's signature using a constant-time comparison.
|
|
122
|
+
|
|
123
|
+
Example (Flask):
|
|
124
|
+
from nomba.webhooks import verify_webhook_signature
|
|
125
|
+
|
|
126
|
+
@app.post("/webhooks/nomba")
|
|
127
|
+
def handle_webhook():
|
|
128
|
+
payload = request.get_json()
|
|
129
|
+
ok = verify_webhook_signature(
|
|
130
|
+
signature_key="...",
|
|
131
|
+
payload=payload,
|
|
132
|
+
signature=request.headers["nomba-signature"],
|
|
133
|
+
timestamp=request.headers["nomba-timestamp"],
|
|
134
|
+
)
|
|
135
|
+
if not ok:
|
|
136
|
+
return "invalid signature", 401
|
|
137
|
+
...
|
|
138
|
+
"""
|
|
139
|
+
expected = compute_signature(signature_key, payload, timestamp)
|
|
140
|
+
return hmac.compare_digest(expected, signature)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def verify_webhook_request(
|
|
144
|
+
signature_key: str,
|
|
145
|
+
*,
|
|
146
|
+
body: bytes | str,
|
|
147
|
+
headers: dict[str, str],
|
|
148
|
+
max_age_seconds: float | None = 300.0,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""
|
|
151
|
+
Convenience wrapper: parses the raw body as JSON, pulls the
|
|
152
|
+
nomba-signature/nomba-timestamp headers (case-insensitively), verifies
|
|
153
|
+
the signature, checks the timestamp isn't stale (replay protection),
|
|
154
|
+
and returns the parsed payload on success.
|
|
155
|
+
|
|
156
|
+
`max_age_seconds` (default 300s / 5 min) rejects webhooks whose
|
|
157
|
+
nomba-timestamp is older than this -- protects against a captured,
|
|
158
|
+
validly-signed webhook being replayed later. Pass None to disable this
|
|
159
|
+
check (signature is still verified).
|
|
160
|
+
|
|
161
|
+
Raises NombaValidationError if the signature is missing, invalid, the
|
|
162
|
+
timestamp is stale, or the body isn't valid JSON. Does not raise on a
|
|
163
|
+
valid-but-unsigned request -- if you didn't configure a signature key on
|
|
164
|
+
the Nomba dashboard, no signature header will be sent; check for that
|
|
165
|
+
yourself if relevant.
|
|
166
|
+
"""
|
|
167
|
+
lower_headers = {k.lower(): v for k, v in headers.items()}
|
|
168
|
+
signature = lower_headers.get(SIGNATURE_HEADER)
|
|
169
|
+
timestamp = lower_headers.get(TIMESTAMP_HEADER)
|
|
170
|
+
if not signature or not timestamp:
|
|
171
|
+
raise NombaValidationError(
|
|
172
|
+
f"Missing '{SIGNATURE_HEADER}' or '{TIMESTAMP_HEADER}' header on webhook request"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if max_age_seconds is not None:
|
|
176
|
+
check_timestamp_freshness(timestamp, max_age_seconds=max_age_seconds)
|
|
177
|
+
|
|
178
|
+
if isinstance(body, bytes):
|
|
179
|
+
body = body.decode("utf-8")
|
|
180
|
+
try:
|
|
181
|
+
payload = json.loads(body)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
raise NombaValidationError(f"Webhook body is not valid JSON: {exc}") from exc
|
|
184
|
+
|
|
185
|
+
if not verify_webhook_signature(
|
|
186
|
+
signature_key, payload, signature=signature, timestamp=timestamp
|
|
187
|
+
):
|
|
188
|
+
raise NombaValidationError("Webhook signature verification failed")
|
|
189
|
+
|
|
190
|
+
return payload
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nomba-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python SDK for the Nomba payments API
|
|
5
|
+
Keywords: nomba,payments,nigeria,fintech,sdk
|
|
6
|
+
Author: Righteousness
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
15
|
+
Requires-Dist: pytest>=8.4.2
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Project-URL: Homepage, https://github.com/RightFix/nomba
|
|
18
|
+
Project-URL: Documentation, https://rightfix.github.io/nomba-docs/
|
|
19
|
+
Project-URL: Repository, https://github.com/RightFix/nomba
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# nomba
|
|
23
|
+
|
|
24
|
+
Unofficial Python SDK for the [Nomba](https://developer.nomba.com) payments API, built with [`uv`](https://docs.astral.sh/uv/) and [`httpx`](https://www.python-httpx.org/).
|
|
25
|
+
|
|
26
|
+
Covers **every endpoint** in Nomba's official [OpenAPI spec](https://github.com/kudi-inc/vendor-openapi-spec) — 64 methods across 10 resource groups, generated directly from the spec so field names and required/optional parameters match Nomba's docs exactly.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add nomba-python
|
|
32
|
+
# or
|
|
33
|
+
pip install nomba-python
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from nomba import Nomba
|
|
40
|
+
|
|
41
|
+
nomba = Nomba(
|
|
42
|
+
client_id="...",
|
|
43
|
+
client_secret="...",
|
|
44
|
+
account_id="...",
|
|
45
|
+
sandbox=True, # set False for live
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
account = nomba.virtual_accounts.create_virtual_account(
|
|
49
|
+
account_ref="ref-123",
|
|
50
|
+
account_name="Jane Doe",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
nomba.close()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or as a context manager:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
with Nomba(client_id=..., client_secret=..., account_id=...) as nomba:
|
|
60
|
+
nomba.virtual_accounts.fetch_a_virtual_account("ref-123")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Async
|
|
64
|
+
|
|
65
|
+
Every resource is also available via `AsyncNomba`, built on `httpx.AsyncClient`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
from nomba import AsyncNomba
|
|
70
|
+
|
|
71
|
+
async def main():
|
|
72
|
+
async with AsyncNomba(
|
|
73
|
+
client_id="...",
|
|
74
|
+
client_secret="...",
|
|
75
|
+
account_id="...",
|
|
76
|
+
sandbox=True,
|
|
77
|
+
) as nomba:
|
|
78
|
+
account = await nomba.virtual_accounts.create_virtual_account(
|
|
79
|
+
account_ref="ref-123",
|
|
80
|
+
account_name="Jane Doe",
|
|
81
|
+
)
|
|
82
|
+
transfer = await nomba.transfers.perform_bank_account_transfer_the_parent_account(
|
|
83
|
+
amount="5000.00",
|
|
84
|
+
account_number="0123456789",
|
|
85
|
+
account_name="John Doe",
|
|
86
|
+
bank_code="058",
|
|
87
|
+
merchant_tx_ref="txn-001",
|
|
88
|
+
sender_name="Jane Sender",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Resource groups
|
|
95
|
+
|
|
96
|
+
Each group is available on both `Nomba` and `AsyncNomba` (async methods are awaited):
|
|
97
|
+
|
|
98
|
+
| Group | Examples |
|
|
99
|
+
|---------------------|----------|
|
|
100
|
+
| `accounts` | `list_all_accounts`, `create_a_sub_account`, `fetch_account_balance`, `suspend_an_account` |
|
|
101
|
+
| `virtual_accounts` | `create_virtual_account`, `fetch_a_virtual_account`, `update_a_virtual_account`, `expire_a_virtual_account` |
|
|
102
|
+
| `checkout` | `create_an_online_checkout_order`, `charge_customer_with_tokenized_card_data`, `list_all_tokenized_cards_for_merchant` |
|
|
103
|
+
| `charge` | `submit_customer_card_details`, `submit_customer_payment_otp`, `fetch_checkout_order_details` |
|
|
104
|
+
| `transfers` | `perform_bank_account_lookup`, `perform_bank_account_transfer_the_parent_account`, `perform_wallet_transfer_from_a_sub_account` |
|
|
105
|
+
| `terminals` | `assign_a_terminal_to_the_parent_account`, `un_assign_terminal_from_an_account` |
|
|
106
|
+
| `transactions` | `fetch_transactions_on_the_parent_account`, `filter_account_transactions`, `confirm_a_transaction_s_status_by_session_id` |
|
|
107
|
+
| `airtime_data` | `make_airtime_purchases_via_parent_account`, `vend_data_bundles_via_parent_account`, `fetch_data_plans_available_on_a_telco_network_provider` |
|
|
108
|
+
| `cabletv` | `cabletv_lookup`, `cable_tv_subscription_via_parent_account` |
|
|
109
|
+
| `electricity` | `fetch_electricity_providers`, `electricity_customer_lookup`, `vend_electricity_via_parent_account` |
|
|
110
|
+
|
|
111
|
+
Every method's docstring lists its required and optional body fields straight from Nomba's schema — check `help(nomba.transfers.perform_bank_account_transfer_the_parent_account)` or your editor's signature hints.
|
|
112
|
+
|
|
113
|
+
## Reliability (token locking + retry/backoff)
|
|
114
|
+
|
|
115
|
+
`NombaClient`/`AsyncNombaClient` guard token fetching with a lock, so
|
|
116
|
+
concurrent requests never race to re-fetch a token — only one fetch happens,
|
|
117
|
+
the rest wait and reuse it. Requests that hit a `429` or transient `5xx` are
|
|
118
|
+
automatically retried with exponential backoff (respecting `Retry-After` if
|
|
119
|
+
Nomba sends one):
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
nomba = Nomba(
|
|
123
|
+
client_id="...", client_secret="...", account_id="...",
|
|
124
|
+
max_retries=3, # default
|
|
125
|
+
backoff_factor=0.5, # default; delay ~= backoff_factor * 2^attempt
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Typed responses
|
|
130
|
+
|
|
131
|
+
Every method's return type is a `TypedDict` generated from Nomba's response
|
|
132
|
+
schema (in `nomba.models`), giving editor autocomplete and type-checking on
|
|
133
|
+
the actual JSON keys (e.g. `resp["data"]["accountNumber"]`). This costs
|
|
134
|
+
nothing at runtime — methods still return plain dicts; the TypedDict is
|
|
135
|
+
purely a type hint, which matters on constrained hardware.
|
|
136
|
+
|
|
137
|
+
## Nested body validation
|
|
138
|
+
|
|
139
|
+
Flat method signatures (e.g. `order_reference=`, `amount=`) are validated by
|
|
140
|
+
Python itself. But some fields are nested objects — like `order={...}` in
|
|
141
|
+
checkout order creation — whose *inner* required fields a flat signature
|
|
142
|
+
can't enforce. Every write call is checked against Nomba's own OpenAPI spec
|
|
143
|
+
(bundled with the package) before any network call, so a typo or missing
|
|
144
|
+
nested field fails fast locally instead of round-tripping to Nomba's API:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from nomba import NombaValidationError
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
nomba.checkout.create_an_online_checkout_order(order={"orderReference": "x"})
|
|
151
|
+
except NombaValidationError as e:
|
|
152
|
+
print(e) # Missing required field(s) in request body: order.amount, order.currency, ...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Webhook signature verification
|
|
156
|
+
|
|
157
|
+
Implements Nomba's documented HMAC-SHA256 scheme
|
|
158
|
+
(`nomba-signature` / `nomba-timestamp` headers, base64-encoded signature),
|
|
159
|
+
including replay protection via the timestamp:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from nomba import verify_webhook_request, NombaValidationError
|
|
163
|
+
|
|
164
|
+
@app.post("/webhooks/nomba")
|
|
165
|
+
def handle_webhook():
|
|
166
|
+
try:
|
|
167
|
+
payload = verify_webhook_request(
|
|
168
|
+
signature_key="...", # the key you set up on the Nomba dashboard
|
|
169
|
+
body=request.get_data(),
|
|
170
|
+
headers=request.headers,
|
|
171
|
+
max_age_seconds=300, # default; reject webhooks older than 5 min
|
|
172
|
+
)
|
|
173
|
+
except NombaValidationError:
|
|
174
|
+
return "invalid signature", 401
|
|
175
|
+
|
|
176
|
+
if payload["event_type"] == "payment_success":
|
|
177
|
+
...
|
|
178
|
+
return "", 200
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A valid signature alone doesn't prove a webhook wasn't captured and resent
|
|
182
|
+
later — `max_age_seconds` rejects stale ones. Pass `max_age_seconds=None` to
|
|
183
|
+
disable that check if you have your own replay protection.
|
|
184
|
+
|
|
185
|
+
## Bounded concurrency for fan-out calls
|
|
186
|
+
|
|
187
|
+
`asyncio.gather` has no built-in limit — firing off many calls at once can
|
|
188
|
+
trigger a retry storm if several start failing together, or just trip
|
|
189
|
+
Nomba's rate limit by bursting too many requests in one window.
|
|
190
|
+
`gather_limited` runs the same calls with a concurrency cap:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from nomba import AsyncNomba, gather_limited
|
|
194
|
+
|
|
195
|
+
async with AsyncNomba(...) as nomba:
|
|
196
|
+
calls = [
|
|
197
|
+
(lambda ref=ref: nomba.virtual_accounts.fetch_a_virtual_account(ref))
|
|
198
|
+
for ref in account_refs
|
|
199
|
+
]
|
|
200
|
+
results = await gather_limited(calls, limit=5)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Extra/undocumented fields can always be passed as kwargs; they get merged into the JSON body verbatim.
|
|
204
|
+
|
|
205
|
+
## Pagination
|
|
206
|
+
|
|
207
|
+
Nomba's list endpoints are cursor-paginated server-side (they return a
|
|
208
|
+
`cursor` you feed back in for the next page). `paginate`/`apaginate` just
|
|
209
|
+
drive that loop for you — no extra pagination scheme is introduced.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from nomba import Nomba, paginate
|
|
213
|
+
|
|
214
|
+
nomba = Nomba(...)
|
|
215
|
+
for account in paginate(nomba.accounts.list_all_accounts, limit=50):
|
|
216
|
+
print(account["accountRef"])
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Async:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from nomba import AsyncNomba, apaginate
|
|
223
|
+
|
|
224
|
+
async with AsyncNomba(...) as nomba:
|
|
225
|
+
async for txn in apaginate(nomba.transactions.fetch_transactions_on_the_parent_account, limit=50):
|
|
226
|
+
print(txn)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Confirmed paginated endpoints (per Nomba's spec): `accounts.list_all_accounts`,
|
|
230
|
+
`accounts.fetch_terminals_assigned_to_an_account`,
|
|
231
|
+
`accounts.fetch_terminals_assigned_to_the_parent_account`,
|
|
232
|
+
`virtual_accounts.filter_virtual_accounts`, and all six
|
|
233
|
+
`transactions.*` list/filter methods.
|
|
234
|
+
|
|
235
|
+
## Card payment flow
|
|
236
|
+
|
|
237
|
+
Card checkout is a multi-step sequence (submit card → maybe OTP → maybe 3D
|
|
238
|
+
Secure → confirm). `CardPaymentFlow` wraps it so you don't have to track
|
|
239
|
+
`transactionId` by hand or look up what Nomba's `responseCode` values mean:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
order = nomba.checkout.create_an_online_checkout_order(
|
|
243
|
+
order={"orderReference": "order-001", "amount": "1000", ...}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
flow = nomba.card_payment(order_reference="order-001")
|
|
247
|
+
step = flow.submit_card(card_details="...", key="")
|
|
248
|
+
|
|
249
|
+
if step.requires_otp:
|
|
250
|
+
step = flow.submit_otp("123456")
|
|
251
|
+
elif step.requires_3ds:
|
|
252
|
+
# redirect the user using step.secure_authentication_data
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
if step.completed:
|
|
256
|
+
result = flow.confirm()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`nomba.card_payment(...)` returns a `CardPaymentFlow` (or `AsyncCardPaymentFlow`
|
|
260
|
+
off `AsyncNomba`). `step` is a `CardPaymentStep` with `.completed`,
|
|
261
|
+
`.requires_otp`, `.requires_3ds`, `.transaction_id`, `.message`, etc.
|
|
262
|
+
|
|
263
|
+
## Error handling
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from nomba import NombaAPIError, NombaAuthError
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
nomba.virtual_accounts.fetch_a_virtual_account("missing-ref")
|
|
270
|
+
except NombaAuthError as e:
|
|
271
|
+
print("auth failed:", e)
|
|
272
|
+
except NombaAPIError as e:
|
|
273
|
+
print("api error:", e.status_code, e.code, e.response_body)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Authentication
|
|
277
|
+
|
|
278
|
+
The SDK handles the OAuth2 client-credentials flow automatically: it fetches an
|
|
279
|
+
access token on first request, caches it, and refreshes it transparently when
|
|
280
|
+
it expires or is rejected (a 401 triggers exactly one retry with a fresh token).
|
|
281
|
+
Get your `client_id` / `client_secret` / `accountId` from your Nomba dashboard
|
|
282
|
+
under **Webhook & API Keys**.
|
|
283
|
+
|
|
284
|
+
## Regenerating from the spec
|
|
285
|
+
|
|
286
|
+
The resource modules in `src/nomba/resources/` and response models in
|
|
287
|
+
`src/nomba/models.py` are generated from `src/nomba/data/nomba_openapi.json`
|
|
288
|
+
(a snapshot of Nomba's published OpenAPI spec, also bundled into the package
|
|
289
|
+
for runtime nested-body validation). To pick up upstream API changes:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
curl -sL -o src/nomba/data/nomba_openapi.json \
|
|
293
|
+
"https://github.com/kudi-inc/vendor-openapi-spec/raw/refs/heads/main/openapi3_0_v_1_0_0.json"
|
|
294
|
+
uv run python scripts/generate_resources.py
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Development
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
uv sync
|
|
301
|
+
uv run python -m pytest # once tests are added
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Status
|
|
305
|
+
|
|
306
|
+
Generated from Nomba's official OpenAPI spec (v1.0.0) — all 64 documented
|
|
307
|
+
endpoints across Accounts, Virtual Accounts, Online Checkout, Charge,
|
|
308
|
+
Transfers, Terminals, Transactions, Airtime/Data, CableTV, and Electricity.
|
|
309
|
+
|
|
310
|
+
Also includes: typed responses, cursor pagination helpers, a guided
|
|
311
|
+
card-payment flow, locked/retrying HTTP clients, local nested-body
|
|
312
|
+
validation against Nomba's own spec, and webhook signature verification.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
nomba_python/__init__.py,sha256=EqZbsXjq9agMJaBHqjxHjGvjchFDhqWehDQYAMb21uI,941
|
|
2
|
+
nomba_python/client.py,sha256=TwieNaDU-eHRIsgiVRITPc7aIuIiF1LyzF6Mn-UdMHA,4781
|
|
3
|
+
nomba_python/concurrency.py,sha256=0wTRJ57uLR-rXaIEMfivSRr4qEKvZMu3j5EXgAyLKoo,1870
|
|
4
|
+
nomba_python/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
nomba_python/data/nomba_openapi.json,sha256=92D2QdcKaC6oe45fEQv_sJTbKV1oXqVDjSJUIFJxU-4,437490
|
|
6
|
+
nomba_python/exceptions.py,sha256=Dx6OASM-7bvzsejmVbw_Iy6PXhm09K6Cu52mxlOjVY8,1484
|
|
7
|
+
nomba_python/flows/__init__.py,sha256=v2yT_QfBLIzWRvhCiDXXELFMWFTf3ye_VkVlEGA2kb0,155
|
|
8
|
+
nomba_python/flows/card_payment.py,sha256=Z0eJeFYrvJ_Za_JWl4qxalhU9Q-s-3LjO318ShtbN_k,7512
|
|
9
|
+
nomba_python/http.py,sha256=DVuw6Btcnwlqq95FFE2x62Zd-lp9QtB8-qVvB9sFc6Q,14078
|
|
10
|
+
nomba_python/models.py,sha256=5m4lG7B4yMUaLvH7DpFAHBAnI9HFS_pGRTP-ue33_DU,19898
|
|
11
|
+
nomba_python/pagination.py,sha256=g5EagLomsdkN9YojCCnp55mm9IrFCfXBaU9500G_Ri8,3678
|
|
12
|
+
nomba_python/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
nomba_python/resources/__init__.py,sha256=Is2J4R6YQhUKzl4KF9nHE1qXCqWfinrgBY5cY9xe1ew,927
|
|
14
|
+
nomba_python/resources/accounts.py,sha256=60Z3pKulFjw1wbxej3Qp7plAOFS2r3w9jTPPoJTLWcg,16774
|
|
15
|
+
nomba_python/resources/airtime_data.py,sha256=rQEOpteN8rjMTyY0qSh2ilivwhW1Yy_nUvKGnU1Z0tM,12281
|
|
16
|
+
nomba_python/resources/cabletv.py,sha256=Xa2tNdjNP7D5jsu9Q-7J6mhK8oGQvxw3jLaO9IhcnRI,7180
|
|
17
|
+
nomba_python/resources/charge.py,sha256=9Nz0AE0YLo3vCvBqcyjnTTRkNVaqN2cKEZQ_RaCweTM,19549
|
|
18
|
+
nomba_python/resources/checkout.py,sha256=g-K9eBRZ0mR_H8o7GpKHKxrrD0tWpfWzdaUkRfBY5rs,10090
|
|
19
|
+
nomba_python/resources/electricity.py,sha256=t-Cq34fJwsMlUn4Gar5VCMZh8Ami34z758CNMZsMEts,8403
|
|
20
|
+
nomba_python/resources/terminals.py,sha256=Ftdl4bOeeoeh8lOy3ARhYWYP_SORs9zOnXX2n1OV9dY,7769
|
|
21
|
+
nomba_python/resources/transactions.py,sha256=HSDsuhYaZ3SIM4KyNOFxA6_4f1nfno1URyAU7xbZyk8,22269
|
|
22
|
+
nomba_python/resources/transfers.py,sha256=Rkqp18KgzHcOoJSx7VRgF8M3tvT3i3H-dmQGgU6XfCc,14089
|
|
23
|
+
nomba_python/resources/virtual_accounts.py,sha256=FpHhnBxJS5-C-dQ-jt-Fb8OoMP2Qq96wyFi7hDTD8hw,9873
|
|
24
|
+
nomba_python/validation.py,sha256=sAlddQ0TDtQO4AiTgNQRRRg9GN-fHArJEfCsulPpNyw,3489
|
|
25
|
+
nomba_python/webhooks.py,sha256=wNJwMqNgkrWmk8zR8Ein_ynlW0XkW6wwrDfhh4gB4Ic,7016
|
|
26
|
+
nomba_python-0.1.0.dist-info/licenses/LICENSE,sha256=5ddBfMvz-MOkk_vJtDwfqiDR8gchx1KRaetdXNL238s,1070
|
|
27
|
+
nomba_python-0.1.0.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
|
|
28
|
+
nomba_python-0.1.0.dist-info/METADATA,sha256=2RKImLUdgWQ8Qxw477-3ySTC4jo6w7xwdB7uaIKz9p8,11232
|
|
29
|
+
nomba_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Righteousness
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|