1. The model: one menu tree, two session directions
On Orbit a USSD service is a menu tree — screens (nodes) with option branches — saved once per tenant with PUT /api/v1/ussd/menu. The engine that walks it is stateless: the aggregator replays the accumulated *-joined input on every step, so the next screen is a deterministic function of the tree and that input. One tree serves both directions of session:
- Dial-in — the subscriber dials your short code; the aggregator opens the session and POSTs each step to your tenant-scoped callback; Orbit answers with the next screen as plain-text
CON/END. - Push — your application opens the session on the subscriber’s handset with
POST /api/v1/ussd/push. ACONpush shows the dispatchmessagefirst; every keypad step after resolves against the same menu a dial-in session uses. AnENDpush is the one-shot notice — a single terminal screen, no tree needed.
CON and the dial-in root are the same screens. Everything below is the same seven steps regardless of direction; only the dispatch changes.
2. Configure sessions, callbacks, and the channel
Three pieces of wiring, each done once per tenant. No carrier traffic should be the trigger for any of them — a push dispatch before all three exist returns409 (no provider), and a CON push against an unsaved menu shows the subscriber “Service is not available.”
- Session callback (both directions). In your aggregator dashboard, set the session callback to
https://api.orbit.devotel.io/api/v1/ussd/callback/<your_tenant_id>. The tenant-scoped path is the whole credential — the endpoint is public by design, and the Numbers → USSD page shows it as a copyable template. Without this URL, dial-in cannot start and aCONpush has nowhere to take the reply. - Menu tree. Save it with
PUT /api/v1/ussd/menu— the worked example in section 3 is the whole call. The validator rejects a broken reference with422before anything is saved, so a malformed tree never answers live traffic. - Push provider (push only). Register the aggregator endpoint with
PUT /api/v1/ussd/push/config(pushUrl, optionalserviceCode,senderId, and the write-onlyauthHeaderValue). The URL must behttps://and must not resolve into internal address space; it is re-validated and DNS-pinned on every dispatch.GETthe config back and you seeauthConfigured: true, never the secret.
3. Build the menu stepwise: the balance-inquiry flow
Grow the tree from the root, one screen per save, simulating between saves — do not assemble seven nodes blind and debug them all at once. The running example: a three-step balance inquiry that collects a confirmation and ends with a follow-up SMS. Step A — the root. Save the entry screen alone:balance node and add two children:
{ "id": "balance-sms", "prompt": "Balance sent by SMS." } and { "id": "bye", "prompt": "Thank you." }. A node with no options ends the session; nothing about that needs a flag.
Step C — the SMS the engine does not send for you. Orbit’s callback answers only the screen; the SMS is your side effect. The balance-sms screen is terminal, so your application fires it on the final callback — where the aggregator tells you which node closed and carries the sessionId — with a normal send call:
sessionId so a retried terminal callback cannot double-send (section 6). The USSD channel page documents the callback payload fields; the SMS channel page covers the send call.
4. Test sessions with the sandbox and simulator
Two test layers before a carrier sees traffic, then one more before the SMS leg goes live. Simulate every path.POST /api/v1/ussd/simulate runs the same engine the live callback uses, against the saved menu or an inline unsaved one. text is the accumulated input exactly as the aggregator replays it — empty for the initial dial, 1 for balance, 1*1 for the SMS confirmation:
node_id, the pre-split action (CON/END), and the exact raw body the aggregator would receive. Walk the whole tree in a loop: opening dial, every numbered branch, every terminal screen, and at least one bad key per menu (9 must end with Invalid selection). The Numbers → USSD page (/numbers/ussd) runs the same simulation visually against the editor’s draft, unsaved edits included — use whichever surface you are already in.
Sandbox the follow-up leg. Point the confirmation SMS at a sandbox magic number, whose trailing digit deterministically selects the delivery outcome (delivered, undelivered, carrier-reject, expired). Your handler then sees every terminal state a real carrier can return — before a real MSISDN runs through it.
5. Cross-channel fallback: USSD to SMS when the session cannot open
A session channel fails open: if the handset is unreachable, off, or the carrier cannot hold the session, there is no screen to show and no keypad answer to wait for. For push, the aggregate is visible one step earlier — the dispatch that cannot reach the provider returns502 (section 6’s table), and a subscriber-side failure surfaces as a session that opens and immediately dies on a carrier timeout.
The fallback rule: a failed USSD leg degrades to SMS, not to nothing.
- Push that cannot open — after your retry budget (section 6) is spent, send the notice as plain SMS with
POST /api/v1/messages/sms. The one-shotENDshape converts cleanly: “Your balance is 1,250 NGN.” is already a valid SMS body. - Session that dies mid-flow — the carrier closed on inactivity or coverage. Because the engine is pure, a re-dial that re-keys the same path lands on the same screens, so the primary recovery is the subscriber re-dialling; the fallback is the terminal value sent by SMS against the last
sessionId. - Interactive step that must complete — a confirmation the business logic cannot skip (a payment approval) should not silently degrade to an SMS no-reply notice. Fail closed on your side: send the SMS asking the subscriber to re-dial the short code, and keep the session-scoped gate on the callback.
prompt verbatim.
6. Production rules: session lifetimes and retry gates
Session lifetimes. The carrier — not your tree — owns the inactivity window, usually 30–90 seconds, and it applies to push exactly as to dial-in. Consequences: keep prompts under 160 characters (a USSD page caps at roughly 182 including carrier framing), make every path terminate, and structure for four decisions or fewer from root to answer. A subscriber abandoned mid-flow re-keys the same path and replays deterministically, because the engine is pure; a flow that relies on no such recovery is wrong for the channel. Retry gates. Two identical-looking calls behave differently, and each needs its own key:- Session callback steps — aggregators retry the terminal
ENDstep, and your side effects fire on that step. Key every side effect on the aggregator’ssessionIdand treat a repeat as a replay, not a duplicate action. The confirmation SMS from section 3 keys on exactly this. - Push dispatches — retry a
502dispatch with the sameclientRequestId, and deduplicate your own outbound calls on that key. A duplicate key means the leg is already in flight or already closed, so the retry is a no-op. Keep the budget small: two retries with backoff, then take the SMS fallback from section 5.
409 → 422 → 502: config, then body, then dispatch.
Live by simulation. A menu edit is safe to save only when the simulator walks every old path green — the editor runs the same engine, so treat a red simulation like a failing test before the PUT goes out.
See Also
- USSD flow builder — the dial-in menu-tree guide with the dashboard editor and deep troubleshooting.
- USSD push sessions — the network-initiated dispatch guide: provider config, idempotency, and the carrier-failure table.
- USSD channel — the protocol contract (
CON/END, accumulated input) and the endpoint map. - USSD session model — the stateless semantics the retry gates above depend on.
- SMS channel — the fallback and follow-up leg documented here.
- Sandbox magic numbers — deterministic outcomes for the non-USSD legs.
- USSD API reference — every field on every endpoint used here.