In Nairobi you can buy lunch with Apple Pay. In a market town three hours away you still send a USSD code to top up airtime, pay school fees, or query a SACCO balance. The 2G phone hasn't died it's been quietly subsidising every fintech that wants reach.
So when we ship a payment platform, USSD isn't a "nice to have" tier. It's tier one.
Why this is harder than it looks
Africa's Talking, Safaricom and the other gateways all expose USSD as an HTTP webhook. Each request carries a session ID, the dialed code, and the user's input so far concatenated with * separators. Your code has to:
- Reconstruct where the user is in the menu tree from a string like
*123#*1*2*3*9870. - Persist transient state (selected merchant, partial entries) for ~3 minutes.
- Reply within ~10 seconds with either
CON ...(continue) orEND ...(terminate). - Never exceed 182 bytes per response.
- Tolerate the user dropping a session, redialing, and resuming.
Most teams reach for nested if/switch and a cache()->put(). It works for two menus. It collapses at fifteen.
What moffhub/ussd does
We modelled menus as a tree of declarative steps:
use Moffhub\Ussd\Menu;
Menu::root('main')
->title('Welcome to ACME Pay')
->item('Pay bill', fn ($m) => $m->goto('pay'))
->item('Check balance', fn ($m) => $m->goto('balance'))
->item('My account', fn ($m) => $m->goto('account'));
Menu::node('pay')
->prompt('Enter merchant code')
->validate('numeric|digits_between:4,8')
->save('merchant_code')
->goto('amount');
Menu::node('amount')
->prompt('Enter amount')
->validate('numeric|min:10|max:150000')
->save('amount')
->goto('confirm');
Menu::node('confirm')
->summary(fn ($s) => "Pay {$s['amount']} to {$s['merchant_code']}? 1) Yes 2) No")
->item('1', PayBill::class)
->item('2', fn () => Menu::end('Cancelled.'));
Sessions persist in Redis (or any cache store) keyed by gateway session ID. The validate() rule reuses Laravel's validator, so your developers already know the syntax. Actions like PayBill::class are plain invokable classes easy to unit-test without simulating the gateway.
Testing
This is the bit that usually doesn't survive contact with reality. We ship a UssdTester:
$tester = (new UssdTester)->dial('*123#');
$tester->respond('1')->assertPrompt('Enter merchant code');
$tester->respond('4321')->assertPrompt('Enter amount');
$tester->respond('500')->assertSummary();
$tester->respond('1')->assertEnded('Payment submitted');
No webhook simulator. No real gateway calls. Tests run in 30 ms.
What's next
We're working on a multi-gateway adapter so the same menu definition runs across Africa's Talking, Safaricom Daraja USSD, and Telkom without any node-tree changes. Watch the repo:
composer require moffhub/ussd
If you've shipped USSD before and have war stories, we want to hear them drop us a note via the contact form.