X-API-Key, and the SDK clients below read ORBIT_API_KEY. The USSD routes and the SMS send sit below the SDKs’ typed resources, so the non-cURL tabs use each SDK’s escape hatch — orbit.request (Node.js), client.request (Python and Ruby), client.Request (Go), $client->request (PHP) — the same shape the sandbox cookbook uses for untyped routes, per the SDK sample coverage policy.
Node.js
Python
Go
Ruby
PHP
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.
CON dispatch shows the message on the handset first, and every keypad step after resolves against the same menu a dial-in session uses. A dispatch before the provider and menu exist returns 409; the table in section 6 lists the rest.
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: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. The full save with the replaced node, in every form:
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). 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. Worked session recipes
Every recipe below replays a whole session wire against the public callback endpoint — the same URL you gave your aggregator in section 2 — so the requests need no API key and the response is the rawCON/END text the gateway renders on the handset. The recipes stay on curl on purpose: they replay the aggregator’s exact form-encoded wire format, and in production it is the aggregator — not your application — that posts it. The four recipes cover the four session shapes a production menu needs: a clean choice → branch → end walk, numeric input with preset validation keys, the timeout fallback, and the terminal-callback notification. Where a recipe has an application side effect (SMS, notify), the request for that leg uses dv_test_sk_… against the sandbox — the same call in the SDK forms shown in section 3.
Recipe 1 — menu-driven session: choice, branch, end
The whole session is three callbacks. (1) The opening dial arrives with an emptytext and gets the root menu back as CON. (2) The subscriber’s choice — here 1, “My balance” — resolves one branch down and gets the balance screen with its own two options, still CON. (3) The confirmation choice — 1*2, “Done” — resolves to a terminal leaf and closes the session with END.
Inbound callback payload (step 2 — the choice that branches):
form-encoded body (aggregator default)
form-encoded body
application/x-www-form-urlencoded exactly as the aggregator posts it:
sessionId — that key is what recipe 4’s notification and the SMS side effect deduplicate on.
Recipe 2 — numeric-input validation with preset keys
Free-form numeric entry (an amount to top up) is the hardest USSD shape, because the engine validates every keypress against the option list. The correct design: encode the valid values as option keys, so validation is the navigation itself. Option keys accept multi-digit strings, so a top-up screen can offer exactly the amounts you sell —50, 100, 500 — and a keypad value that is none of those ends the session as an invalid selection instead of being booked as a half-valid amount.
The menu:
form-encoded body
form-encoded body
Recipe 3 — session-timeout fallback reply
A session that the subscriber abandons mid-flow receives no further callbacks — the carrier closes on inactivity (usually 30–90 seconds) and most aggregators never POST a “session ended” notice. The fallback is a tenant-owned control: your callback handler records(sessionId, phoneNumber, lastNode) on every step and arms a timer longer than the carrier’s window; when the timer fires without a terminal END having arrived, you send the fallback SMS yourself. The control is entirely on your side — Orbit holds no per-session state, which is exactly why the timer belongs to you.
Inbound callback payload — the last step your handler saw before the silence:
form-encoded body
POST /api/v1/messages/sms. A sandbox magic number whose trailing digit picks the delivery outcome lets you rehearse the whole fallback without a real MSISDN:
Recipe 4 — callback-on-end notification
The terminalEND step is the business event your application cares about, and aggregators retry it — so treat the terminal callback as a notification you fire once, keyed on sessionId. The notification can be your own webhook, a helpdesk ticket, or the confirmation SMS from section 3; the deduplication contract is the same: first terminal callback for a sessionId fires, repeats are replays.
Inbound callback payload — the terminal close:
form-encoded body
sessionId, so the aggregator’s retry of the terminal callback re-reads “already sent” and does nothing:
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 and the notification in recipe 4 key 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 recipe 3.
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.
7. 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.
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.