> ## 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 USSD menus end to end — session model to production

> Thread the whole USSD build in one pass: decide dial-in versus push, wire the callback and channel endpoints, grow the menu stepwise, test with the simulator and sandbox numbers, fall back to SMS, and hold the production rules on session lifetimes and retries.

Two guides cover USSD in depth — [Build an interactive USSD flow](/guides/ussd-flow-builder) for the menu tree and [Send USSD push sessions](/guides/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:

```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": "Acme Mobile. 1. My balance 2. Buy airtime",
        "options": [
          { "key": "1", "label": "My 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 }
    ]
  }'
```

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:

```json theme={null}
{
  "id": "balance",
  "prompt": "Your balance is 1,250 NGN. 1. Text it to me 2. Done",
  "options": [
    { "key": "1", "label": "Get balance by SMS", "next": "balance-sms" },
    { "key": "2", "label": "Done", "next": "bye" }
  ]
}
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+234801234567", "from": "AcmeMobile", "body": "Acme Mobile: your balance is 1,250 NGN." }'
```

Key that send on the aggregator's `sessionId` so a retried terminal callback cannot double-send (section 6). The [USSD channel page](/channels/ussd) documents the callback payload fields; the [SMS channel page](/channels/sms) 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:

```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*1" }'
```

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](/sandbox/magic-numbers), 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 `409` → `422` → `502`: config, then body, then dispatch.

| Status                         | Meaning                                           | Gate                                                                          |
| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------- |
| `409` `CHANNEL_NOT_CONFIGURED` | No push provider saved                            | Run `PUT /api/v1/ussd/push/config`                                            |
| `422`                          | Invalid body, or `pushUrl` rejected by URL safety | `https://` URL, not internal address space; E.164 number; message ≤ 182 chars |
| `502` `MESSAGE_SEND_FAILED`    | Provider unreachable or non-2xx                   | Retry with the same `clientRequestId`, check the provider console             |

**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](/guides/ussd-flow-builder) — the dial-in menu-tree guide with the dashboard editor and deep troubleshooting.
* [USSD push sessions](/guides/ussd-push-sessions) — the network-initiated dispatch guide: provider config, idempotency, and the carrier-failure table.
* [USSD channel](/channels/ussd) — the protocol contract (`CON`/`END`, accumulated input) and the endpoint map.
* [USSD session model](/concepts/ussd-session-model) — the stateless semantics the retry gates above depend on.
* [SMS channel](/channels/sms) — the fallback and follow-up leg documented here.
* [Sandbox magic numbers](/sandbox/magic-numbers) — deterministic outcomes for the non-USSD legs.
* [USSD API reference](/api-reference/ussd) — every field on every endpoint used here.
