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.
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.
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):
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/configregisters 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 behttps://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 booleanauthConfiguredflag.PUT /api/v1/ussd/menustores the menu tree. It is one tree per tenant, shared by both directions — a push whose disposition isCONresumes against the same root a dial-in session would open on.POST /api/v1/ussd/pushdispatches one session.phoneNumberaccepts E.164 with or without the leading+and is normalized before dispatch;messageis 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. ACONpush 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 —
rootand every optionnextpointer resolve by id, so a duplicate id would make navigation ambiguous. - Resolvable references —
rootand every option’snextmust point to an existing node; a broken reference fails validation with422at save time. - DTMF-only option keys — option
keyvalues 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 withfinal: true, closes the session. Explicitly markingfinalterminates 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).
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.
/simulateruns the same pure function the live callback runs, so a simulated walk is a real regression harness, not an approximation of one.
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. ACONre-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 withEND, dispatch itself is the terminal step — design follow-ups accordingly.
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
- USSD channel page — endpoint map, menu shape reference, menu push sessions.
- Send USSD push sessions — the operational walkthrough for the network-initiated direction: provider config, dispatch, retries, sandbox.
- Build an interactive USSD flow — provisioning, testing, and troubleshooting end to end.
- USSD API reference — every field on every endpoint.
- Idempotency and safe retries — the replay-cache convention for the follow-up legs a session may trigger.