Skip to main content

USSD session model

A USSD interaction is a synchronous, session-scoped exchange between a subscriber’s handset and your application, relayed through an aggregator. This page explains the protocol shape, the design invariant that follows from it (a stateless menu engine), the tree validation that invariant makes safe to trust, and what downstream handlers should branch on. For the request-by-request endpoint walkthrough, see the USSD channel page; for the build-it-end-to-end walkthrough, see Build an interactive USSD flow.

1. The synchronous session model

The subscriber dials a short code (for example *384*1#). The mobile network opens a session and your aggregator POSTs each keypress to your callback URL. Your application replies with a plain-text body whose first token decides the disposition:
  • CON — show the message and keep the session open, awaiting more input.
  • END — show the message and terminate the session.
This is the de-facto African-aggregator convention (Africa’s Talking / Infobip parity). Every screen the subscriber sees is one complete request/response round trip: there is no streaming, no server push, and no session polling. Until the session closes, the carrier holds it open; your reply is the only thing that advances it. Orbit’s POST /api/v1/ussd/callback/:tenantId is the public webhook that answers those aggregator steps, and POST /api/v1/ussd/simulate runs the same engine offline so you can walk a tree before a carrier sees it.

2. The stateless-engine invariant

On every callback, the aggregator sends the full accumulated input string — each prior keypress joined by * (the initial dial sends an empty string). A subscriber who pressed 1, then 2, delivers text: "1*2" on the third step. That replay property is what makes the engine stateless. Because every callback carries the complete input history, the next screen is a pure, deterministic function of (menu definition, accumulated input):
There is no per-session state to read, write, or expire. Navigation re-derives from the menu’s root on every callback by consuming one input token per branch, so any replica can answer any step with identical results. The callback only uses sessionId and phoneNumber for telemetry and routing, not for engine state.

3. Menu-tree invariants

The menu definition is validated before it is saved, so a malformed tree can never answer live traffic. The enforced invariants:
  • Unique node idsroot and every option next pointer resolve by id, so a duplicate id would make navigation ambiguous.
  • Resolvable referencesroot and every option’s next must point to an existing node; a broken reference fails validation with 422 at save time.
  • DTMF-only option keys — option key values match ^[0-9#*]+$ (digits, #, *). A real handset can only relay the 12-key DTMF set, so an option keyed to a letter would be unreachable on any live session.
  • Terminal screens — a node with no options, or with final: true, closes the session. Explicitly marking final terminates even when options exist.
  • Bounded tree — up to 200 nodes, at most 12 options per node, prompts capped at 160 characters (a USSD screen holds roughly 182).
Because resolution is pure, a bad keypress is answered deterministically: an input that matches no option ends the session with END Invalid selection., and extra input after a terminal screen re-renders that screen and closes. The engine never re-prompts — a re-prompt would loop forever, because the aggregator replays the same bad token on the next callback.

4. Why statelessness matters

The USSD session lives entirely in the callback POST plus the synchronous plain-text response. No session persistence exists to lose, so:
  • Any replica answers any step. Pod restarts, autoscaling, and multi-replica deployments cannot corrupt a session — a callback routed to a different replica resolves identically.
  • Failure recovery is cheap. A failed session is re-run by re-dialing; the subscriber follows the same path and lands on the same screens. There is no session store to reconstruct.
  • Testing is a full-faithfulness simulation. /simulate runs the same pure function the live callback runs, so a simulated walk is a real regression harness, not an approximation of one.
The trade-off of the replay design is bandwidth, not correctness: callbacks carry the full input history so the application needs none of it.

5. What to branch on

A USSD session completes over the callback handshake alone. Your downstream handling should key on the disposition token:
  • CON — an intermediate screen. Keep the session open; do not trigger confirmations, receipts, or side effects yet. A CON re-rendered on a retry is harmless.
  • END — the session is closed. Treat this as the completion signal for the branch the subscriber selected; any follow-up (a confirmation SMS, a log record, a status flip) fires exactly once on the terminal screen.
Because END screens are the idempotent completion point, a subscriber who re-dials and re-keys the same path receives the same terminal message — a replay, not a duplicate action. Keep any side effect an END triggers idempotent on your side (for example, keyed on sessionId), so a carrier-level retry of the final callback cannot double-fire it.

See also