This tutorial builds a complete, multi-level USSD menu from scratch — a mock banking application with balance checks and money transfers — with proper session state management, not just a toy single-level example. By the end you'll understand exactly what it takes to go from a webhook handler to a live, dialable short code.
Before writing code, it's worth being precise about the architecture: your application doesn't "connect to" USSD directly. Instead:
*384*7#) with a USSD gateway provider.CON (continue) or END (terminate).That's the entire integration surface — no persistent socket, no special SDK required to get started, just a webhook endpoint.
mkdir ussd-demo && cd ussd-demo
npm init -y
npm install express dotenv
Before writing handler logic, map out the menu as a flowchart — this catches design mistakes far cheaper than debugging them in code:
Main Menu
├── 1. Check Balance → END (show balance)
├── 2. Send Money
│ ├── Enter recipient number → CON
│ ├── Enter amount → CON
│ └── Confirm → END (show confirmation)
└── 3. Mini Statement → END (show last 3 transactions)
Real applications need actual session state — not string-parsing on every request. Here's a version using an in-memory store (swap for Redis in production, since in-memory state won't survive a server restart or work across multiple instances):
// server.js
import express from "express";
import "dotenv/config";
const app = express();
app.use(express.urlencoded({ extended: true }));
// In-memory session store — use Redis in production
const sessions = new Map();
function getSession(sessionId) {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, { step: "MAIN_MENU", data: {} });
}
return sessions.get(sessionId);
}
app.post("/ussd", (req, res) => {
const { sessionId, phoneNumber, text } = req.body;
const session = getSession(sessionId);
const input = text.split("*").pop(); // most recent input only
let response = "";
switch (session.step) {
case "MAIN_MENU": {
if (text === "") {
response = `CON Welcome to Sendexa Demo Bank
1. Check Balance
2. Send Money
3. Mini Statement`;
} else if (input === "1") {
response = `END Your balance is GHS 1,250.00`;
sessions.delete(sessionId);
} else if (input === "2") {
session.step = "ENTER_RECIPIENT";
response = `CON Enter recipient phone number:`;
} else if (input === "3") {
response = `END Last 3 transactions:
-GHS 50.00 (Airtime)
-GHS 200.00 (Transfer)
+GHS 500.00 (Deposit)`;
sessions.delete(sessionId);
} else {
response = `END Invalid option. Please try again.`;
sessions.delete(sessionId);
}
break;
}
case "ENTER_RECIPIENT": {
session.data.recipient = input;
session.step = "ENTER_AMOUNT";
response = `CON Enter amount (GHS):`;
break;
}
case "ENTER_AMOUNT": {
session.data.amount = input;
session.step = "CONFIRM";
response = `CON Send GHS ${session.data.amount} to ${session.data.recipient}?
1. Confirm
2. Cancel`;
break;
}
case "CONFIRM": {
if (input === "1") {
// TODO: call your actual transfer logic here
response = `END GHS ${session.data.amount} sent to ${session.data.recipient}.`;
} else {
response = `END Transaction cancelled.`;
}
sessions.delete(sessionId);
break;
}
default: {
response = `END Session expired. Please try again.`;
sessions.delete(sessionId);
}
}
res.set("Content-Type", "text/plain");
res.status(200).send(response);
});
app.listen(3000, () => console.log("USSD server running on port 3000"));
Notice this version tracks an explicit step per session instead of parsing the accumulated text string on every request — that scales cleanly past two or three menu levels, where string-parsing starts becoming unreadable.
Carriers need a public URL to reach your webhook, so during development, tunnel your local server with a tool like ngrok:
npx ngrok http 3000
Register the resulting HTTPS URL (e.g., https://abc123.ngrok.io/ussd) as your webhook endpoint in the Sendexa dashboard against your sandbox short code, then dial the sandbox code from a test device to walk through the flow exactly as a real user would.
END instead of CON mid-flow — kills the session one step early; every non-final response must be CON.text string for anything beyond 2–3 levels — track explicit state instead, as shown above.Moving from sandbox to production involves:
A production USSD application is really just a webhook with careful state management — the hard part isn't the protocol, it's designing a menu that's short enough for a feature-phone screen and robust enough to handle abandoned sessions and invalid input gracefully. Start with the flow diagram, keep state explicit, and test on real devices before you request a production short code.
For the underlying protocol details — session length, CON/END, and why USSD still matters in 2026 — see our companion guide, What is USSD?
