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

# Worked USSD session samples

> Worked samples for the USSD session lifecycle: define a menu tree, handle the aggregator session callback with CON and END replies, dry-run a step in the simulator, and the errors to expect. Each sample shows the request and the response together.

## Worked USSD session samples

USSD is not a message channel — it is a **session channel**. The subscriber dials a short code (for example `*384*1#`), the aggregator POSTs each step of the session to your callback URL, and **your synchronous HTTP response is the next screen**: a `text/plain` body that starts with `CON ` (keep the session open, render the menu) or `END ` (show a final message and terminate). Samples on this page therefore pair the aggregator's callback and your reply body — one is meaningless without the other. For the interactive builder surface, see the [USSD flow builder guide](/guides/ussd-flow-builder).

Bodies are UTF-8 — menus routinely carry non-English prompts in the regions where USSD reaches feature phones, and any characters a GSM handset can render are valid in a prompt.

### 1. Define the session flow

<Note>
  `PUT /api/v1/ussd/menu`
</Note>

A session flow is one menu tree: unique node ids, a `root` the initial dial renders, and option branches whose `next` points at another node. A node with no `options` — or with `final: true` — is a terminal screen (`END`); anything else renders its choices and continues (`CON`).

**Request**

```json theme={null}
{
  "serviceCode": "*384*1#",
  "root": "main",
  "nodes": [
    {
      "id": "main",
      "prompt": "Karibu Acme. 1. Salio 2. Kununua",
      "options": [
        { "key": "1", "label": "Angalia salio", "next": "balance" },
        { "key": "2", "label": "Nunua airtime", "next": "topup" }
      ]
    },
    { "id": "balance", "prompt": "Salio lako ni TZS 4,200." },
    { "id": "topup", "prompt": "Ingiza kiasi (TZS).", "final": true }
  ]
}
```

The Swahili prompts above are ordinary UTF-8 in the body — no special encoding is needed.

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "menu": {
        "serviceCode": "*384*1#",
        "root": "main",
        "nodes": [
          {
            "id": "main",
            "prompt": "Karibu Acme. 1. Salio 2. Kununua",
            "options": [
              { "key": "1", "label": "Angalia salio", "next": "balance" },
              { "key": "2", "label": "Nunua airtime", "next": "topup" }
            ]
          },
          { "id": "balance", "prompt": "Salio lako ni TZS 4,200." },
          { "id": "topup", "prompt": "Ingiza kiasi (TZS).", "final": true }
        ]
      }
    },
    "meta": {
      "request_id": "req_menu_put",
      "timestamp": "2026-09-01T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

The tree is validated before anything is saved — duplicate node ids, a `root` naming no node, or an option `next` pointing at a missing node all return `422` (see step 4), so a broken graph never answers live sessions.

### 2. Handle a session callback

<Note>
  `POST /api/v1/ussd/callback/{tenantId}`
</Note>

The public callback your aggregator (e.g. Africa's Talking) POSTs to on every step of a session. On the **initial dial** the accumulated `text` is empty and the reply renders the root menu; on each later step the aggregator replays the full `*`-joined input and the reply renders the screen that input resolves to. Read the request and response together — the response is the screen.

**Aggregator request (form-encoded or JSON — both are accepted)**

```
POST /api/v1/ussd/callback/<your_tenant_id>
Content-Type: application/x-www-form-urlencoded

sessionId=ATUid_3f9c1e2a7b4d85f0c1a2b3d4&serviceCode=*384*1%23&phoneNumber=%2B254700123456&text=
```

**Your response — `CON` renders the next menu and keeps the session open**

```
CON Karibu Acme.
1. Salio
2. Kununua
```

Text is UTF-8 end to end; the first token of the response body is all the protocol carries (`CON` / `END`).

**Aggregator request — the subscriber pressed `1`**

```
sessionId=ATUid_3f9c1e2a7b4d85f0c1a2b3d4&serviceCode=*384*1%23&phoneNumber=%2B254700123456&text=1
```

**Your response — `END` shows a final message and terminates the session**

```
END Salio lako ni TZS 4,200.
```

An unmatched keypress also ends the session — `END Invalid selection. Session ended.` — which is deliberate: USSD has no "back", and re-prompting would loop because the aggregator replays the bad token on every callback. Idle sessions are closed by the operator (commonly 30–90 seconds), and because the engine is stateless a subscriber who re-dials and re-keys lands on the same screens — design every path to terminate within a few keypresses.

### 3. Read the session back

The engine is pure and stateless, so nothing is stored per session and there is **no session-history API** — "reading a session back" means fetching the flow definition and replaying the aggregator's accumulated input through the simulator, which runs the same resolution the live callback runs.

<Note>
  `GET /api/v1/ussd/menu`
</Note>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "menu": {
        "serviceCode": "*384*1#",
        "root": "main",
        "nodes": [
          { "id": "balance", "prompt": "Salio lako ni TZS 4,200." }
        ]
      }
    },
    "meta": {
      "request_id": "req_menu_get",
      "timestamp": "2026-09-01T12:01:00.000Z"
    }
  }
  ```
</ResponseExample>

Then replay one step — the aggregator's exact `*`-joined string — and inspect the node-visit outcome:

<Note>
  `POST /api/v1/ussd/simulate`
</Note>

**Request**

```json theme={null}
{
  "text": "1"
}
```

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "action": "END",
      "message": "Salio lako ni TZS 4,200.",
      "node_id": "balance",
      "end_reason": null,
      "raw": "END Salio lako ni TZS 4,200."
    },
    "meta": {
      "request_id": "req_sim",
      "timestamp": "2026-09-01T12:02:00.000Z"
    }
  }
  ```
</ResponseExample>

`data.raw` is the exact `text/plain` body the live callback would return for that input, and `node_id` names the node the walk landed on — the per-step node-visit trail. Walk every path this way before pointing the aggregator at the callback: initial dial, one key per branch, each terminal screen, and one deliberately bad key to confirm the invalid-selection end.

### 4. Errors

**422 — the flow graph is invalid.** Any structural violation in the tree returns `VALIDATION_ERROR` and names the offending spot (issue paths point into `nodes`, `options`, or `root` — an option key may only contain digits, `#`, or `*`, because a handset can only send the 12-key DTMF set):

<ResponseExample>
  ```json 422 theme={null}
  {
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "option "2" points to unknown node "withdraw"",
      "status": 422,
      "details": {
        "issues": [
          {
            "field": "nodes.0.options.1.next",
            "message": "option "2" points to unknown node "withdraw""
          }
        ]
      }
    },
    "meta": {
      "request_id": "req_menu_422",
      "timestamp": "2026-09-01T12:03:00.000Z"
    }
  }
  ```
</ResponseExample>

Re-send the tree with every `next` resolving to a node in `nodes`.

**Callback timeouts.** The callback endpoint answers in milliseconds — there is no slow internal hop, so a hang on the subscriber's side is almost always the operator's idle-session timeout rather than a 504-class delay here. Two defensive cases still matter:

* **You publish no menu.** A callback for a tenant with no configured menu gets `END Service is not available.` (HTTP 200) — the session closes gracefully instead of hanging the subscriber at a frozen prompt.
* **`GET`/`PUT /menu` times out.** Retry with your `meta.request_id` — reads and writes are idempotent, and a replaced menu takes effect on the next callback.

Keep screens under roughly 160 characters and every path terminating; a menu that waits on input forever is the one shape an idle carrier will punish.
