Hellio Messaging
Docs /USSD use cases

USSD use cases

Concrete callback examples built on the USSD passthrough: a reusable menu router, then balance checks, mobile banking, event registration, polls and airtime top-up.

scope: ussd

New to the callback contract? Read USSD first. In short: Hellio POSTs each step to your callback as {sessionId, msisdn, serviceCode, input, sequence, mode} and you reply with {message, action}, where action is continue or end.

A minimal callback

Verify the signature, read the step, return a menu.

The smallest useful callback: verify the signature, branch on sequence and input, and return continue to prompt or end to finish. Hellio sends only the latest input per step, so anything you need to remember across steps you track yourself, keyed on sessionId.

<?php
// POST https://your-app.example/ussd  (this is your callback_url)

$testSecret = getenv('HELLIO_USSD_TEST_SECRET'); // both from POST /v1/ussd/apps
$liveSecret = getenv('HELLIO_USSD_LIVE_SECRET');
$payload    = file_get_contents('php://input');
$step       = json_decode($payload, true);

// 1. Verify it came from Hellio, using the secret for this 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;
}

$input = trim((string) ($step['input'] ?? ''));

// 2. Build a reply. `con()` keeps the session open, `end()` closes it.
function reply(string $message, string $action): void {
    header('Content-Type: application/json');
    echo json_encode(['message' => $message, 'action' => $action]);
    exit;
}
$con = fn (string $m) => reply($m, 'continue');
$end = fn (string $m) => reply($m, 'end');

// 3. Route on the step.
if (($step['sequence'] ?? 1) === 1) {
    $con("Welcome to Hellio\n1. Check balance\n2. Buy airtime");
}

match ($input) {
    '1'     => $end('Your balance is GHS 152.40'),
    '2'     => $con("Enter amount:"),
    default => $end('Invalid choice. Goodbye.'),
};

A reusable menu router

Track state across steps for deeper, multi-level menus.

For menus more than one level deep, keep a small amount of state per session. This Laravel controller stores the subscriber's path in the cache under sessionId and expires it with the session. Each concrete flow below plugs into the same shape.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class UssdCallbackController extends Controller
{
    public function __invoke(Request $request)
    {
        // Verify the signature over the RAW body, with the secret for this request's mode.
        $raw    = $request->getContent();
        $secret = $request->input('mode') === 'test'
            ? config('services.hellio.ussd_test_secret')
            : config('services.hellio.ussd_live_secret');
        $expected = hash_hmac('sha256', $raw, $secret);
        abort_unless(hash_equals($expected, $request->header('X-Hellio-Signature', '')), 401);

        $sessionId = (string) $request->input('sessionId');
        $input     = trim((string) $request->input('input', ''));

        // Per-session scratch state, expires with the USSD session.
        $key   = "ussd:{$sessionId}";
        $state = Cache::get($key, ['screen' => 'root', 'data' => []]);

        [$message, $action, $state] = $this->route($state, $input);

        if ($action === 'continue') {
            Cache::put($key, $state, now()->addSeconds(180));
        } else {
            Cache::forget($key);
        }

        return response()->json(['message' => $message, 'action' => $action]);
    }

    /** @return array{0: string, 1: string, 2: array} */
    private function route(array $state, string $input): array
    {
        return match ($state['screen']) {
            'root' => match ($input) {
                ''      => ["Welcome to Hellio\n1. Check balance\n2. Buy airtime", 'continue', ['screen' => 'menu']],
                default => ['Session started.', 'continue', ['screen' => 'menu']],
            },
            'menu' => match ($input) {
                '1'     => ['Your balance is GHS 152.40', 'end', $state],
                '2'     => ['Enter amount in GHS:', 'continue', ['screen' => 'amount']],
                default => ['Invalid choice. Goodbye.', 'end', $state],
            },
            'amount' => is_numeric($input) && $input > 0
                ? ["Buy GHS {$input} airtime?\n1. Confirm\n2. Cancel", 'continue', ['screen' => 'confirm', 'data' => ['amount' => $input]]]
                : ['Enter a valid amount:', 'continue', $state],
            'confirm' => $input === '1'
                ? ["GHS {$state['data']['amount']} airtime is on its way.", 'end', $state]
                : ['Cancelled.', 'end', $state],
            default => ['Service unavailable.', 'end', $state],
        };
    }
}
The sequence field tells you which step you are on without any storage, which is enough for one and two level menus. Reach for per-session state (as above) only when a later screen needs a value the subscriber entered earlier.

Account balance check

One prompt, one lookup, done.

The simplest useful service. Open with a menu, look up the balance for the caller's msisdn, and end.

$msisdn = $step['msisdn'];

