USSD
Run interactive USSD services on Hellio's shared short code. A subscriber dials your extension, Hellio relays each step to your callback, and you return the next menu.
How it works
A webhook passthrough between the subscriber and your server.
Hellio hosts one shared short code, *921#. You rent an extension under it, for example *921*100#, and point it at a callback URL you control. From then on, every menu the subscriber sees is served by your own code:
- A subscriber dials your extension, e.g.
*921*100#. - Hellio opens a session and POSTs the first step to your callback URL.
- Your callback returns a message and whether to keep the session open (
continue) or close it (end). - Hellio shows your message on the handset. Whatever the subscriber types next comes back to your callback as the next step, carrying the same
sessionId. - You return
endwhen the flow is complete. Hellio closes the session and, on the live short code, bills one session fee.
Test and live modes
Build against the sandbox for free, then switch to live once you own an extension.
Every USSD app has a mode, exactly like a test or live API token. A new app starts in test mode, so you can build and exercise the whole flow in the sandbox at no cost. When you are ready for real subscribers, purchase an extension for the app and switch it to live.
- Create the app. POST /v1/ussd/apps registers your callback URL. The app starts in
testmode. - Build and test in the sandbox. Drive the flow with POST /v1/ussd/simulate or the dashboard simulator. This runs in test mode, needs no extension, and is never charged.
- Purchase an extension. Rent an extension and bind it to the app. An app must have an active purchased extension before it can go live.
- Switch to live. POST /v1/ussd/apps/{id}/mode with
{"mode":"live"}. The app now serves real subscribers on its extension and is billed per session. Going live before you own an extension returns402 extension_required.
Each app carries two signing secrets, test_secret and live_secret, both returned when you create the app. Hellio signs every sandbox step with test_secret and every live step with live_secret, so your callback verifies against the secret that matches the request's mode. Rotate either secret at any time with POST /v1/ussd/apps/{id}/rotate-secret.
What Hellio sends your callback
Each step is a signed JSON POST to your callback URL.
For every step, Hellio sends a POST with a JSON body and a X-Hellio-Signature header:
| Field | Type | Description |
|---|---|---|
sessionId | string | Unique id for this dialled session. Identical across every step of the same call. |
msisdn | string | The subscriber's number in international format. |
serviceCode | string | The full dialled code, e.g. *921*100#. |
input | string | What the subscriber typed on this step. Empty on the first step. |
sequence | integer | 1-based step counter. 1 is the opening prompt. |
mode | string | test in the sandbox, live on the real short code. |
{
"sessionId": "atx-8f3ac9e1-2b44-4d70",
"msisdn": "+233241234567",
"serviceCode": "*921*100#",
"input": "1",
"sequence": 2,
"mode": "live"
}
Verifying the signature
Confirm each step really came from Hellio.
Every request is signed so you can be sure it is genuine. The X-Hellio-Signature header is the HMAC-SHA256 of the raw request body, keyed with the secret that matches the request's mode: your app's test_secret for mode: test (sandbox) and live_secret for mode: live (real short code). Both secrets are returned when you create the app via POST /v1/ussd/apps. Recompute the signature over the raw body and compare with a constant-time check:
import crypto from 'node:crypto';
// Read the RAW body, e.g. express.raw({ type: 'application/json' }).
app.post('/ussd', (req, res) => {
const raw = req.body; // Buffer
const step = JSON.parse(raw.toString());
// Pick the secret that matches the request's mode.
const secret = step.mode === 'test' ? TEST_SECRET : LIVE_SECRET;
const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
const got = req.get('X-Hellio-Signature') || '';
if (
expected.length !== got.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got))
) {
return res.status(401).end();
}
// step.sessionId, step.input, step.sequence ...
});
$payload = file_get_contents('php://input');
$step = json_decode($payload, true);
// Pick the secret that matches the request's mode.
$secret = ($step['mode'] ?? 'live') === 'test' ? $testSecret : $liveSecret;
$expected = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($expected, $_SERVER['HTTP_X_HELLIO_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
// $step['sessionId'], $step['input'], $step['sequence'] ...
Your response
Return the next menu and whether to keep the session open.
Respond with a JSON body containing a message and an action:
| Field | Type | Description |
|---|---|---|
message | string | The text to show the subscriber on this step. Keep it within one USSD page (about 182 characters). Use newlines to lay out a menu. |
action | string | continue shows the message and waits for the next input. end shows the message and closes the session. con, continue, prompt and menu all map to continue; anything else ends. |
{
"message": "Welcome to Hellio\n1. Check balance\n2. Buy airtime",
"action": "continue"
}
Session lifecycle and timeouts
How a session opens, continues, and closes.
- Open. The first step arrives with
sequence: 1and an emptyinput. Return your root menu withaction: continue. - Continue. Each reply comes back as a new request with the same
sessionId, an incrementedsequence, and the subscriber'sinput. Hellio sends only the latest input, so track anything you need to remember bysessionId. - End. Return
action: endto finish. Hellio closes the session and, on the live short code, bills one session fee. - Timeout. Your callback must reply within 8 seconds. A slow, failing, or non-2xx callback ends the session gracefully with a fallback message and is not billed. A session left idle past 180 seconds is treated as abandoned.
Extensions and the short code
Rent a dial-in code under the shared short code.
The short code *921# is shared across all clients. You rent an extension, a numeric suffix, and subscribers reach your service by dialling *921*<extension>#. Rent one via POST /v1/ussd/extensions with a numeric code, optionally binding it to an app:
curl -X POST https://api.helliomessaging.com/v1/ussd/extensions \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"100","app_id":"3f2a...."}'
{
"data": {
"id": "9a1c2b3d-....",
"code": "100",
"dial_string": "*921*100#",
"length": 3,
"status": "active",
"monthly_price": "150.0000",
"auto_renew": true,
"app_id": "3f2a....",
"expires_at": "2026-08-06T10:24:01+00:00"
}
}
Extensions are a monthly rental, priced by digit length. Shorter is premium. Check availability and price for a specific code first with GET /v1/ussd/pricing/availability.
USSD balance
A dedicated balance for USSD, separate from your SMS credit and main wallet.
USSD has its own balance. Per-session fees, and API extension rentals, are drawn from it: they never touch your SMS bundle units or your main money wallet. Top up your USSD balance from the dashboard under USSD → Extensions, by card (Paystack) or offline. Over the API, renting draws straight from this balance, so a rental returns 402 insufficient_ussd_balance when it is too low to cover the first month.
From the dashboard, renting an extension is paid per rental (Paystack or offline) and the code goes live only once that payment clears. About two weeks before a rental ends, we email and SMS you to renew; if the month ends without a renewal the extension is suspended until you renew it.
Pricing
A monthly extension rental, plus a per-network fee for each billable session.
Extension rental (per month)
Priced by digit length. Rentals last 30 days and auto-renew from your USSD balance.
| Length | Example | Monthly (GHS) |
|---|---|---|
| 1 digit | *921*1# | 500.0000 |
| 2 digits | *921*10# | 300.0000 |
| 3 digits | *921*100# | 150.0000 |
| 4 digits | *921*1000# | 80.0000 |
| 5 digits | *921*10000# | 40.0000 |
| 6+ digits | *921*100000# | 20.0000 |
Session fee (per billable session)
Each session your callback actually serves is charged once to the app owner's USSD balance, by the subscriber's network. A subscriber who dials and drops before you respond is not charged.
| Network | Per session (GHS) |
|---|---|
| MTN | 0.0300 |
| Telecel | 0.0300 |
| AirtelTigo | 0.0300 |
Sandbox testing
Drive your callback end to end in test mode, with no telco and no charge.
Build and test the whole flow before you go live. POST /v1/ussd/simulate drives a session against your own app in test mode (mode: test), with no aggregator, no purchased extension, and no billing. You reference the app by app_id, so you can simulate it before renting an extension. You may only simulate an app you own.
Start with new_session: true and an empty input to open the session, then repeat with the same session_id and the subscriber's next input until the response returns "action": "end":
# Step 1: open the session
curl -X POST https://api.helliomessaging.com/v1/ussd/simulate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"app_id":"3f2a....","session_id":"test-1","msisdn":"+233241234567","new_session":true}'
# Step 2: send the subscriber's next input
curl -X POST https://api.helliomessaging.com/v1/ussd/simulate \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"app_id":"3f2a....","session_id":"test-1","msisdn":"+233241234567","input":"1"}'
Each call returns your callback's reply plus a convenience continue flag:
{
"data": {
"message": "Your balance is GHS 152.40",
"action": "end",
"continue": false
}
}
API reference
Every USSD endpoint, with request and response schemas.
All USSD endpoints require the ussd scope. The full, interactive reference lives in the API Reference:
| Endpoint | Purpose |
|---|---|
GET /v1/ussd/pricing | Short code, per-network session prices, per-length rentals. |
GET /v1/ussd/pricing/availability | Whether a specific extension code is free to rent, and its price. |
GET /v1/ussd/apps | List your USSD apps. |
POST /v1/ussd/apps | Create an app (name + callback URL). Starts in test mode and returns the test and live signing secrets. |
PUT /v1/ussd/apps/{id} | Update an app's name, callback URL, or active state. |
POST /v1/ussd/apps/{id}/mode | Switch an app between test and live. Going live needs a purchased extension, else 402. |
POST /v1/ussd/apps/{id}/rotate-secret | Rotate the test or live signing secret for an app. |
DELETE /v1/ussd/apps/{id} | Delete an app. |
GET /v1/ussd/extensions | List your rented extensions. |
POST /v1/ussd/extensions | Rent an extension on the shared short code. |
DELETE /v1/ussd/extensions/{id} | Release an extension. |
GET /v1/ussd/sessions | List sessions, filterable by ?status=. |
GET /v1/ussd/sessions/{id} | Inspect a single session, including steps and charge. |
POST /v1/ussd/simulate | Drive your callback in the sandbox, no charge. |
Ready to build your menus? See USSD use cases for a menu router and worked examples.
