Skip to content

Commit f1af67e

Browse files
Albert A. Ninyehclaude
andcommitted
Add USSD support (client.ussd) and bump to 1.1.0
Adds a USSD resource under the client.ussd namespace covering the full API contract: pricing, availability, apps (list/create/update/delete), extensions (list/rent/release), sessions (list/get), and simulate. Also adds a ConflictError (409) for extension_unavailable, a _put helper for the app update endpoint, and an error-message fallback to the response body's error field. Includes tests mirroring the existing suite and a README USSD section with the inbound callback contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eac7020 commit f1af67e

8 files changed

Lines changed: 461 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
55
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [1.1.0] - 2026-07-07
8+
9+
### Added
10+
- USSD support via the `client.ussd` namespace: `pricing`, `availability`,
11+
`apps`, `create_app`, `update_app`, `delete_app`, `extensions`,
12+
`rent_extension`, `release_extension`, `sessions`, `session`, and `simulate`.
13+
- `ConflictError` (409), raised when renting an extension that is no longer
14+
available (`extension_unavailable`).
15+
16+
### Changed
17+
- Error messages now fall back to the response body's `error` field when no
18+
`message` is present.
19+
720
## [1.0.0] - 2026-07-05
821

922
### Added

README.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
[![License](https://img.shields.io/pypi/l/helliomessaging.svg)](LICENSE)
77

88
Python client for the [Hellio Messaging](https://helliomessaging.com) API v1:
9-
**SMS**, **OTP** (SMS / email / voice), **Voice broadcasts**, **Number Lookup (HLR)**,
10-
**Email Verification**, and **Webhooks**. Fully type-hinted and synchronous.
9+
**SMS**, **OTP** (SMS / email / voice), **Voice broadcasts**, **USSD**,
10+
**Number Lookup (HLR)**, **Email Verification**, and **Webhooks**. Fully
11+
type-hinted and synchronous.
1112

1213
## Install
1314
```bash
@@ -90,6 +91,81 @@ client.webhooks()
9091
client.delete_webhook(1)
9192
```
9293

94+
## USSD
95+
USSD lives under the `client.ussd` namespace. Needs a token with the `ussd`
96+
ability. You rent an **extension** (a short-code suffix, e.g. `*920*100#`), point
97+
it at a USSD **app** whose `callback_url` Hellio calls on every step, and can
98+
inspect **sessions** or `simulate` a step without dialling the real code. List
99+
endpoints are cursor-paginated (`data` array + `meta.next_cursor`).
100+
101+
```python
102+
from hellio import Hellio
103+
104+
client = Hellio(token="your-token-here")
105+
106+
# Pricing and availability
107+
client.ussd.pricing() # session prices per network + extension rents
108+
client.ussd.availability(100) # {'data': {'valid': True, 'available': True, 'monthly_price': '50.00'}}
109+
110+
# Apps (the callback endpoints Hellio POSTs session steps to)
111+
client.ussd.apps() # list (pass cursor="..." for the next page)
112+
app = client.ussd.create_app("Airtime Top-up", "https://your-app.com/ussd")
113+
app_id = app["data"]["id"]
114+
client.ussd.update_app(app_id, name="Airtime", active=True)
115+
client.ussd.delete_app(app_id)
116+
117+
# Extensions (short-code suffixes you rent and bind to an app)
118+
client.ussd.extensions()
119+
ext = client.ussd.rent_extension(100, app_id=app_id)
120+
client.ussd.release_extension(ext["data"]["id"])
121+
122+
# Sessions
123+
client.ussd.sessions(status="ended") # optional status filter
124+
client.ussd.session("sess_ref_123")
125+
126+
# Simulate a subscriber step against your callback (no real dialling)
127+
client.ussd.simulate(
128+
msisdn="233241234567",
129+
service_code="*920*100#",
130+
user_input="1",
131+
new_session=True,
132+
)
133+
# -> {'data': {'message': 'Welcome...', 'action': 'continue', 'continue': True}}
134+
```
135+
136+
Renting an extension that has just been taken raises `ConflictError` (409); an
137+
empty balance raises `InsufficientBalanceError` (402):
138+
139+
```python
140+
from hellio import ConflictError, InsufficientBalanceError
141+
142+
try:
143+
client.ussd.rent_extension(100)
144+
except ConflictError:
145+
... # someone else rented it first; try another code
146+
except InsufficientBalanceError:
147+
... # top up
148+
```
149+
150+
### Inbound callback
151+
When a subscriber uses your extension, Hellio POSTs
152+
`{ sessionId, msisdn, serviceCode, input, sequence, mode }` to the app's
153+
`callback_url`, signed with an `X-Hellio-Signature` header
154+
(`HMAC-SHA256(rawBody, app.secret)`). Verify the signature, then return
155+
`{ message, action }` where `action` is `"continue"` or `"end"`:
156+
157+
```python
158+
import hashlib
159+
import hmac
160+
161+
def handle_ussd(raw_body: bytes, signature: str, secret: str) -> dict:
162+
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
163+
if not hmac.compare_digest(expected, signature):
164+
raise ValueError("bad signature")
165+
# ... branch on the parsed payload ...
166+
return {"message": "Welcome to Airtime Top-up", "action": "continue"}
167+
```
168+
93169
## Error handling
94170
Non-2xx responses raise typed exceptions (all extend `HellioError`). Each error
95171
carries `message`, `status_code`, and `response` (the parsed body); validation
@@ -99,6 +175,7 @@ errors also expose `errors`.
99175
|---|---|
100176
| `InvalidApiTokenError` | 401 |
101177
| `InsufficientBalanceError` | 402 |
178+
| `ConflictError` | 409 |
102179
| `ValidationError` (`.errors`) | 422 |
103180
| `RateLimitError` | 429 |
104181
| `HellioError` | other |

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "helliomessaging"
7-
version = "1.0.0"
8-
description = "Official Python SDK for the Hellio Messaging API (SMS, OTP, Voice, Number Lookup, Email Verification, Webhooks)."
7+
version = "1.1.0"
8+
description = "Official Python SDK for the Hellio Messaging API (SMS, OTP, Voice, USSD, Number Lookup, Email Verification, Webhooks)."
99
readme = "README.md"
1010
requires-python = ">=3.9"
1111
license = { text = "MIT" }
1212
authors = [{ name = "Albert Ninyeh", email = "eaglesecurity0@gmail.com" }]
13-
keywords = ["hellio", "sms", "otp", "voice", "messaging", "hlr", "email-verification"]
13+
keywords = ["hellio", "sms", "otp", "voice", "ussd", "messaging", "hlr", "email-verification"]
1414
classifiers = [
1515
"Development Status :: 4 - Beta",
1616
"Intended Audience :: Developers",

src/hellio/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,25 @@
1010

1111
from .client import Hellio
1212
from .errors import (
13+
ConflictError,
1314
HellioError,
1415
InsufficientBalanceError,
1516
InvalidApiTokenError,
1617
RateLimitError,
1718
ServiceUnavailableError,
1819
ValidationError,
1920
)
21+
from .ussd import Ussd
2022

21-
__version__ = "0.1.0"
23+
__version__ = "1.1.0"
2224

2325
__all__ = [
2426
"Hellio",
27+
"Ussd",
2528
"HellioError",
2629
"InvalidApiTokenError",
2730
"InsufficientBalanceError",
31+
"ConflictError",
2832
"ValidationError",
2933
"RateLimitError",
3034
"ServiceUnavailableError",

src/hellio/client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
import httpx
1414

1515
from .errors import (
16+
ConflictError,
1617
HellioError,
1718
InsufficientBalanceError,
1819
InvalidApiTokenError,
1920
RateLimitError,
2021
ServiceUnavailableError,
2122
ValidationError,
2223
)
24+
from .ussd import Ussd
2325

2426
Recipients = Union[str, Sequence[str]]
2527

@@ -68,6 +70,9 @@ def __init__(
6870
},
6971
)
7072

73+
#: USSD endpoints (pricing, apps, extensions, sessions, simulate).
74+
self.ussd = Ussd(self)
75+
7176
# ---------------------------------------------------------------- lifecycle
7277

7378
def close(self) -> None:
@@ -279,6 +284,11 @@ def _post(
279284
) -> Dict[str, Any]:
280285
return self._request("POST", path, json=body)
281286

287+
def _put(
288+
self, path: str, body: Optional[Dict[str, Any]] = None
289+
) -> Dict[str, Any]:
290+
return self._request("PUT", path, json=body)
291+
282292
def _delete(self, path: str) -> Dict[str, Any]:
283293
return self._request("DELETE", path)
284294

@@ -303,12 +313,15 @@ def _request(
303313
return data
304314

305315
message = data.get("message")
316+
if not isinstance(message, str) or not message:
317+
message = data.get("error")
306318
if not isinstance(message, str) or not message:
307319
message = "Hellio API request failed."
308320

309321
error_class = {
310322
401: InvalidApiTokenError,
311323
402: InsufficientBalanceError,
324+
409: ConflictError,
312325
422: ValidationError,
313326
429: RateLimitError,
314327
503: ServiceUnavailableError,

src/hellio/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ class InsufficientBalanceError(HellioError):
4545
"""Raised on HTTP 402. The account balance is too low for the request."""
4646

4747

48+
class ConflictError(HellioError):
49+
"""Raised on HTTP 409. The request conflicts with the current state, e.g. a
50+
USSD extension that is no longer available to rent (``extension_unavailable``)."""
51+
52+
4853
class ValidationError(HellioError):
4954
"""Raised on HTTP 422. One or more request fields failed validation."""
5055

@@ -62,6 +67,7 @@ class ServiceUnavailableError(HellioError):
6267
"HellioError",
6368
"InvalidApiTokenError",
6469
"InsufficientBalanceError",
70+
"ConflictError",
6571
"ValidationError",
6672
"RateLimitError",
6773
"ServiceUnavailableError",

0 commit comments

Comments
 (0)