Rhaqim

SystemsRust

ussdframework

Declarative, JSON-driven USSD flows in Rust, with session management, validation, expression routing and a visual menu builder.

ActiveRustSince 2024★ 12MIT
cargo add ussdframework

USSD is how most of a continent banks. It runs on feature phones, over the signalling channel, with no app store and no internet, and every flow is a state machine with a two-second budget. ussdframework handles session management, screen navigation, input validation, function dispatch and routing, so you write business logic instead of a state machine.

An optional Menu Builder module adds an admin portal and REST API for managing menu configuration in a database, with a visual flow editor, JSON import/export, and SQLite or PostgreSQL behind it.

+-------------------------------+
|         Telco / Gateway       |
|  formats USSD requests and    |
|  serializes responses         |
+---------------+---------------+
                |
                v
+-------------------------------+
|      USSD Framework Layer     |
|                               |
|  ┌──────────┐  ┌───────────┐  |
|  │  Router  │  │  Session  │  |
|  └────┬─────┘  └─────┬─────┘  |
|       │              │        |
|  ┌────▼──────────────▼─────┐  |
|  │     Screen Executor     │  |
|  │  Menu / Input / Router  │  |
|  │  Function / Quit        │  |
|  └────────────┬────────────┘  |
+---------------+---------------+
                |
                v
+-------------------------------+
|        Service Layer          |
|  Your Rust functions, HTTP    |
|  calls, database queries      |
+-------------------------------+

Install

[dependencies]
ussdframework = "0.1.0"

With the Menu Builder (SQLite is the default backend):

ussdframework = { version = "0.1.0", features = ["menubuilder"] }

With PostgreSQL instead:

ussdframework = { version = "0.1.0", features = ["menubuilder", "db-postgres"], default-features = false }
FeatureDefaultDescription
db-sqliteyesSQLite database backend
db-postgresnoPostgreSQL database backend
menubuildernoAdmin portal and REST API

Quick start

use ussdframework::prelude::*;

fn main() {
    let mut app = UssdApp::new(true, None);

    // Load screen definitions
    let content = include_str!("../examples/data/menu.json");
    let menus: USSDMenu = serde_json::from_str(content).unwrap();

    // Register your business-logic functions
    app.register_functions(my_functions());

    let request = USSDRequest {
        msisdn: "1234567890".to_string(),
        session_id: "session-abc".to_string(),
        input: "1".to_string(),
        service_code: "*123#".to_string(),
        language: "en".to_string(),
    };

    let response = app.run(request, menus);
    println!("{}", response.message);
}

Screen types

Every screen declares a screen_type that determines how the framework handles it.

TypeDisplaysWaits for inputUse
InitialnonoEntry point; auto-advances to default_next_screen
MenuyesyesNumbered options; validates the selection
InputyesyesFree-text prompt with regex + max-length validation
FunctionnonoCalls a registered function; stores the result in session data
RouternonoEvaluates conditions against session data and branches
QuityesnoFinal message; ends the session

The request lifecycle:

Incoming request


 Resolve or create session


 Has session timed out? ──Yes──► Restart from InitialScreen
      │ No

 Loop over current screen:
  ├── Initial  ──► auto-advance, no display
  ├── Function ──► call registered function, advance
  ├── Router   ──► evaluate expressions, advance
  ├── Menu     ──► display options, wait for input
  ├── Input    ──► display prompt, validate input, wait
  └── Quit     ──► display message, end session


 Persist session, return response

Two navigation shortcuts are built into every screen: 0 goes back one screen, 00 returns to the first screen visited.

Screen properties

