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:
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:
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
idmust be unique; duplicates block the save. - Resolvable pointers — the start screen (
root) plus every optionnextmust 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).
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:
- Add a screen for the root (e.g. id
main) with prompt text likeWelcome to Acme. 1. Balance 2. Top up. - Add branch screens (e.g.
balance,topup) and mark each one “Terminate the session” — these are your terminal answers. - On the root screen, add options: key
1pointing tobalance, key2pointing totopup. Set the start screen tomain. - Save — the editor’s validation blocks anything the API would reject anyway.
- Simulate: run the initial dial (empty input →
CON Welcome to Acme…), then1(→END Your balance…), then2(→ 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 hitPOST /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:CONkeeps the session open. A node withoptionsshows 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.ENDcloses the session. A node with nooptions, orfinal: trueeven 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).
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:
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. Keepprompt 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
- USSD channel page — the session protocol contract, menu shape reference, and the full endpoint map.
- USSD session model — the stateless session semantics the editor and simulator both rely on.
- Flow recipes — reusable flow definitions when a follow-up SMS/email leg joins the USSD session.
- Sandbox magic numbers — deterministic delivery outcomes for the follow-up leg.
- Build your first IVR flow — graph-validation discipline that applies the same way to USSD menu trees.
- USSD API reference — every field on every endpoint used here.