Skip to main content
An interactive USSD flow is a menu of plain-text screens the subscriber navigates with keypad presses over a live session — no app, no data plan. This guide builds one end to end: a three-branch balance-and-topup menu you can reuse as your first production USSD service. The primitives are few (nodes, options, CON/END replies) and the pitfalls are known (session expiry, carrier prompt quirks). Follow it step for step.

1. Where USSD fits

USSD is the only channel that reaches a feature phone with no data plan, and the only session channel that works on 2G coverage. That makes it the primary product surface in regions where smartphone penetration is uneven — West Africa, East Africa, parts of South and Southeast Asia — and a useful fallback channel elsewhere (balance checks when the app is off-device, OTP-less confirmations). The session is synchronous: the subscriber dials a short code, the mobile operator holds a session open, and every keypress round-trips your application for the next screen. Sessions are cheap for the carrier and survive roaming where IP does not. Choose USSD when the subscriber needs a menu of a few choices in under 160 characters per screen, within a session the carrier closes after tens of seconds of inactivity. For anything longer (surveys, rich content), hand off to SMS or a voice call from the USSD screen.

2. Provision a short code and point it at Orbit

A short code (the *384*1# the subscriber dials) belongs to a mobile network operator. You get it either directly from the MNO or, more commonly, through a USSD aggregator (Africa’s Talking, Infobip, or a regional equivalent) that resells session access. Provision the code with your aggregator first; nothing in Orbit works until the aggregator has somewhere to send session traffic. The link between the provisioned code and Orbit is a single callback URL. In your aggregator dashboard, set the session callback to:
The tenant-scoped path is the whole credential — the endpoint is public by design. The end-to-end picture is: subscriber dials the short code → aggregator opens a session → aggregator POSTs each step to that URL → Orbit replies with the next screen as plain text. The USSD channel page documents the protocol contract (the CON/END first token, the replayed accumulated input) you conform to here. Re-point the callback or swap aggregators at any time — the menu you build below is independent of which carrier path delivers the session.

3. Build the session flow as a menu tree

Orbit models a USSD flow as a menu tree — screens (nodes) with option branches — persisted as a single definition against your tenant. The engine that walks it is stateless: because the aggregator replays the accumulated *-joined input on every step, the next screen is a deterministic function of the menu and that input. If you already keep menus in the Flows API for other channels, this is the same graph-thinking applied to a keypad-driven tree; the flow recipes guide covers the multi-channel side. For USSD specifically you configure one purpose-fit object — PUT /api/v1/ussd/menu:
The validator checks unique node ids and that root plus every option next resolves before anything is saved — a broken reference returns 422, so a malformed tree can never answer live traffic. GET /api/v1/ussd/menu reads the current definition back under data.menu (or null when none is set).

4. Menus, session timeouts, and terminal responses

Each node renders as one screen. Two rules from the protocol drive the whole interaction model:
  • CON keeps the session open. A node with options shows the choices and waits for the next keypress. The subscriber sees your numbered list; you see the selection token (key) appended to the accumulated input.
  • END closes the session. A node with no options, or final: true even with options, renders terminally — the carrier closes the session and the interaction is over. Use terminal nodes for the answers the menu exists to deliver (“Your balance is…”, a confirmation, a one-shot notice).
Session timeouts arrive from the carrier, not from your tree: when the subscriber goes idle past the operator’s window (usually 30–90 seconds), the MNO closes the session itself. Because Orbit’s engine is pure, the damage is bounded — a subscriber who re-dials and re-keys the same path lands on the same screens — but you should still design for abandonment rather than fight it: put the answer as close to the root as a few keypresses allow, keep screens under ~160 characters (a USSD page caps at roughly 182), and make every path terminate instead of relying on indefinite prompts. Never let a bad-key loop form: Orbit ends the session with END Invalid selection. on an unmatched input, which is the only behavior that stops a mis-keyed token from replaying forever. Stray * separators are dropped, so dial strings like **1 desync-proof. A push session (network-initiated) is the inverse — your application opens the session on the subscriber’s handset, for balance prompts, mobile-money confirmations, or feature-phone OTP. It is a tenant-configured provider endpoint plus POST /api/v1/ussd/push; the channel page covers both halves, and the troubleshooting section below includes the push-specific failures.

5. Test before a carrier ever sees traffic

Two layers of testing, both before pointing the aggregator at Orbit. Simulate the tree. POST /api/v1/ussd/simulate runs the same engine as the live callback: pass text — the accumulated *-joined input, empty for the initial dial — and optionally an inline menu to preview an unsaved candidate:
The reply carries the resolved node_id, the pre-split action (CON/END) and message, and the exact raw body the aggregator would receive (e.g. "END Your balance is 1,250 NGN."). Walk every path in a cheap loop: initial dial, one key per branch, the terminal screens, and at least one bad key per menu to confirm the Invalid selection end. This is the regression harness for menu edits — treat a red simulation like a failing test before you re-point the live callback. Sandbox numbers for the outbound sides. When the flow also triggers a follow-up SMS, email, or voice confirmation (common on the topup branch), exercise that leg in sandbox mode with the sandbox magic numbers: the trailing digit of the recipient deterministically selects the delivery outcome (delivered, undelivered, carrier-reject, expired), so your handler for the follow-up receipt is tested against every terminal state before a real carrier digit runs through it. The simulator above covers the USSD session itself; the magic numbers cover whatever the session kicks off.

6. Troubleshooting

Session expiry mid-flow. Symptom: the subscriber is cut off with a carrier message before finishing. The MNO closed on inactivity. Fix in the tree, not in the network: shorten prompts, reduce branch depth, and verify with the simulator that every leaf is reachable in a handful of keypresses. A flow that requires more than ~4 decisions is structured wrong for USSD. Screens render garbled or truncated on some handsets. Some (especially older) MNO gateways insert their own header/footer into the screen, and the character cap is 182 including brackets. Keep prompt under ~160 chars and avoid decorative separators; re-test against a real handset after every menu edit, not only the simulator. Bad key loops — a session that never ends. Symptom: an unmatched input keeps re-prompting. Orbit terminates exactly this case (END Invalid selection.), so it returns only when a custom instrumentation layer added re-prompting logic; the first fix is removing that layer and letting the engine end the session. The aggregator replays the bad token on every subsequent callback, so re-prompting loops forever. Aggregator reaches the callback but gets an error. When no menu is configured, or any unexpected error occurs, Orbit deliberately returns END Service is not available. — a terminal response that closes the session rather than hanging the subscriber. Check GET /api/v1/ussd/menu for a saved definition first; a null data.menu explains most “the dial works but shows an error” reports. Push sessions return 409 / 422 / 502. 409 means no push provider is configured yet — set PUT /api/v1/ussd/push/config first. 422 means the body is invalid or the pushUrl was rejected by URL safety validation — the URL must be https:// and must not resolve into internal address space. 502 means the aggregator endpoint was unreachable or returned non-2xx; retry it and check the provider’s own dashboard for the rejected signature. The callback URL works in the aggregator test console but not for live dials. The /callback/:tenantId path segments are easy to swap on dashboard save. Re-paste the exact URL and confirm the tenant id; the path is the whole credential.

See Also