if ($step['sequence'] === 1) {
    $con("MyBank\n1. Account balance\n2. Last 5 transactions");
}

match ($input) {
    '1'     => $end('Balance: GHS '.$account->balanceFor($msisdn)),
    '2'     => $end($account->miniStatement($msisdn)), // fits one USSD page
    default => $end('Invalid choice.'),
};

Mobile-banking menu

A PIN gate, then a menu of actions.

Gate sensitive actions behind a PIN entered on the handset, then branch. Keep the PIN only in per-session state and never log it.

// screen: 'root'  -> ask for PIN
['Enter your 4-digit PIN:', 'continue', ['screen' => 'pin']];

// screen: 'pin'   -> verify, then show the menu
$ok = $bank->verifyPin($step['msisdn'], $input);
$ok
    ? ["1. Balance\n2. Send money\n3. Pay bill\n4. Airtime", 'continue', ['screen' => 'menu']]
    : ['Wrong PIN. Goodbye.', 'end', []];

// screen: 'menu'  -> dispatch
match ($input) {
    '1'     => ['Balance: GHS '.$bank->balance($step['msisdn']), 'end', []],
    '2'     => ['Enter recipient number:', 'continue', ['screen' => 'send_to']],
    '3'     => ['Enter biller code:', 'continue', ['screen' => 'bill']],
    '4'     => ['Enter airtime amount:', 'continue', ['screen' => 'airtime']],
    default => ['Invalid choice.', 'end', []],
};
USSD input is not encrypted end to end. Use it for low-risk actions and short-lived PINs, add server-side attempt limits, and confirm high-value transfers out of band.

Event registration

Collect a few fields across steps, then persist.

Gather one field per screen, storing answers in per-session data, and write the record when you have everything.

// 'root'   -> ["Register for DevFest\nEnter your full name:", 'continue', ['screen' => 'name']]
// 'name'   -> store name, ask ticket type
['Ticket:\n1. Standard\n2. Student', 'continue', ['screen' => 'ticket', 'data' => ['name' => $input]]];

// 'ticket' -> store choice, confirm
$type = $input === '2' ? 'Student' : 'Standard';
["Confirm {$state['data']['name']} - {$type}?\n1. Yes\n2. No", 'continue',
    ['screen' => 'confirm', 'data' => [...$state['data'], 'ticket' => $type]]];

// 'confirm' -> persist and end
if ($input === '1') {
    Registration::create([
        'msisdn' => $step['msisdn'],
        'name'   => $state['data']['name'],
        'ticket' => $state['data']['ticket'],
    ]);
    $end('You are registered. See you there!');
}
$end('Registration cancelled.');

Voting and polls

One vote per number, tallied on the spot.

Show the options, record one vote per msisdn, and confirm. Guard against double voting server-side.

if ($step['sequence'] === 1) {
    $con("Best act of the night?\n1. Sarkodie\n2. Stonebwoy\n3. Black Sherif");
}

$choice = ['1' => 'Sarkodie', '2' => 'Stonebwoy', '3' => 'Black Sherif'][$input] ?? null;

if ($choice === null) {
    $end('Invalid option.');
}

if ($poll->hasVoted($step['msisdn'])) {
    $end('You have already voted. Thank you!');
}

$poll->record($step['msisdn'], $choice);
$end("Vote for {$choice} counted. Thank you!");

Airtime top-up

Enter an amount, confirm, dispatch.

Top up the caller's own number, or another. Confirm the amount before you dispatch so a mistyped digit does not cost money.

// 'root'    -> ["Airtime top-up\n1. My number\n2. Another number", 'continue', ['screen' => 'who']]
// 'who'     -> '1' uses $step['msisdn']; '2' asks for a number ('screen' => 'other')
// 'amount'  -> validate, then confirm
is_numeric($input) && $input >= 1
    ? ["Top up {$target} with GHS {$input}?\n1. Confirm\n2. Cancel", 'continue',
        ['screen' => 'confirm', 'data' => ['target' => $target, 'amount' => $input]]]
    : ['Enter an amount of at least GHS 1:', 'continue', $state];

// 'confirm' -> dispatch on '1'
if ($input === '1') {
    $airtime->topUp($state['data']['target'], $state['data']['amount']);
    $end("GHS {$state['data']['amount']} sent to {$state['data']['target']}.");
}
$end('Top-up cancelled.');
Test any of these against the sandbox before you go live: point an app's callback_url at your handler, then drive it by app_id with POST /v1/ussd/simulate or the dashboard emulator. No purchased extension is needed to simulate. Sandbox sessions run in test mode and are never charged. Once it works, purchase an extension and switch the app to live. See Sandbox testing.
Was this page helpful? Thanks for the feedback! Still stuck? Talk to our team