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. There are two directions of USSD, and the session treats them as one model:
  • Dial-in (handset-initiated) — the subscriber dials a short code (for example *384*1#) and the network opens the session. Your application answers.
  • Push (network-initiated) — your application asks the aggregator to open a session on the subscriber’s handset. The handset rings into the menu with the first screen already on it.
Both directions resolve against the same tenant-scoped menu tree through the same callback contract, and both sit on the same synchronous protocol. This page explains that protocol shape, the design invariant that follows from it (a stateless menu engine), where push adds its own pieces (provider configuration and a dispatch endpoint), 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 end-to-end builds, see Build an interactive USSD flow and Send USSD push sessions.

1. The synchronous session model

Either direction produces the same session. The mobile network opens it and the 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 mid-session, 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/<tenant_id> is the public, tenant-scoped webhook that answers those aggregator steps — one URL serves both directions, so a push you dispatched and a dial the subscriber placed land on the same handler. POST /api/v1/ussd/simulate runs the same engine offline so you can walk a tree before a carrier sees it. The direction differs only in who opens the session: on dial-in the handset dials the short code; on push your application dispatches once and the first screen arrives with the session. From that point on, both are the callback contract above.

2. The stateless-engine invariant

On every callback, the aggregator sends the full accumulated input string — each prior keypress joined by * (the initial dial, or a push’s opening screen, 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. The push direction

Dial-in covers the subscriber reaching you. Push covers you reaching the subscriber — balance prompts, mobile-money confirmations, feature-phone OTP flows. The model adds three pieces on top of the shared callback contract:
  • PUT /api/v1/ussd/push/config registers the tenant’s own aggregator push endpoint (URL plus an optional auth-header credential). The provider is per tenant; nothing is global and no company-wide credential exists. The URL must be https:// and is re-checked against the SSRF guard on every dispatch, so an endpoint that resolves into internal address space fails rather than dispatching. Reads of the config redact the credential to a boolean authConfigured flag.
  • PUT /api/v1/ussd/menu stores the menu tree. It is one tree per tenant, shared by both directions — a push whose disposition is CON resumes against the same root a dial-in session would open on.
  • POST /api/v1/ussd/push dispatches one session. phoneNumber accepts E.164 with or without the leading + and is normalized before dispatch; message is capped at 182 characters so a too-long prompt fails fast rather than being truncated by the GSM network.

END vs CON at dispatch

The dispatch chooses the disposition the session opens with:
  • END — one-shot notice. The handset shows the message and the session closes. No callback ever fires and no menu is needed.
  • CON — session awaiting a reply. The session opens on the menu tree’s root with your message as the first screen, and every keypad step after resolves through the tenant-scoped callback exactly as a dial-in step would. A CON push without the callback URL registered and a menu saved has nowhere to take the reply.

4. Invariants that hold in both directions

Because push and dial-in are two ends of one session, the invariants are direction-independent:
  • Synchronous session — the carrier holds the session open and each screen is one round trip, whichever side opened it.
  • Stateless replay — a push’s first callback carries an empty input string and a dial-in’s carries the accumulated keypad string; the engine treats them identically, as pure functions of (menu, input).
  • CON/END branch targets — menu-tree validation (unique ids, resolvable references, DTMF-only option keys) protects push sessions exactly as it protects dial-in ones, because both resolve against the same saved tree.
  • ~182-char plain-text screens — the payload ceiling is a GSM property, not a direction property; the 182-character cap is enforced on push dispatch for the same reason menu prompts are capped at save time.

5. Menu-tree invariants

The menu definition is validated before it is saved, so a malformed tree can never answer live traffic — in either direction. The enforced invariants:
  • Unique node ids — root and every option next pointer resolve by id, so a duplicate id would make navigation ambiguous.
  • Resolvable references — root 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.

6. 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 (or re-dispatching); 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.

7. 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. For a push dispatched with END, dispatch itself is the terminal step — design follow-ups accordingly.
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. On the push side, pass a stable clientRequestId on dispatch so a replayed request can be deduplicated rather than double-shown.

See also