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

# USSD

> Build menu-over-dial-code sessions for 2G / feature phones with the Orbit USSD API

# USSD

USSD (Unstructured Supplementary Service Data) is the menu-over-dial-code channel: a subscriber dials a short code (e.g. `*384*1#`), the mobile network opens a **synchronous session** and posts each keypress to your application, and the application replies with the next screen. It is the de-facto reach channel for feature phones with no data plan — table stakes for West-Africa and other emerging-market deployments (Africa's Talking / Infobip parity).

Orbit models a USSD service as a **menu tree**: a set of screens (`nodes`) navigated from a `root`. You configure the tree once, point your aggregator's session callback at Orbit, and the engine resolves each step for you. The engine is **pure and stateless** — the next screen is a deterministic function of `(menu, accumulated input)` — so it stays correct across pod restarts and replicas without per-session storage.

## The session protocol

Orbit speaks the de-facto African-aggregator protocol (Africa's Talking). On every step the aggregator sends the **full accumulated input string**, with each prior keypress joined by `*` (the initial dial is an empty string). Your reply is a plain-text body whose first token is:

* `CON ` — show the message and keep the session open, awaiting more input.
* `END ` — show the message and terminate the session.

Because the aggregator replays the entire input chain on every callback, you never have to track session state yourself.

## Configure a menu

`PUT /api/v1/ussd/menu` validates and persists the authenticated tenant's menu, replacing any previously configured one.

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

The menu tree is checked for **unique node ids** and that `root` plus every option `next` resolves to an existing node; a broken reference returns `422` before anything is saved. Fetch the current menu with `GET /api/v1/ussd/menu` — it returns the definition under `data.menu`, or `null` when none has been configured.

### Menu shape

| Field         | Where  | Notes                                                                            |
| ------------- | ------ | -------------------------------------------------------------------------------- |
| `serviceCode` | menu   | Optional dial code the menu answers (documentation only), e.g. `*384*1#`.        |
| `root`        | menu   | Id of the node shown on the initial dial. Must reference an existing node.       |
| `nodes`       | menu   | 1–200 screens. Must include `root`.                                              |
| `id`          | node   | Stable id (≤ 64 chars), referenced by `root` and option `next` pointers.         |
| `prompt`      | node   | Screen text (≤ 160 chars — a USSD screen is \~182 chars max).                    |
| `options`     | node   | 0–12 branch choices. Omit (or leave empty) for a terminal screen.                |
| `final`       | node   | Set `true` to terminate the session even when the node has options.              |
| `key`         | option | The token the subscriber types to pick the option (digits, letters, `#` or `*`). |
| `label`       | option | Human-readable text rendered next to the key.                                    |
| `next`        | option | Id of the node this option navigates to.                                         |

A node with no `options` (or `final: true`) is **terminal**: the engine renders it as an `END` screen and closes the session.

## Simulate a session step

`POST /api/v1/ussd/simulate` runs the engine exactly as the live callback would, so you can build and test a menu before pointing your aggregator at it. Pass `text` (the accumulated `*`-joined input — empty for the initial dial) and, optionally, an inline `menu` to preview an unsaved definition. Without an inline `menu`, the tenant's configured menu is used.

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

### Response

```json theme={null}
{
  "data": {
    "action": "END",
    "message": "Your balance is 1,250 NGN.",
    "node_id": "balance",
    "raw": "END Your balance is 1,250 NGN."
  },
  "meta": {
    "request_id": "req_xyz789",
    "timestamp": "2026-06-21T00:00:00Z"
  }
}
```

`raw` is the exact plain-text body the aggregator receives (`CON …` or `END …`); `action` and `message` are the same value pre-split for convenience, and `node_id` is the screen the engine resolved to (useful for telemetry).

## Wire the aggregator callback

`POST /api/v1/ussd/callback/:tenantId` is the **public webhook** your aggregator posts to on every step of a session. The `:tenantId` path segment scopes the call to the tenant that owns the menu, so the endpoint stays unauthenticated — the aggregator authenticates by hitting the tenant-scoped URL you configured in its dashboard.

It accepts JSON **or** `application/x-www-form-urlencoded` bodies (the default for most aggregators) carrying `sessionId`, `phoneNumber`, `serviceCode`, and the accumulated `text`, and responds with a `text/plain` body beginning `CON ` (continue) or `END ` (terminate).

In your Africa's Talking (or equivalent) dashboard, set the callback URL to:

```
https://api.orbit.devotel.io/api/v1/ussd/callback/<your_tenant_id>
```

If no menu is configured for the tenant — or any error occurs — Orbit returns `END Service is not available.` so the session is closed cleanly rather than leaving the subscriber hanging.

## Network-initiated (push) sessions

Everything above answers a session the *subscriber* opened by dialling a short code. A **push session** works in the opposite direction: your application asks your aggregator to **open** a USSD session on a subscriber's handset — the primitive behind balance prompts, mobile-money confirmations, and feature-phone OTP flows.

Push has no platform-wide provider. You register your own aggregator push endpoint per tenant — exactly like the inbound callback URL you set in your aggregator dashboard — and Orbit POSTs each push to it. The `pushUrl` must be an `https://` URL; it is re-validated and DNS-pinned against the SSRF guard on every send.

`PUT /api/v1/ussd/push/config` stores the provider endpoint. The `authHeaderValue` credential is write-only — `GET /api/v1/ussd/push/config` returns the config under `data.config` with the secret replaced by an `authConfigured` boolean, never the value itself.

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/ussd/push/config \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "pushUrl": "https://ussd.aggregator.example/push",
    "serviceCode": "*384*1#",
    "senderId": "ACME",
    "authHeaderName": "Authorization",
    "authHeaderValue": "Bearer agg_secret_token"
  }'
```

With a provider configured, `POST /api/v1/ussd/push` opens the session. Pass the destination `phoneNumber` in E.164 and the first-screen `message` (≤ 182 chars); `action: CON` keeps the session open for a reply (the default), while `END` shows a one-shot notice. An optional `clientRequestId` is echoed to the aggregator for idempotency.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ussd/push \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+234801234567",
    "message": "Your balance is 1,250 NGN. Reply 1 to top up.",
    "action": "CON",
    "clientRequestId": "bal-2026-06-29-001"
  }'
```

The response carries `status: "accepted"` and the `providerStatus` (the 2xx your aggregator returned). The subscriber's reply, if any, then arrives on your callback like any other session step. The call returns `409` when no push provider is configured yet, `422` when the body is invalid or the configured `pushUrl` is rejected by the SSRF guard, and `502` when the aggregator can't be reached or returns a non-2xx.

For the full field reference, see the [USSD API reference](/api-reference/ussd#network-initiated-push-sessions).

## Navigation rules

USSD has no "back" affordance, so the engine never re-prompts on a bad key (the aggregator would replay the bad token on every subsequent callback and loop forever). Instead:

* An input that **doesn't match any option** ends the session with `END Invalid selection. Session ended.`
* Extra input that arrives **after a terminal screen** re-renders that screen and ends the session.
* A stray leading / trailing / double `*` separator is dropped before navigation, so it can't desync the menu walk.

## Capabilities

* **Stateless menu engine** — deterministic `(menu, input)` → screen resolution; no per-session storage, resilient across restarts and replicas.
* **Up to 200 screens, 12 options each** — branch trees with terminal and forced-`final` nodes.
* **Inline preview** — simulate any step against a saved or unsaved menu with `/simulate`.
* **Form-encoded or JSON callbacks** — works with the default body encoding African aggregators send.
* **Network-initiated push** — open a session on a subscriber's handset via your own per-tenant aggregator endpoint, for balance prompts, mobile-money confirmations and OTP flows.

<Note>
  USSD stays inside the GSM session channel in both directions: an inbound reply is answered **synchronously** within the open session, and a push session is opened through your own USSD aggregator. Neither path originates an outbound voice call or SMS, so no outbound-termination configuration is involved.
</Note>

## Pricing

Orbit charges a flat per-1M platform fee for USSD session callbacks; your aggregator (Africa's Talking, Infobip, etc.) bills the underlying session minutes separately. See the [pricing page](https://orbit.devotel.io/pricing).
