What you'll build
In this part you'll replace the single-level menu with a full session state machine. Users will be able to navigate through multiple levels of menus — selecting an option, providing input, and seeing a confirmation — all within a single USSD session.
By the end of this part your service will handle:
- A three-level menu tree for all three services
- Session state tracking so the server knows which step each user is on
- Input validation at each level (e.g. reject non-numeric airtime amounts)
- Automatic session cleanup when a session ends
How USSD sessions work
Africa's Talking sends every request in the session to the same webhook. The text field contains the full chain of all user inputs so far, separated by *. For example:
- First request (main menu shown):
text = "" - User presses
2(Buy airtime):text = "2" - User enters
10(amount):text = "2*10" - User confirms with
1:text = "2*10*1"
Your handler parses text.split("*") to know exactly where in the flow the user is. You never need to look up session state from a database for the menu level — it's all in text.
**When to use sessionId:** Use `sessionId` for data that changes between requests and can't be reconstructed from `text` alone — like a fetched account balance or a generated reference number. We'll store those in a server-side `Map
Build the session state machine
Rewrite src/ussd/handler.ts to implement the full GH Utilities session state machine. Parse the incoming text as `const steps = text === "" ? [] : text.split("*")`. Level 0 (steps.length === 0): Show main menu. "Welcome to GH Utilities\n1. Check balance\n2. Buy airtime\n3. Pay utility bill\n0. Exit" Level 1 — main menu selection (steps[0]): - "0" → END "Thank you. Goodbye." - "1" → CON "Check Balance\nEnter your account number:\n0. Back" - "2" → CON "Buy Airtime\nEnter amount (GHS):\n0. Back" - "3" → CON "Pay Utility Bill\nEnter your meter number:\n0. Back" - anything else → CON "Invalid option.\n0. Back to main menu" Level 2 (steps[0] === "1", steps[1] exists): - steps[1] === "0" → go back to main menu (respond CON with the main menu text again) - steps[1] is non-empty string → CON "Account: [steps[1]]\nYour balance is GHS 45.00\n\n1. Pay outstanding balance\n2. View last 3 transactions\n0. Back" Level 2 (steps[0] === "2", steps[1] exists): - steps[1] === "0" → back to main menu - steps[1] is not a valid number or is <= 0 or > 500 → CON "Invalid amount. Enter amount between 1 and 500 GHS:\n0. Back" - valid amount → CON "Buy Airtime\nAmount: GHS [steps[1]]\nThis will be charged to your account.\n\n1. Confirm\n2. Cancel" Level 3 (steps[0] === "2", steps[2] === "1"): END "Airtime purchase of GHS [steps[1]] is being processed. You will receive an SMS confirmation shortly." Level 3 (steps[0] === "2", steps[2] === "2"): END "Airtime purchase cancelled." Level 2 (steps[0] === "3", steps[1] exists): - steps[1] === "0" → back to main menu - non-empty → CON "Meter: [steps[1]]\nOutstanding balance: GHS 120.00\n\n1. Pay GHS 120.00\n2. Pay different amount\n0. Back" Level 3 (steps[0] === "3", steps[2] === "1"): CON "Confirm payment of GHS 120.00 for meter [steps[1]]?\n\n1. Confirm\n2. Cancel" Level 3 (steps[0] === "3", steps[2] === "2"): CON "Enter amount to pay (GHS):\n0. Back" Level 4 (steps[0] === "3", steps[2] === "1", steps[3] === "1"): END "Payment of GHS 120.00 for meter [steps[1]] submitted. Reference: GHU-[random 6-digit number]. SMS confirmation will follow." Level 4 (steps[0] === "3", steps[2] === "1", steps[3] === "2"): END "Payment cancelled." Default fallback: END "Session error. Please try again."
Add a helper to go back to the main menu
Going "back" means re-serving the main menu. Extract it as a constant so you only define it once:
In src/ussd/handler.ts, extract the main menu string into a const MAIN_MENU at the top of the file. Replace all occurrences of the inline main menu text with MAIN_MENU. This keeps all three "back" paths in sync.
Test the full flow
Restart the dev server and go to the AT simulator. Walk through each path:
Balance check: Dial → 1 → enter any account number (e.g. 12345) → should see "Your balance is GHS 45.00" with sub-options.
Airtime purchase: Dial → 2 → enter 50 → confirm with 1 → should see the "being processed" END message.
Invalid input: Dial → 2 → enter abc → should see the invalid amount error with a retry prompt.
Bill payment: Dial → 3 → enter a meter number → 1 to pay → 1 to confirm → should see a reference number in the END message.
Project structure after Part 2
src/
ussd/
handler.ts ← full state machine (steps[] parsing)
index.ts
**Keep menus short:** USSD responses are capped at 182 characters. If your menu text approaches that, shorten option labels. The AT sandbox will show a truncation warning if you exceed the limit.
What's next
In Part 3 we wire up real data — a Supabase database for account records and the Africa's Talking Airtime API to actually send airtime. The balance and meter lookups will query real rows instead of returning hardcoded values.
BlocWeave