Skip to content

Commit 72dba39

Browse files
Albert A. Ninyehclaude
andcommitted
feat: update USSD module for API v1 test/live app modes
USSD apps now have test and live modes. create_app responses expose id (UUID string), mode, test_secret, live_secret, is_live, and active in place of the single secret. Add set_mode and rotate_secret, type every app/extension id as str, and reshape simulate to take app_id. - Add ExtensionRequiredError (402 extension_required) and slug-aware error mapping so 402 resolves to InsufficientBalanceError or ExtensionRequiredError by the response error slug. - rent_extension now surfaces insufficient_ussd_balance (dedicated USSD balance) on 402. - simulate(app_id, session_id, msisdn, input="", new_session=False, service_code=None): service_code sent only when set; unknown_app -> 422. - Update README USSD lifecycle, CHANGELOG, and tests; bump to 1.2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1af67e commit 72dba39

8 files changed

Lines changed: 317 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ 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.2.0] - 2026-07-07
8+
9+
### Added
10+
- USSD apps now have **test** and **live** modes. `create_app` responses expose
11+
`id` (UUID string), `mode`, `test_secret` (`ussk_test_`), `live_secret`
12+
(`ussk_live_`), `is_live`, and `active` in place of the single `secret`.
13+
- `client.ussd.set_mode(app_id, mode)` to switch an app between `test` and
14+
`live`, and `client.ussd.rotate_secret(app_id, mode)` to rotate the secret for
15+
a mode.
16+
- `ExtensionRequiredError` (402, `extension_required`), raised when switching an
17+
app to live mode before an extension has been purchased.
18+
19+
### Changed
20+
- App and extension ids are UUID strings; every id parameter (`update_app`,
21+
`delete_app`, `rent_extension`'s `app_id`, `release_extension`, `session`, and
22+
the new methods) is now typed `str`.
23+
- `simulate` now takes the app to target: `simulate(app_id, session_id, msisdn,
24+
input="", new_session=False, service_code=None)`. `service_code` is optional
25+
and defaults to the shared short code server-side. Simulation always runs in
26+
the sandbox (test mode); an app you do not own returns 422 `unknown_app`.
27+
- Renting an extension draws from the dedicated USSD balance; on insufficient
28+
funds it now returns 402 `insufficient_ussd_balance` (mapped to
29+
`InsufficientBalanceError`).
30+
- Error mapping now honours the response `error` slug, so 402 responses resolve
31+
to `InsufficientBalanceError` or `ExtensionRequiredError` as appropriate.
32+
733
## [1.1.0] - 2026-07-07
834

935
### Added

README.md

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,19 @@ client.delete_webhook(1)
9393

9494
## USSD
9595
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`).
96+
ability. You build a USSD **app** whose `callback_url` Hellio calls on every
97+
step, `simulate` it in the sandbox by `app_id`, rent an **extension** (a
98+
short-code suffix, e.g. `*920*100#`) from your dedicated USSD balance, then flip
99+
the app to **live** mode. You can also inspect **sessions**. List endpoints are
100+
cursor-paginated (`data` array + `meta.next_cursor`).
101+
102+
Every app has a **test** and a **live** mode. A new app starts in `test` and
103+
carries two secrets, `test_secret` (`ussk_test_...`) and `live_secret`
104+
(`ussk_live_...`); the active one is the secret for the current `mode`.
105+
Simulation always runs in the sandbox (test mode): no charge, no extension
106+
needed. Going live requires a purchased extension, and live USSD sessions are
107+
billed to a dedicated USSD balance, separate from your SMS credit and main
108+
wallet.
100109

101110
```python
102111
from hellio import Hellio
@@ -107,51 +116,67 @@ client = Hellio(token="your-token-here")
107116
client.ussd.pricing() # session prices per network + extension rents
108117
client.ussd.availability(100) # {'data': {'valid': True, 'available': True, 'monthly_price': '50.00'}}
109118

110-
# Apps (the callback endpoints Hellio POSTs session steps to)
111-
client.ussd.apps() # list (pass cursor="..." for the next page)
119+
# 1. Create an app (starts in test mode; ids are UUID strings)
112120
app = client.ussd.create_app("Airtime Top-up", "https://your-app.com/ussd")
113-
app_id = app["data"]["id"]
121+
app_id = app["data"]["id"] # e.g. "9b1f...": a UUID string
122+
# app["data"] also has: mode, test_secret, live_secret, is_live, active
123+
client.ussd.apps() # list (pass cursor="..." for the next page)
114124
client.ussd.update_app(app_id, name="Airtime", active=True)
115-
client.ussd.delete_app(app_id)
116125

117-
# Extensions (short-code suffixes you rent and bind to an app)
126+
# 2. Simulate a subscriber step in the sandbox (no dialling, no charge)
127+
client.ussd.simulate(
128+
app_id=app_id,
129+
session_id="sess-1",
130+
msisdn="233241234567",
131+
input="1",
132+
new_session=True,
133+
)
134+
# -> {'data': {'message': 'Welcome...', 'action': 'continue', 'continue': True}}
135+
136+
# 3. Rent an extension from your USSD balance and bind it to the app
118137
client.ussd.extensions()
119138
ext = client.ussd.rent_extension(100, app_id=app_id)
120-
client.ussd.release_extension(ext["data"]["id"])
139+
140+
# 4. Go live (needs a purchased extension) and rotate secrets as needed
141+
client.ussd.set_mode(app_id, "live")
142+
client.ussd.rotate_secret(app_id, "live")
121143

122144
# Sessions
123145
client.ussd.sessions(status="ended") # optional status filter
124146
client.ussd.session("sess_ref_123")
125147

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}}
148+
# Teardown
149+
client.ussd.release_extension(ext["data"]["id"])
150+
client.ussd.delete_app(app_id)
134151
```
135152

