Python client for the Hellio Messaging API v1: SMS, OTP (SMS / email / voice), Voice broadcasts, USSD, Number Lookup (HLR), Email Verification, and Webhooks. Fully type-hinted and synchronous.
pip install helliomessagingThe import package is hellio (the main class is Hellio).
Generate a token in your dashboard -> Settings -> API -> Generate API token, then construct the client directly:
from hellio import Hellio
client = Hellio(
token="your-token-here",
default_sender="HellioSMS", # optional default Sender ID for SMS
)Or rely on environment variables:
HELLIO_API_TOKEN=your-token-here
HELLIO_BASE_URL=https://api.helliomessaging.com/v1
HELLIO_DEFAULT_SENDER=HellioSMSclient = Hellio() # reads HELLIO_API_TOKEN, HELLIO_BASE_URL, HELLIO_DEFAULT_SENDEREvery call returns the decoded JSON response as a dict (payloads are under the
data key). You can also use the client as a context manager so the underlying
HTTP connection is closed for you:
with Hellio(token="your-token-here") as client:
client.balance()from hellio import Hellio
client = Hellio(token="your-token-here", default_sender="HellioSMS")
# Account
client.balance() # {'data': {'balance': '195.0000', 'available': '194.65', ...}}
client.pricing("GH") # optional ISO-2 country filter
client.pricing() # all networks
# SMS (recipients: string, comma list, or list)
client.sms("233241234567", "Hello!")
client.sms(["233241234567", "233201234567"], "Hi all", sender="HellioSMS")
client.message(1024) # delivery status
client.campaign(1024) # campaign summary
# OTP - sender (Sender ID) is REQUIRED for sms/voice and must be approved on your account.
# Optional length (4-10 digits) and expiry (minutes). Returns status "queued".
client.otp("233241234567", "HellioSMS") # SMS
client.otp("233241234567", "HellioSMS", channel="voice") # Voice (TTS reads the code)
client.otp("233241234567", "HellioSMS", length=6, expiry=10) # custom length / expiry
client.otp("user@example.com", channel="email") # Email (no sender)
client.verify("233241234567", "123456") # bool
client.verify_otp("user@example.com", "123456", channel="email") # full response
# Voice broadcast - text (we TTS it) or a hosted audio_url
client.voice("233241234567", "HELLIO", text="Your code is 1 2 3 4")
client.voice(["233241234567"], "HELLIO", audio_url="https://cdn.example.com/promo.mp3")
client.voice_status(42)
# Number lookup (HLR) - async; poll results
client.lookup(["233241234567"])
client.lookups()
client.lookup_result(5)
# Email verification
client.verify_email(["user@gmail.com", "bad@nodomain.invalid"])
# Webhooks (receive delivery reports)
client.create_webhook("https://your-app.com/hooks/hellio", ["message.delivered", "message.failed"])
client.webhooks()
client.delete_webhook(1)USSD lives under the client.ussd namespace. Needs a token with the ussd
ability. You build a USSD app whose callback_url Hellio calls on every
step, simulate it in the sandbox by app_id, rent an extension (a
short-code suffix, e.g. *920*100#) from your dedicated USSD balance, then flip
the app to live mode. You can also inspect sessions. List endpoints are
cursor-paginated (data array + meta.next_cursor).
Every app has a test and a live mode. A new app starts in test and
carries two secrets, test_secret (ussk_test_...) and live_secret
(ussk_live_...); the active one is the secret for the current mode.
Simulation always runs in the sandbox (test mode): no charge, no extension
needed. Going live requires a purchased extension, and live USSD sessions are
billed to a dedicated USSD balance, separate from your SMS credit and main
wallet.
from hellio import Hellio
client = Hellio(token="your-token-here")
# Pricing and availability
client.ussd.pricing() # session prices per network + extension rents
client.ussd.availability(100) # {'data': {'valid': True, 'available': True, 'monthly_price': '50.00'}}
# 1. Create an app (starts in test mode; ids are UUID strings)
app = client.ussd.create_app("Airtime Top-up", "https://your-app.com/ussd")
app_id = app["data"]["id"] # e.g. "9b1f...": a UUID string
# app["data"] also has: mode, test_secret, live_secret, is_live, active
client.ussd.apps() # list (pass cursor="..." for the next page)
client.ussd.update_app(app_id, name="Airtime", active=True)
# 2. Simulate a subscriber step in the sandbox (no dialling, no charge)
client.ussd.simulate(
app_id=app_id,
session_id="sess-1",
msisdn="233241234567",
input="1",
new_session=True,
)
# -> {'data': {'message': 'Welcome...', 'action': 'continue', 'continue': True}}
# 3. Rent an extension from your USSD balance and bind it to the app
client.ussd.extensions()
ext = client.ussd.rent_extension(100, app_id=app_id)
# 4. Go live (needs a purchased extension) and rotate secrets as needed
client.ussd.set_mode(app_id, "live")
client.ussd.rotate_secret(app_id, "live")
# Sessions
client.ussd.sessions(status="ended") # optional status filter
client.ussd.session("sess_ref_123")
# Teardown
client.ussd.release_extension(ext["data"]["id"])
client.ussd.delete_app(app_id)Renting an extension that has just been taken raises ConflictError (409); too
low a USSD balance raises InsufficientBalanceError (402,
insufficient_ussd_balance); switching to live before an extension is purchased
raises ExtensionRequiredError (402, extension_required):
from hellio import ConflictError, ExtensionRequiredError, InsufficientBalanceError
try:
client.ussd.rent_extension(100)
except ConflictError:
... # someone else rented it first; try another code
except InsufficientBalanceError:
... # top up your USSD balance
try:
client.ussd.set_mode(app_id, "live")
except ExtensionRequiredError:
... # rent an extension for the app firstWhen a subscriber uses your extension, Hellio POSTs
{ sessionId, msisdn, serviceCode, input, sequence, mode } to the app's
callback_url, signed with an X-Hellio-Signature header
(HMAC-SHA256(rawBody, secret), where secret is the app's test_secret or
live_secret for the mode on the request). Verify the signature, then return
{ message, action } where action is "continue" or "end":
import hashlib
import hmac
def handle_ussd(raw_body: bytes, signature: str, secret: str) -> dict:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("bad signature")
# ... branch on the parsed payload ...
return {"message": "Welcome to Airtime Top-up", "action": "continue"}Non-2xx responses raise typed exceptions (all extend HellioError). Each error
carries message, status_code, and response (the parsed body); validation
errors also expose errors.
| Exception | Status |
|---|---|
InvalidApiTokenError |
401 |
InsufficientBalanceError |
402 (insufficient_ussd_balance) |
ExtensionRequiredError |
402 (extension_required) |
ConflictError |
409 |
ValidationError (.errors) |
422 |
RateLimitError |
429 |
HellioError |
other |
from hellio import Hellio, InsufficientBalanceError
client = Hellio(token="your-token-here")
try:
client.sms("233241234567", "Hi")
except InsufficientBalanceError:
... # top upverify() is a convenience wrapper: it returns False on a 422 validation
error (invalid code) instead of raising.
Rate limit: 120 requests/minute per token.
The client accepts an injected httpx.Client, so you can mock the transport in
your own tests (for example with respx):
import httpx
from hellio import Hellio
client = Hellio(token="test", http_client=httpx.Client(base_url="https://api.helliomessaging.com/v1/"))MIT