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

> Build and host menu-driven USSD services for 2G / feature phones: define a menu tree, simulate sessions, and point your aggregator at the session callback.

# USSD API

USSD (Unstructured Supplementary Service Data) is the menu-over-dial-code channel for feature phones. A subscriber dials a short code such as `*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 needs no data plan, which makes it the reach channel for markets where a large share of devices are 2G feature phones.

Orbit hosts the menu and the session engine for you. You configure a menu tree once, point your aggregator (for example Africa's Talking) at the tenant-scoped callback, and Orbit resolves each step.

**Base path:** `/api/v1/ussd`

**Authentication:** API key (`X-API-Key`) or session JWT — except the aggregator callback, which is a public, tenant-scoped webhook.

## How a session works

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

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

Because the aggregator replays the entire input chain on each callback, the engine is stateless: the next screen is a deterministic function of `(menu, accumulated input)`. There is no per-session storage to manage.

## Endpoints

| Method | Path                               | Auth                            | Purpose                                    |
| ------ | ---------------------------------- | ------------------------------- | ------------------------------------------ |
| `GET`  | `/api/v1/ussd/menu`                | API key / JWT                   | Fetch the tenant's configured menu         |
| `PUT`  | `/api/v1/ussd/menu`                | `owner` · `admin` · `developer` | Create or replace the menu                 |
| `POST` | `/api/v1/ussd/simulate`            | API key / JWT                   | Preview a session step                     |
| `GET`  | `/api/v1/ussd/push/config`         | API key / JWT                   | Fetch the push-provider config             |
| `PUT`  | `/api/v1/ussd/push/config`         | `owner` · `admin` · `developer` | Create or replace the push-provider config |
| `POST` | `/api/v1/ussd/push`                | `owner` · `admin` · `developer` | Open a network-initiated (push) session    |
| `POST` | `/api/v1/ussd/callback/{tenantId}` | Public webhook                  | Aggregator session callback                |

## The menu definition

A menu is a tree of **nodes** (screens) navigated from a `root` node. Each branch node lists numbered **options**; a node with no options (or `final: true`) terminates the session.

| Field                     | Type    | Notes                                                                                |
| ------------------------- | ------- | ------------------------------------------------------------------------------------ |
| `serviceCode`             | string  | Optional dial code this menu answers (documentation only, e.g. `*384*1#`).           |
| `root`                    | string  | Id of the node shown on the initial dial. Must reference an existing node.           |
| `nodes`                   | array   | 1–200 screens. Must include `root`.                                                  |
| `nodes[].id`              | string  | Stable node id (≤ 64 chars), referenced by `root` and option `next`. Must be unique. |
| `nodes[].prompt`          | string  | Screen text (≤ 160 chars).                                                           |
| `nodes[].options`         | array   | Up to 12 branch options. Omit for a terminal screen.                                 |
| `nodes[].options[].key`   | string  | Input token the subscriber types (digits, letters, `#` or `*`; ≤ 8 chars).           |
| `nodes[].options[].label` | string  | Label rendered next to the key.                                                      |
| `nodes[].options[].next`  | string  | Id of the node this option navigates to. Must resolve to an existing node.           |
| `nodes[].final`           | boolean | Force the node to end the session even if it has options.                            |

On `PUT /menu` the tree is validated for unique node ids and that `root` plus every option `next` resolves to an existing node; the request is rejected otherwise. A successful write replaces any previously configured menu.

### Create or replace a 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",
        "options": [
          { "key": "1", "label": "Check balance", "next": "balance" },
          { "key": "2", "label": "Buy airtime", "next": "airtime" }
        ]
      },
      { "id": "balance", "prompt": "Your balance is 250.00" },
      { "id": "airtime", "prompt": "Airtime top-up is coming soon." }
    ]
  }'
```

The configured menu is returned under `data.menu`. `GET /api/v1/ussd/menu` returns the same shape, or `data.menu: null` when none has been configured yet.

## Simulate a session step

Run the live engine against an input string without touching your aggregator — useful while building and testing menus. Pass an inline `menu` to preview an unsaved definition; omit it to use the tenant's configured menu.

| Field  | Type   | Notes                                                                  |
| ------ | ------ | ---------------------------------------------------------------------- |
| `text` | string | Accumulated input, `*`-joined (empty or omitted for the initial dial). |
| `menu` | object | Optional inline menu to preview instead of the saved one.              |

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

```json theme={null}
{
  "data": {
    "action": "END",
    "message": "Your balance is 250.00",
    "node_id": "balance",
    "raw": "END Your balance is 250.00"
  },
  "meta": { "request_id": "req_…", "timestamp": "2026-06-18T00:00:00.000Z" }
}
```

`raw` is the exact plain-text body the callback would return for the same input. When no menu is configured the simulator returns an `END` screen rather than an error.

## Aggregator callback

Configure this URL in your aggregator dashboard (for example Africa's Talking). It is a public webhook: the `{tenantId}` path segment scopes the call to the tenant that owns the menu, so no API key is sent. The aggregator posts it on every step of a session.

It accepts both JSON and `application/x-www-form-urlencoded` bodies with the fields below, and responds with a `text/plain` body beginning `CON ` (continue) or `END ` (terminate).

| Field         | Type   | Notes                                         |
| ------------- | ------ | --------------------------------------------- |
| `sessionId`   | string | Aggregator session id (logged for telemetry). |
| `phoneNumber` | string | Subscriber MSISDN.                            |
| `serviceCode` | string | Dial code the subscriber used.                |
| `text`        | string | Accumulated input string, `*`-joined.         |

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ussd/callback/tenant_abc123 \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "sessionId=ATUid_123&phoneNumber=%2B254700000000&serviceCode=*384*1%23&text=1"
```

