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. Use the dashboard editor instead of curl

You do not need to hand-craft the JSON above. The Numbers → USSD page (/numbers/ussd in the dashboard) is a full menu editor plus session simulator wired to the same GET/PUT /ussd/menu and POST /ussd/simulate endpoints — everything section 3 does by curl, the page does visually, and it is the better default day-to-day surface. Open it from the Numbers hub (the USSD tab embeds the same page) or deep-link straight to /numbers/ussd. The page also shows the aggregator callback URL as a copyable template — replace the {your-tenant-id} segment with your workspace tenant id and paste it into your aggregator dashboard, exactly as section 2 describes. What the editor checks before it lets you save. The page validates the tree client-side with the same integrity rules the API enforces, so a broken reference is caught before the PUT round-trips. The invariants:
  • Unique screen ids — every node’s id must be unique; duplicates block the save.
  • Resolvable pointers — the start screen (root) plus every option next must point at an id that exists in the tree.
  • DTMF-only option keys — option keys may only contain digits, *, or # (^[0-9#*]+$). A real handset can never send a letter over a USSD session, so a letter key would create an option no phone could reach.
  • Size caps — up to 200 screens per menu, up to 12 options per screen, and prompt text capped at 160 characters (a USSD page caps at roughly 182 chars including carrier framing).
The editor also flags one deliberate UX trap the raw API happily accepts: a non-final screen with zero options. The engine ends the session on any node with no options, so the page requires you to either add an option or check the explicit “Terminate the session on this screen” box — saving an implicitly-terminal screen is impossible to do by accident. Simulate a session from the same page. Below the editor, the simulator panel runs POST /api/v1/ussd/simulate against the menu in the editor — including unsaved edits, since you can pass an inline menu. Type the accumulated input exactly as the aggregator would send it: each keypress joined by *, empty for the initial dial (1*2 for a subscriber who pressed 1 then 2). The panel rejects non-DTMF input up front (digits, *, # only) instead of running a session against a string no handset could produce. Each run returns the resolved screen id, the pre-split CON/END action, the screen body, and the exact raw text the live callback would emit — so a simulated walk with the same inputs is a full-faithfulness regression harness for menu edits before you touch the aggregator. An END that the engine flagged as a fault (an unmatched keypress, or an unmapped route) is rendered as an error badge, not a clean close, so a broken path is visible at a glance. Build a small menu end to end. Using the page, the whole loop is:
  1. Add a screen for the root (e.g. id main) with prompt text like Welcome to Acme. 1. Balance 2. Top up.
  2. Add branch screens (e.g. balance, topup) and mark each one “Terminate the session” — these are your terminal answers.
  3. On the root screen, add options: key 1 pointing to balance, key 2 pointing to topup. Set the start screen to main.
  4. Save — the editor’s validation blocks anything the API would reject anyway.
  5. Simulate: run the initial dial (empty input → CON Welcome to Acme…), then 1 (→ END Your balance…), then 2 (→ terminal top-up screen), then a bad key (9 → END — invalid option). Every path green means the tree is safe to take live.

5. Hand-off to live traffic

Once the menu is saved, live sessions hit POST /api/v1/ussd/callback/:tenantId — the public aggregator endpoint. The callback is read-only against your menu: Orbit answers each step with the plain-text CON/END screen and owns nothing else about the session. If your application records the session (analytics, a follow-up leg, a balance lookup), keep those side effects on END idempotent, keyed on the sessionId the aggregator sends — aggregators may retry a callback and a duplicate terminal step must not double-count.

6. 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.

7. 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.

8. 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