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:

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:

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

BlocWeave prompt

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:

BlocWeave prompt

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.