Skip to main content

Worked USSD session samples

USSD is not a message channel — it is a session channel. The subscriber dials a short code (for example *384*1#), the aggregator POSTs each step of the session to your callback URL, and your synchronous HTTP response is the next screen: a text/plain body that starts with CON (keep the session open, render the menu) or END (show a final message and terminate). Samples on this page therefore pair the aggregator’s callback and your reply body — one is meaningless without the other. For the interactive builder surface, see the USSD flow builder guide. Bodies are UTF-8 — menus routinely carry non-English prompts in the regions where USSD reaches feature phones, and any characters a GSM handset can render are valid in a prompt.

1. Define the session flow

PUT /api/v1/ussd/menu
A session flow is one menu tree: unique node ids, a root the initial dial renders, and option branches whose next points at another node. A node with no options — or with final: true — is a terminal screen (END); anything else renders its choices and continues (CON). Request
The Swahili prompts above are ordinary UTF-8 in the body — no special encoding is needed.
The tree is validated before anything is saved — duplicate node ids, a root naming no node, or an option next pointing at a missing node all return 422 (see step 4), so a broken graph never answers live sessions.

2. Handle a session callback

POST /api/v1/ussd/callback/{tenantId}
The public callback your aggregator (e.g. Africa’s Talking) POSTs to on every step of a session. On the initial dial the accumulated text is empty and the reply renders the root menu; on each later step the aggregator replays the full *-joined input and the reply renders the screen that input resolves to. Read the request and response together — the response is the screen. Aggregator request (form-encoded or JSON — both are accepted)
Your response — CON renders the next menu and keeps the session open
Text is UTF-8 end to end; the first token of the response body is all the protocol carries (CON / END). Aggregator request — the subscriber pressed 1
Your response — END shows a final message and terminates the session
An unmatched keypress also ends the session — END Invalid selection. Session ended. — which is deliberate: USSD has no “back”, and re-prompting would loop because the aggregator replays the bad token on every callback. Idle sessions are closed by the operator (commonly 30–90 seconds), and because the engine is stateless a subscriber who re-dials and re-keys lands on the same screens — design every path to terminate within a few keypresses.

3. Read the session back

The engine is pure and stateless, so nothing is stored per session and there is no session-history API — “reading a session back” means fetching the flow definition and replaying the aggregator’s accumulated input through the simulator, which runs the same resolution the live callback runs.
GET /api/v1/ussd/menu
Then replay one step — the aggregator’s exact *-joined string — and inspect the node-visit outcome:
POST /api/v1/ussd/simulate
Request
data.raw is the exact text/plain body the live callback would return for that input, and node_id names the node the walk landed on — the per-step node-visit trail. Walk every path this way before pointing the aggregator at the callback: initial dial, one key per branch, each terminal screen, and one deliberately bad key to confirm the invalid-selection end.

4. Errors

422 — the flow graph is invalid. Any structural violation in the tree returns VALIDATION_ERROR and names the offending spot (issue paths point into nodes, options, or root — an option key may only contain digits, #, or *, because a handset can only send the 12-key DTMF set):
Re-send the tree with every next resolving to a node in nodes. Callback timeouts. The callback endpoint answers in milliseconds — there is no slow internal hop, so a hang on the subscriber’s side is almost always the operator’s idle-session timeout rather than a 504-class delay here. Two defensive cases still matter:
  • You publish no menu. A callback for a tenant with no configured menu gets END Service is not available. (HTTP 200) — the session closes gracefully instead of hanging the subscriber at a frozen prompt.
  • GET/PUT /menu times out. Retry with your meta.request_id — reads and writes are idempotent, and a replaced menu takes effect on the next callback.
Keep screens under roughly 160 characters and every path terminating; a menu that waits on input forever is the one shape an idle carrier will punish.