PropertyTypeRequiredDescription
textstring or {"lang": "..."} mapyes*Display text; supports {{variable}} interpolation. *Not rendered for Function/Router/Initial.
screen_typestringyesOne of the six types above
default_next_screenstringyesFallback when no condition matches
menu_itemsobjectMenu onlyMap of named option objects
input_identifierstringInput onlySession key the input is stored under
validation_regexstringnoPattern the input must match
max_lengthnumbernoMax character count
max_retriesnumbernoFailed attempts before redirecting
timeout_screenstringnoWhere to send the user once retries run out
functionstringFunction onlyName of the registered function
router_optionsarrayRouter onlyOrdered { router_option, next_screen } pairs
service_codestringnoService code that activates this entry point
"menu_items": {
  "UniqueKey": {
    "option": "1",
    "display_name": "Balance Inquiry",
    "next_screen": "BalanceScreen"
  }
}

The top-level JSON has two keys: the screens themselves, and the services they can call.

{
  "menus": {
    "ScreenName": { }
  },
  "services": {
    "check_balance": {
      "function_name": "check_balance",
      "function_url":  "http://my-app:3000/ussd/check_balance",
      "data_key":      "balance_result"
    }
  }
}
FieldDescription
function_nameMust match a key registered via register_functions(). If no Rust function is found, function_url is tried instead.
function_urlHTTP endpoint the framework POSTs the session to. This is what makes functions polyglot.
data_keyWhere the result lands in session.data; reference it as {{data_key.field}}

Validation and retries

"EnterPinScreen": {
  "text": "Enter your 4-digit PIN:",
  "screen_type": "Input",
  "input_identifier": "pin",
  "validation_regex": "^[0-9]{4}$",
  "max_length": 4,
  "max_retries": 3,
  "timeout_screen": "PinLockedScreen",
  "default_next_screen": "VerifyPinFunctionScreen"
}

max_length is checked before the regex. After max_retries consecutive failures the user is sent to timeout_screen, usually a Quit screen explaining the lockout.

Routing

A Router screen evaluates router_options in order and jumps to the first match, falling through to default_next_screen if nothing matches. Expressions that don’t parse are logged as warnings at menu-load time by validate_router_expressions, not at 2 a.m. in production.

"router_options": [
  { "router_option": "{{balance.status == 'success'}}", "next_screen": "BalanceResultScreen" },
  { "router_option": "{{balance.status == 'failed'}}",  "next_screen": "NetworkErrorScreen" }
]

Syntax is {{<session_key> <op> '<value>'}}, supporting ==, >, >=, < and <=.

Sessions

The framework keeps one session per session_id.

FieldDescription
session_idUnique identifier from the incoming request
msisdnCaller’s phone number
languageLanguage code used for multi-language text lookup
dataKey-value store for collected inputs and function results
current_screenScreen being processed
visited_screensStack backing 0 (back) navigation
screen_attemptsPer-screen failed-attempt counter
end_sessiontrue once a Quit screen is reached

There is a default in-memory store, but you should replace it in production. Implement SessionCache and pass it to UssdApp::new:

use ussdframework::prelude::*;

pub struct RedisSession { /* ... */ }

impl SessionCache for RedisSession {
    fn store_session(&self, session: &USSDSession) -> Result<(), String> {
        let json = serde_json::to_string(session).map_err(|e| e.to_string())?;
        // write json to Redis under session.session_id
        Ok(())
    }

    fn retrieve_session(&self, session_id: &str) -> Result<Option<USSDSession>, String> {
        // read from Redis, deserialize, return
        Ok(None)
    }
}

fn main() {
    let mut app = UssdApp::new(false, Some(Box::new(RedisSession::new())));
}

Functions

Registered functions conform to USSDFunction: fn(&USSDSession, &str) -> USSDData. The return value is stored in session.data under the service’s data_key.

use ussdframework::prelude::*;

fn get_balance(session: &USSDSession, input: &str) -> USSDData {
    let json = json!({ "status": "success", "balance": "KES 1,234.56" });
    USSDData::new(Some(json))
}

fn main() {
    let mut app = UssdApp::new(true, None);

    let mut fns = std::collections::HashMap::new();
    fns.insert("get_balance".to_string(), get_balance as USSDFunction);

    app.register_functions(fns);
}