136-
Renting an extension that has just been taken raises `ConflictError` (409); an
137-
empty balance raises `InsufficientBalanceError` (402):
153+
Renting an extension that has just been taken raises `ConflictError` (409); too
154+
low a USSD balance raises `InsufficientBalanceError` (402,
155+
`insufficient_ussd_balance`); switching to live before an extension is purchased
156+
raises `ExtensionRequiredError` (402, `extension_required`):
138157

139158
```python
140-
from hellio import ConflictError, InsufficientBalanceError
159+
from hellio import ConflictError, ExtensionRequiredError, InsufficientBalanceError
141160

142161
try:
143162
client.ussd.rent_extension(100)
144163
except ConflictError:
145164
... # someone else rented it first; try another code
146165
except InsufficientBalanceError:
147-
... # top up
166+
... # top up your USSD balance
167+
168+
try:
169+
client.ussd.set_mode(app_id, "live")
170+
except ExtensionRequiredError:
171+
... # rent an extension for the app first
148172
```
149173

150174
### Inbound callback
151175
When a subscriber uses your extension, Hellio POSTs
152176
`{ sessionId, msisdn, serviceCode, input, sequence, mode }` to the app's
153177
`callback_url`, signed with an `X-Hellio-Signature` header
154-
(`HMAC-SHA256(rawBody, app.secret)`). Verify the signature, then return
178+
(`HMAC-SHA256(rawBody, secret)`, where `secret` is the app's `test_secret` or
179+
`live_secret` for the `mode` on the request). Verify the signature, then return
155180
`{ message, action }` where `action` is `"continue"` or `"end"`:
156181

157182
```python
@@ -174,7 +199,8 @@ errors also expose `errors`.
174199
| Exception | Status |
175200
|---|---|
176201
| `InvalidApiTokenError` | 401 |
177-
| `InsufficientBalanceError` | 402 |
202+
| `InsufficientBalanceError` | 402 (`insufficient_ussd_balance`) |
203+
| `ExtensionRequiredError` | 402 (`extension_required`) |
178204
| `ConflictError` | 409 |
179205
| `ValidationError` (`.errors`) | 422 |
180206
| `RateLimitError` | 429 |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "helliomessaging"
7-
version = "1.1.0"
7+
version = "1.2.0"
88
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"

src/hellio/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .client import Hellio
1212
from .errors import (
1313
ConflictError,
14+
ExtensionRequiredError,
1415
HellioError,
1516
InsufficientBalanceError,
1617
InvalidApiTokenError,
@@ -20,14 +21,15 @@
2021
)
2122
from .ussd import Ussd
2223

23-
__version__ = "1.1.0"
24+
__version__ = "1.2.0"
2425

2526
__all__ = [
2627
"Hellio",
2728
"Ussd",
2829
"HellioError",
2930
"InvalidApiTokenError",
3031
"InsufficientBalanceError",
32+
"ExtensionRequiredError",
3133
"ConflictError",
3234
"ValidationError",
3335
"RateLimitError",

src/hellio/client.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .errors import (
1616
ConflictError,
17+
ExtensionRequiredError,
1718
HellioError,
1819
InsufficientBalanceError,
1920
InvalidApiTokenError,
@@ -312,20 +313,30 @@ def _request(
312313
if 200 <= response.status_code < 300:
313314
return data
314315

316+
slug = data.get("error")
315317
message = data.get("message")
316318
if not isinstance(message, str) or not message:
317-
message = data.get("error")
319+
message = slug
318320
if not isinstance(message, str) or not message:
319321
message = "Hellio API request failed."
320322

321-
error_class = {
322-
401: InvalidApiTokenError,
323-
402: InsufficientBalanceError,
324-
409: ConflictError,
325-
422: ValidationError,
326-
429: RateLimitError,
327-
503: ServiceUnavailableError,
328-
}.get(response.status_code, HellioError)
323+
# Some slugs share a status code (e.g. 402 covers both an insufficient
324+
# balance and a missing extension), so a slug match wins over the
325+
# status-code default when present.
326+
slug_errors = {
327+
"insufficient_ussd_balance": InsufficientBalanceError,
328+
"extension_required": ExtensionRequiredError,
329+
}
330+
error_class = slug_errors.get(slug) if isinstance(slug, str) else None
331+
if error_class is None:
332+
error_class = {
333+
401: InvalidApiTokenError,
334+
402: InsufficientBalanceError,
335+
409: ConflictError,
336+
422: ValidationError,
337+
429: RateLimitError,
338+
503: ServiceUnavailableError,
339+
}.get(response.status_code, HellioError)
329340

330341
raise error_class(message, response.status_code, data)
331342

src/hellio/errors.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ class InvalidApiTokenError(HellioError):
4242

4343

4444
class InsufficientBalanceError(HellioError):
45-
"""Raised on HTTP 402. The account balance is too low for the request."""
45+
"""Raised on HTTP 402. A balance is too low for the request, e.g. the
46+
dedicated USSD balance when renting an extension (``insufficient_ussd_balance``)."""
47+
48+
49+
class ExtensionRequiredError(HellioError):
50+
"""Raised on HTTP 402 (``extension_required``) when switching a USSD app to
51+
live mode before an extension has been purchased for it."""
4652

4753

4854
class ConflictError(HellioError):
@@ -67,6 +73,7 @@ class ServiceUnavailableError(HellioError):
6773
"HellioError",
6874
"InvalidApiTokenError",
6975
"InsufficientBalanceError",
76+
"ExtensionRequiredError",
7077
"ConflictError",
7178
"ValidationError",
7279
"RateLimitError",

0 commit comments

Comments
 (0)