```text theme={null}
END Your balance is 250.00
```

USSD has no "back" affordance, so an input that doesn't match any option — or that arrives after a terminal screen — ends the session cleanly. A callback for a tenant with no configured menu ends the session with `END Service is not available.` rather than leaving the subscriber hanging.

## Network-initiated (push) sessions

The endpoints above all answer a session the *subscriber* opened by dialling a short code. Push works in the opposite direction: your application asks your aggregator to **open** a USSD session on a subscriber's handset — for balance prompts, mobile-money confirmations, and feature-phone OTP flows.

Push has no company-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 push URL must be an `https://` URL and is re-validated and DNS-pinned against the SSRF guard on every send.

### Configure the push provider

Set the aggregator endpoint your push sessions go out through with `PUT /api/v1/ussd/push/config`.

| Field             | Type   | Notes                                                                                  |
| ----------------- | ------ | -------------------------------------------------------------------------------------- |
| `pushUrl`         | string | **Required.** Aggregator push endpoint. Must be an `https://` URL (≤ 2048 chars).      |
| `serviceCode`     | string | Optional default service / short code the push opens against (e.g. `*384*1#`).         |
| `senderId`        | string | Optional sender / channel id some aggregators require on push.                         |
| `authHeaderName`  | string | Optional header name the provider authenticates with (e.g. `Authorization`, `apiKey`). |
| `authHeaderValue` | string | Optional credential for `authHeaderName`. Write-only — never returned on read.         |

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

`GET /api/v1/ussd/push/config` returns the stored config under `data.config`, or `data.config: null` when none is set. The credential is never echoed back: instead of `authHeaderValue`, the read response carries `authConfigured` — a boolean reporting whether a credential is stored.

```json theme={null}
{
  "data": {
    "config": {
      "pushUrl": "https://ussd.aggregator.example/push",
      "serviceCode": "*384*1#",
      "senderId": "ACME",
      "authHeaderName": "Authorization",
      "authConfigured": true
    }
  },
  "meta": { "request_id": "req_…", "timestamp": "2026-06-18T00:00:00.000Z" }
}
```

### Open a push session

`POST /api/v1/ussd/push` opens the session on the subscriber's handset via the configured aggregator.

| Field             | Type   | Notes                                                                                                 |
| ----------------- | ------ | ----------------------------------------------------------------------------------------------------- |
| `phoneNumber`     | string | **Required.** Destination handset in E.164 (e.g. `+234801234567`). A leading `+` is added if omitted. |
| `message`         | string | **Required.** First screen text (1–182 chars).                                                        |
| `action`          | string | `CON` keeps the session open for a reply; `END` shows a one-shot notice. Defaults to `CON`.           |
| `serviceCode`     | string | Optional override of the configured default service code for this push.                               |
| `clientRequestId` | string | Optional idempotency / correlation key, echoed to the aggregator.                                     |

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

```json theme={null}
{
  "data": {
    "status": "accepted",
    "providerStatus": 200,
    "phoneNumber": "+234801234567"
  },
  "meta": { "request_id": "req_…", "timestamp": "2026-06-18T00:00:00.000Z" }
}
```

`providerStatus` is the HTTP status your aggregator returned (a 2xx). `accepted` means the aggregator took the session; the subscriber's reply, if any, then arrives on your [aggregator callback](#aggregator-callback) like any other step.

### Error codes

| Status | When                                                                                     |
| ------ | ---------------------------------------------------------------------------------------- |
| `409`  | No push provider is configured yet — set one via `PUT /api/v1/ussd/push/config` first.   |
| `422`  | The request body is invalid, or the configured `pushUrl` was rejected by the SSRF guard. |
| `502`  | The aggregator could not be reached, or returned a non-2xx response.                     |

<Note>
  USSD sessions never originate outbound voice or SMS. The reply is returned synchronously to the aggregator inside the open session, so no termination path is involved.
</Note>

## See also

* [Messaging API](/api-reference/endpoints/messaging) — send SMS, WhatsApp, and RCS
* [RCS API](/api-reference/rcs) — rich messaging on Android
