Menu Flows & Navigation
Learn how to build intuitive menu navigation for your USSD application.
Overview
USSD applications are built around menu-driven navigation. Users navigate by:
- Seeing a menu (numbered options)
- Entering a number
- Seeing the next menu or result
- Repeating until task complete
Your backend must:
- Display clear menus
- Route user selections
- Track navigation state
- Handle back/exit options
Basic Menu Structure
Simple Menu Example
Welcome to MyBank USSD
1. Check Balance
2. Transfer Money
3. Buy Airtime
0. Exit
Implementation:
function showMainMenu() {
return {
menu: [
"Welcome to MyBank USSD",
"",
"1. Check Balance",
"2. Transfer Money",
"3. Buy Airtime",
"0. Exit"
],
end_session: false
};
}
Menu Hierarchy
Main Menu
├── 1. Account
│ ├── 1. Check Balance
│ ├── 2. Mini Statement
│ └── 0. Back
├── 2. Transfer
│ ├── 1. Mobile Number
│ ├── 2. Bank Account
│ └── 0. Back
├── 3. Services
│ ├── 1. Buy Airtime
│ ├── 2. Pay Bill
│ └── 0. Back
└── 0. Exit
Implementing Menu Navigation
Step-Based Routing
Use session step to route to correct handler:
function handleRequest(session, input) {
switch (session.step) {
case 'main_menu':
return handleMainMenu(session, input);
case 'account_menu':
return handleAccountMenu(session, input);
case 'transfer_menu':
return handleTransferMenu(session, input);
case 'enter_amount':
return handleAmountEntry(session, input);
case 'enter_pin':
return handlePinEntry(session, input);
case 'confirm':
return handleConfirmation(session, input);
default:
// Fallback to main menu
session.step = 'main_menu';
return showMainMenu();
}
}
Main Menu Handler
function handleMainMenu(session, input) {
if (!input) {
// First dial - show main menu
return {
menu: [
"Welcome to MyBank",
"",
"1. Account",
"2. Transfer",
"3. Services",
"0. Exit"
],
end_session: false
};
}
// Handle selection
switch (input) {
case '1':
session.step = 'account_menu';
return showAccountMenu();
case '2':
session.step = 'transfer_menu';
return showTransferMenu();
case '3':
session.step = 'services_menu';
return showServicesMenu();
case '0':
return {
menu: ["Thank you for using MyBank!"],
end_session: true
};
default:
return {
menu: [
"Invalid option",
"",
"Please try again"
],
end_session: false
};
}
}
Sub-Menu Handler
function handleAccountMenu(session, input) {
if (!input) {
// Just navigated here - show account menu
return {
menu: [
"Account Services",
"",
"1. Check Balance",
"2. Mini Statement",
"0. Main Menu"
],
end_session: false
};
}
switch (input) {
case '1':
// Show balance and end
const balance = await getBalance(session.msisdn);
return {
menu: [
"Account Balance",
"",
`Available: ${balance.available} MZN`,
`Reserved: ${balance.reserved} MZN`
],
end_session: true
};
case '2':
session.step = 'mini_statement';
return showMiniStatement(session.msisdn);
case '0':
session.step = 'main_menu';
return showMainMenu();
default:
return showError("Invalid option");
}
}
Multi-Step Flows
Example: Money Transfer Flow
Implementation
// Step 1: Transfer menu
function handleTransferMenu(session, input) {
if (!input) {
return {
menu: [
"Transfer Money",
"",
"1. To Mobile Number",
"2. To Bank Account",
"0. Main Menu"
],
end_session: false
};
}
if (input === '1') {
session.step = 'enter_recipient';
session.data.transfer_type = 'mobile';
return {
menu: ["Enter recipient mobile number:"],
end_session: false
};
}
// ... handle other options
}
// Step 2: Enter recipient
function handleEnterRecipient(session, input) {
if (!validateMobileNumber(input)) {
return {
menu: [
"Invalid mobile number",
"",
"Please enter a valid number:"
],
end_session: false
};
}
session.data.recipient = input;
session.step = 'enter_amount';
return {
menu: [
`Transfer to: ${input}`,
"",
"Enter amount (MZN):"
],
end_session: false
};
}
// Step 3: Enter amount
function handleEnterAmount(session, input) {
const amount = parseFloat(input);
if (isNaN(amount) || amount <= 0) {
return {
menu: ["Invalid amount", "", "Enter amount:"],
end_session: false
};
}
if (amount > 10000) {
return {
menu: ["Amount exceeds limit", "", "Max: 10,000 MZN"],
end_session: false
};
}
session.data.amount = amount;
session.step = 'enter_pin';
return {
menu: [
"Transfer Summary",
`To: ${session.data.recipient}`,
`Amount: ${amount} MZN`,
"",
"Enter PIN:"
],
end_session: false
};
}
// Step 4: Enter PIN
function handleEnterPin(session, input) {
if (input.length !== 4 || !/^\d+$/.test(input)) {
return {
menu: ["Invalid PIN", "", "Enter 4-digit PIN:"],
end_session: false
};
}
session.data.pin = input;
session.step = 'confirm_transfer';
return {
menu: [
"Confirm Transfer",
"",
`To: ${session.data.recipient}`,
`Amount: ${session.data.amount} MZN`,
"",
"1. Confirm",
"0. Cancel"
],
end_session: false
};
}
// Step 5: Confirmation
async function handleConfirmTransfer(session, input) {
if (input === '0') {
// Cancel
session.step = 'main_menu';
session.data = {};
return {
menu: ["Transfer cancelled"],
end_session: true
};
}
if (input === '1') {
// Process transfer
try {
const result = await processTransfer({
from: session.msisdn,
to: session.data.recipient,
amount: session.data.amount,
pin: session.data.pin
});
return {
menu: [
"Transfer Successful!",
"",
`Amount: ${session.data.amount} MZN`,
`To: ${session.data.recipient}`,
`Ref: ${result.reference}`,
"",
"Thank you!"
],
end_session: true
};
} catch (error) {
return {
menu: [
"Transfer Failed",
"",
error.message,
"",
"Please try again later"
],
end_session: true
};
}
}
return showError("Invalid option");
}
Back Navigation
Using History Stack
// Track navigation history
function navigateTo(session, newStep) {
if (!session.history) {
session.history = [];
}
session.history.push(session.step); // Save current step
session.step = newStep; // Move to new step
}
// Go back
function goBack(session) {
if (session.history && session.history.length > 0) {
session.step = session.history.pop(); // Restore previous step
} else {
session.step = 'main_menu'; // Default to main menu
}
}
// Handle "0" as back button
function handleBackOption(session, input) {
if (input === '0') {
goBack(session);
return getMenuForStep(session.step);
}
// ... handle other options
}
Example with Back Button
function handleServicesMenu(session, input) {
if (!input) {
return {
menu: [
"Services",
"",
"1. Buy Airtime",
"2. Pay Bill",
"3. Check Offers",
"0. Back"
],
end_session: false
};
}
if (input === '0') {
goBack(session);
return showMainMenu();
}
// ... handle other options
}
Input Validation
Phone Number Validation
function validateMozambicanNumber(number) {
// Mozambique: +258 82/84/86/87 + 7 digits
const pattern = /^(258)?(82|84|86|87)\d{7}$/;
return pattern.test(number);
}
function handlePhoneInput(session, input) {
if (!validateMozambicanNumber(input)) {
return {
menu: [
"Invalid phone number",
"",
"Format: 258821234567",
"or: 821234567",
"",
"Try again:"
],
end_session: false
};
}
// Valid number
session.data.phone = normalizeNumber(input);
session.step = 'next_step';
return showNextMenu();
}
function normalizeNumber(number) {
// Remove country code if present
if (number.startsWith('258')) {
number = number.substring(3);
}
// Add country code
return '258' + number;
}
Amount Validation
function validateAmount(input, min = 1, max = 10000) {
const amount = parseFloat(input);
if (isNaN(amount)) {
return { valid: false, error: "Invalid number" };
}
if (amount < min) {
return { valid: false, error: `Minimum: ${min} MZN` };
}
if (amount > max) {
return { valid: false, error: `Maximum: ${max} MZN` };
}
return { valid: true, amount };
}
function handleAmountInput(session, input) {
const validation = validateAmount(input, 100, 50000);
if (!validation.valid) {
return {
menu: [
validation.error,
"",
"Enter amount:"
],
end_session: false
};
}
session.data.amount = validation.amount;
session.step = 'next_step';
return showNextMenu();
}
PIN Validation
function validatePIN(input) {
// Must be 4 digits
if (!/^\d{4}$/.test(input)) {
return { valid: false, error: "PIN must be 4 digits" };
}
return { valid: true };
}
function handlePINInput(session, input) {
const validation = validatePIN(input);
if (!validation.valid) {
return {
menu: [
validation.error,
"",
"Enter PIN:"
],
end_session: false
};
}
session.data.pin = input;
session.step = 'confirm';
return showConfirmation(session);
}
Dynamic Menus
Generate Menu from Data
async function showDynamicMenu(userId) {
// Fetch user's recent transactions
const transactions = await getRecentTransactions(userId);
const menu = ["Select transaction:", ""];
transactions.forEach((txn, index) => {
menu.push(`${index + 1}. ${txn.description} - ${txn.amount} MZN`);
});
menu.push("0. Main Menu");
return {
menu,
end_session: false
};
}
Conditional Menu Options
async function showPersonalizedMenu(msisdn) {
const user = await getUserProfile(msisdn);
const menu = ["Welcome " + user.name, ""];
// Show balance check for all users
menu.push("1. Check Balance");
// Show transfer only for verified users
if (user.verified) {
menu.push("2. Transfer Money");
}
// Show loan option only for eligible users
if (user.loan_eligible) {
menu.push("3. Request Loan");
}
menu.push("0. Exit");
return {
menu,
end_session: false
};
}
Pagination
Long Lists
function showPaginatedList(items, page = 1, pageSize = 5) {
const start = (page - 1) * pageSize;
const end = start + pageSize;
const pageItems = items.slice(start, end);
const menu = ["Select option:", ""];
pageItems.forEach((item, index) => {
menu.push(`${index + 1}. ${item.name}`);
});
// Navigation options
if (end < items.length) {
menu.push("9. Next Page");
}
if (page > 1) {
menu.push("8. Previous Page");
}
menu.push("0. Back");
return {
menu,
end_session: false,
currentPage: page,
totalPages: Math.ceil(items.length / pageSize)
};
}
// Handle pagination input
function handlePaginatedInput(session, input) {
if (input === '9') {
// Next page
session.data.current_page++;
return showPaginatedList(
session.data.items,
session.data.current_page
);
}
if (input === '8') {
// Previous page
session.data.current_page--;
return showPaginatedList(
session.data.items,
session.data.current_page
);
}
// Handle item selection
const itemIndex = parseInt(input) - 1;
const pageSize = 5;
const absoluteIndex = (session.data.current_page - 1) * pageSize + itemIndex;
const selectedItem = session.data.items[absoluteIndex];
return handleItemSelection(session, selectedItem);
}
Error Handling
Generic Error Handler
function showError(message = "An error occurred") {
return {
menu: [
message,
"",
"Please try again",
"",
"Press 0 to exit"
],
end_session: false
};
}
// Usage
try {
const result = await processPayment(session.data);
return showSuccess(result);
} catch (error) {
console.error('Payment error:', error);
return showError("Payment failed. Please try again later");
}
Retry Logic
function handleWithRetry(session, input) {
if (!session.data.retry_count) {
session.data.retry_count = 0;
}
try {
const result = await externalAPICall(input);
session.data.retry_count = 0; // Reset on success
return showSuccess(result);
} catch (error) {
session.data.retry_count++;
if (session.data.retry_count >= 3) {
return {
menu: [
"Service temporarily unavailable",
"",
"Please try again later"
],
end_session: true
};
}
return {
menu: [
"Request failed",
"",
"Press 1 to retry",
"Press 0 to cancel"
],
end_session: false
};
}
}
Best Practices
1. Keep Menus Short and Clear
// ✅ Good - concise
[
"Main Menu",
"",
"1. Balance",
"2. Transfer",
"0. Exit"
]
// ❌ Bad - too verbose
[
"Welcome to our comprehensive banking system main menu",
"Please select from the following options available to you",
"1. Check your current account balance and view details",
"2. Transfer money to another account or mobile number",
"0. Exit the USSD banking system"
]
2. Always Provide an Exit Option
// Always include "0. Exit" or "0. Back"
[
"Services",
"",
"1. Option A",
"2. Option B",
"0. Main Menu" // ✅ Always provide way out
]
3. Validate All Inputs
function handleInput(session, input) {
// Validate before processing
if (!input) {
return showError("No input received");
}
if (!isValidOption(input, session.step)) {
return showError("Invalid option");
}
// Process valid input
return processInput(session, input);
}
4. Provide Clear Error Messages
// ✅ Good - specific and helpful
[
"Invalid amount",
"Min: 100 MZN",
"Max: 10,000 MZN",
"",
"Enter amount:"
]
// ❌ Bad - vague
[
"Error",
"Try again"
]
5. Confirm Destructive Actions
function handleDelete(session, input) {
if (session.step === 'confirm_delete') {
if (input === '1') {
// Actually delete
return performDelete(session.data.item_id);
}
} else {
// Show confirmation first
session.step = 'confirm_delete';
return {
menu: [
"Confirm Delete",
"",
"Are you sure?",
"",
"1. Yes, delete",
"0. Cancel"
],
end_session: false
};
}
}
Complete Example: Airtime Purchase
function handleAirtimePurchase(session, input) {
switch (session.step) {
case 'airtime_menu':
return showAirtimeMenu();
case 'enter_phone':
return handlePhoneEntry(session, input);
case 'select_amount':
return handleAmountSelection(session, input);
case 'confirm_purchase':
return handleConfirmation(session, input);
default:
session.step = 'airtime_menu';
return showAirtimeMenu();
}
}
function showAirtimeMenu() {
return {
menu: [
"Buy Airtime",
"",
"1. For my number",
"2. For another number",
"0. Main Menu"
],
end_session: false
};
}
function handlePhoneEntry(session, input) {
if (input === '1') {
// Use own number
session.data.airtime_phone = session.msisdn;
session.step = 'select_amount';
return showAmountOptions();
}
if (input === '2') {
return {
menu: ["Enter phone number:"],
end_session: false
};
}
// Validate entered number
if (!validateMozambicanNumber(input)) {
return {
menu: ["Invalid number", "", "Enter phone:"],
end_session: false
};
}
session.data.airtime_phone = input;
session.step = 'select_amount';
return showAmountOptions();
}
function showAmountOptions() {
return {
menu: [
"Select amount:",
"",
"1. 10 MZN",
"2. 25 MZN",
"3. 50 MZN",
"4. 100 MZN",
"5. Custom amount",
"0. Cancel"
],
end_session: false
};
}
function handleAmountSelection(session, input) {
const amounts = { '1': 10, '2': 25, '3': 50, '4': 100 };
if (input in amounts) {
session.data.airtime_amount = amounts[input];
session.step = 'confirm_purchase';
return showPurchaseConfirmation(session);
}
if (input === '5') {
return {
menu: ["Enter amount (10-500):"],
end_session: false
};
}
// Handle custom amount
const validation = validateAmount(input, 10, 500);
if (!validation.valid) {
return showError(validation.error);
}
session.data.airtime_amount = validation.amount;
session.step = 'confirm_purchase';
return showPurchaseConfirmation(session);
}
function showPurchaseConfirmation(session) {
return {
menu: [
"Confirm Purchase",
"",
`Phone: ${session.data.airtime_phone}`,
`Amount: ${session.data.airtime_amount} MZN`,
"",
"1. Confirm",
"0. Cancel"
],
end_session: false
};
}
async function handleConfirmation(session, input) {
if (input === '0') {
return {
menu: ["Purchase cancelled"],
end_session: true
};
}
if (input === '1') {
try {
const result = await purchaseAirtime({
from: session.msisdn,
to: session.data.airtime_phone,
amount: session.data.airtime_amount
});
return {
menu: [
"Purchase Successful!",
"",
`${session.data.airtime_amount} MZN airtime`,
`sent to ${session.data.airtime_phone}`,
"",
`Ref: ${result.reference}`
],
end_session: true
};
} catch (error) {
return showError("Purchase failed. Please try again");
}
}
return showError("Invalid option");
}
Next Steps
- Examples - Real-world implementations
- API Reference - Complete API docs