Webhook functions

When a service’s function_name is not registered in the Rust FunctionMap, the framework POSTs the full session to function_url instead. That makes handlers polyglot: Python, Go, TypeScript, whatever your team already runs, with no Rust in your main project.

# FastAPI
@app.post("/ussd/check_balance")
async def check_balance(request: Request):
    session = await request.json()
    # session["data"] holds every previously collected input
    return {"balance": "KES 1,234.56"}

Whatever JSON you return is stored at session.data[data_key].

A registered Rust function always wins. The webhook is only called when no Rust function is found, and both modes can coexist in the same menu.

Multi-language

text accepts either a plain string or a language map:

"text": {
  "en": "Welcome to Demo Bank",
  "fr": "Bienvenue à Demo Banque",
  "sw": "Karibu Demo Benki"
}

Resolution uses session.language, falling back to "default" (the value stored when a plain string is used), then to an empty string. Set the user’s language with an Input screen early in the flow.

An optional database-backed admin portal for editing screens at runtime. No code changes, no redeploy. app.serve() starts a single Actix-web server handling both POST /ussd and the portal, running migrations on startup.

use ussdframework::prelude::*;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let mut app = UssdApp::new(true, None);
    app.register_functions(my_functions());

    app.serve(
        8080,                            // port
        Some("examples/data/menu.json"), // optional JSON seed, loaded once
        None,                            // DB URL; falls back to USSD_DATABASE_URL
    ).await
}

The portal manages screens, menu items, router options and services, and does JSON import/export. /admin/menu_nodes opens a ReactFlow visual editor: a colour-coded node graph of the whole flow, edges labelled with menu options and router conditions, click-to-edit panels, a legend and a minimap. All screens load in three parallel requests and are grouped client-side, with no per-screen round-trips.

REST API

Everything the portal does is available at http://localhost:8080/api/.

MethodPathDescription
GET/api/screensList all screens
POST/api/screensCreate a screen
PUT/api/screensUpdate a screen
GET/api/screens/{name}Get a screen by name
DELETE/api/screens/{name}Delete a screen
GET/api/screens/multiple?ScreenName=XGet screens matching a query

The same five verbs exist for /api/menu_items, /api/router_options and /api/services, each with a multiple?ScreenName=X variant. Bulk operations live at POST /api/upload (seed the database from JSON) and GET /api/download (export the live configuration).

use ussdframework::builder::menubuilder::MenuBuilder;

MenuBuilder::to_json(Some("output/menu.json"));   // export
MenuBuilder::from_json(Some("input/menu.json"));  // import

Self-hosting

Run the framework as a standalone Docker service and call it over HTTP, with no Rust required anywhere in your stack.

VariableDefaultDescription
USSD_PORT8080Port the server binds to
USSD_DATABASE_URL/app/data/menu.sqlite3SQLite path or Postgres connection string
USSD_JSON_SEEDunsetJSON seed file loaded once on first run
docker run --rm -p 8080:8080 \
  -v "$(pwd)/examples/data/menu.json:/app/menu.json:ro" \
  -e USSD_JSON_SEED=/app/menu.json \
  ussdframework:latest

The included Compose file starts the framework alongside an example Python webhook handler:

make docker-up    # start
make docker-down  # stop
Telco gateway  ──POST /ussd──►  ussdframework container

                           webhook POST │ (Function screen)

                               your-app container
                               (Python / Go / Node.js)

Example

The bundled example covers main-menu navigation, balance inquiry with PIN validation and retry limits, a send-money input chain with confirmation, airtime purchase, account management, English/French selection, and error, network-error and lockout screens.

make run             # menu builder + /ussd on :8080
make run-standalone  # plain /ussd on :8081, no DB or portal
POST http://127.0.0.1:8080/ussd

{
  "msisdn": "0712345678",
  "session_id": "test-session-1",
  "input": "",
  "service_code": "*123#",
  "language": "en"
}