Skip to main content
Two guides cover USSD in depth — Build an interactive USSD flow for the menu tree and Send USSD push sessions for network-initiated dispatch — and each is correct on its own. What neither does alone is thread the whole build: pick the session shape, wire the callbacks and provider config once, grow the menu a step at a time, test it, fail over to SMS when the handset is unreachable, and hold the production rules. This cookbook is that single pass, grounded in a three-step balance-inquiry menu that closes with a confirmation SMS.

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. A CON push shows the dispatch message first; every keypad step after resolves against the same menu a dial-in session uses. An END push is the one-shot notice — a single terminal screen, no tree needed.
Pick the direction by who starts the conversation: a subscriber-initiated service (balance enquiry, self-serve top-up) is dial-in; a notice the subscriber must see now (payment confirmation, feature-phone OTP) is push. Most deployments run both off one tree — the push that lands 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 returns 409 (no provider), and a CON push against an unsaved menu shows the subscriber “Service is not available.”
  1. 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 a CON push has nowhere to take the reply.
  2. 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 with 422 before anything is saved, so a malformed tree never answers live traffic.
  3. Push provider (push only). Register the aggregator endpoint with PUT /api/v1/ussd/push/config (pushUrl, optional serviceCode, senderId, and the write-only authHeaderValue). The URL must be https:// and must not resolve into internal address space; it is re-validated and DNS-pinned on every dispatch. GET the config back and you see authConfigured: true, never the secret.
Step 2 is the only one with anything to iterate on. The other two either exist or return their named errors.

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:
Simulate the opening dial and both branches (section 4), then add the next screen. Step B — a confirmation screen. The balance branch gets a second step: after showing the balance, ask whether the subscriber wants it by SMS. Replace the balance node and add two children:
with terminal leaves { "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:
Key that send on the aggregator’s 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:
Each reply carries the resolved 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 returns 502 (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-shot END shape 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.
Design for the fallback in the menu itself: the answer belongs as close to the root as a few keypresses allow, so the SMS that replaces a dead session is short, self-contained, and already written — most of the time it is the terminal screen’s 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 END step, and your side effects fire on that step. Key every side effect on the aggregator’s sessionId and treat a repeat as a replay, not a duplicate action. The confirmation SMS from section 3 keys on exactly this.
  • Push dispatches — retry a 502 dispatch with the same clientRequestId, 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.
Failure codes, in the order the layers check them. Triage 409422502: 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.