Back to Blog
Developer Guides

How to Build a USSD Application: A Step-by-Step Tutorial (2026)

Collins VidzroCollins Vidzro2026-08-1112 min read
How to Build a USSD Application: A Step-by-Step Tutorial (2026)

How to Build a USSD Application: A Step-by-Step Tutorial (2026)

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.


1. How USSD Integration Actually Works

Before writing code, it's worth being precise about the architecture: your application doesn't "connect to" USSD directly. Instead:

  1. You register a short code (e.g., *384*7#) with a USSD gateway provider.
  2. The provider handles the carrier-level plumbing across MTN, Vodafone, Airtel, and other networks.
  3. Every time a user dials your code, the provider sends an HTTP POST webhook to a URL you control.
  4. Your server responds with plain text, prefixed 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.


2. Prerequisites

  • Node.js 18+
  • A Sendexa account with a sandbox USSD short code — sign up at sendexa.co
  • A publicly reachable URL for your webhook during development (see the tunneling step below)

3. Project Setup

mkdir ussd-demo && cd ussd-demo
npm init -y
npm install express dotenv

4. Designing the Menu Flow

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)

5. Building the Webhook Handler

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.


6. Testing Locally

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.


7. Common Mistakes to Avoid

  • Sending END instead of CON mid-flow — kills the session one step early; every non-final response must be CON.
  • No session timeout handling — if a user abandons a session, don't let a stale entry accumulate forever; expire sessions after a few minutes.
  • Parsing the raw text string for anything beyond 2–3 levels — track explicit state instead, as shown above.
  • Forgetting menus have to fit on a small screen — many feature phones display only a handful of lines; keep menu text short.
  • Not handling invalid input gracefully — always have a fallback branch rather than letting an unexpected input crash the flow.

8. Going Live

Moving from sandbox to production involves:

  1. Requesting a production short code — through your provider; approval and carrier registration can take a few days.
  2. Load-testing your webhook — USSD sessions are short-lived but can spike heavily during promotions; make sure your session store (Redis, not in-memory) can handle concurrent load.
  3. Monitoring response times — carriers enforce strict timeouts (typically a few seconds) on your webhook response; a slow database query can silently break your entire menu.
  4. Handling multiple carriers — test the full flow on each carrier you plan to support, not just one, since edge-case behavior can differ slightly.

Conclusion

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?

#USSD#Tutorial#Node.js#Integration
Collins Vidzro

Collins Vidzro

Founder & Lead Developer at Sendexa, writing about high-throughput communication APIs, security, and digital inclusion.

Share: