> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build an interactive USSD flow end to end

> Go from zero to a live USSD menu: understand where USSD fits, pick a short code, model a session flow as a menu tree, handle menus and terminal screens, test with the simulator and sandbox numbers, and fix the failures carriers hand you.

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:

```
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. 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](/channels/ussd) 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](/guides/flows-recipes) covers the multi-channel side. For USSD specifically you configure one purpose-fit object — `PUT /api/v1/ussd/menu`:

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/ussd/menu \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "serviceCode": "*384*1#",
    "root": "main",
    "nodes": [
      {
        "id": "main",
        "prompt": "Welcome to Acme. 1. Balance 2. Top up",
        "options": [
          { "key": "1", "label": "Check balance", "next": "balance" },
          { "key": "2", "label": "Buy airtime", "next": "topup" }
        ]
      },
      { "id": "balance", "prompt": "Your balance is 1,250 NGN." },
      { "id": "topup", "prompt": "Enter amount (NGN).", "final": true }
    ]
  }'
```

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

## 5. 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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ussd/simulate \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "text": "1" }'
```

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](/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.

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

* [USSD channel page](/channels/ussd) — the session protocol contract, menu shape reference, and the full endpoint map.
* [Flow recipes](/guides/flows-recipes) — reusable flow definitions when a follow-up SMS/email leg joins the USSD session.
* [Sandbox magic numbers](/sandbox/magic-numbers) — deterministic delivery outcomes for the follow-up leg.
* [Build your first IVR flow](/guides/build-ivr-flow) — graph-validation discipline that applies the same way to USSD menu trees.
* [USSD API reference](/api-reference/ussd) — every field on every endpoint used here.
