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

# Troubleshooting: USSD menu, callback, and simulate

> Diagnose 'END Service is not available' (unconfigured menu), 'END Invalid selection' (unreachable option keys), and 422 VALIDATION_ERROR on PUT /ussd/menu; callback-URL wiring, accumulated-text re-derivation, offline simulation, and sessionId-keyed follow-ups.

# Troubleshooting: USSD menu, callback, and simulate

A USSD session fails at one of three layers: at menu save time (a 422 on
`PUT /api/v1/ussd/menu`), at the aggregator callback wire (a session that
never starts or closes with an engine fault `END`), or in your own handler
stacked on top of Orbit's callback. Work the layer that matches the symptom
before re-saving the menu or opening an aggregator ticket. Background on the
protocol is on the [USSD session model](/concepts/ussd-session-model) page;
the endpoint map is on the [USSD channel page](/channels/ussd).

## Symptom map

Read the plain-text body the aggregator receives, then match it to a cause:

| Symptom                                                  | Most likely cause                                                                                                                                                                                                             | Fix                                                                                                |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `END Service is not available.` on the initial dial      | No menu saved for the tenant. This is the graceful-close body the callback returns when `GET /api/v1/ussd/menu` would return `data.menu: null`. It is also the body a subscriber sees on any callback error.                  | Save the menu with `PUT /api/v1/ussd/menu` (see below), then retry                                 |
| `END Invalid selection. Session ended.` mid-walk         | The pressed key matched no option on the current screen: a key typed by hand, an option `key` a handset can never send, or input that arrives after a terminal screen (a screen with no options, or one marked `final: true`) | Make every branch key DTMF-only (`^[0-9#*]+$`) and confirm it matches its target node's option key |
| `END Service unavailable.` (engine-resolved route fault) | `root`, or an option's `next` pointer, resolves to a node id that does not exist: a broken route inside a saved menu                                                                                                          | Fix the dangling reference and re-save; save-time validation rejects it before live traffic        |
| HTTP 422 `VALIDATION_ERROR` on menu save                 | One of the tree invariants failed: duplicate node id, a `root` or `next` pointer that does not resolve, or a non-DTMF option key                                                                                              | Read `error.details.issues` in the 422 envelope (see below)                                        |

## The 422 on menu save: decode `details.issues`

`PUT /api/v1/ussd/menu` validates the whole tree before persisting it, and a
rejection is atomic (nothing partial is saved). The failing invariant is in
`error.details.issues`, an array of `message`s with the index path:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "root \"main\" does not reference an existing node",
    "status": 422,
    "details": {
      "issues": [
        { "message": "root \"main\" does not reference an existing node", "path": ["root"] },
        { "message": "option \"1\" points to unknown node \"balance\"", "path": ["nodes", 0, "options", 0, "next"] },
        { "message": "Duplicate node id \"balance\"", "path": ["nodes"] }
      ]
    }
  },
  "meta": { "request_id": "req_...", "timestamp": "..." }
}
```

The three invariant groups the validator enforces, and what each rejection
reads as:

* **Broken references**: `root "<id>" does not reference an existing node`,
  or an option pointer `option "<key>" points to unknown node "<next>"`. Every
  `root` and every option `next` must resolve by id.
* **Duplicate node ids**: `Duplicate node id "<id>"`. Node ids resolve by
  name, so a duplicate makes navigation ambiguous.
* **Non-DTMF option keys**: a key containing anything but digits, `#`, or `*`
  fails the regex `^[0-9#*]+$`. A handset can only relay the 12-key DTMF set,
  so a key like `balance` is unreachable on any live session, and the engine
  answers every real keypress there with "Invalid selection" (see the engine
  fault `END`s above).

Also bounded: 1 to 200 nodes, at most 12 options per node, prompts up to 160
characters (a USSD screen holds roughly 182). Fix the tree and re-submit; the
save is rejected in full, so a green retry after fixing confirms the rest of
the tree is now valid.

## Point the aggregator at the callback URL

`POST /api/v1/ussd/callback/:tenantId` is the public webhook your aggregator
POSTs to on every step of a session. In the Africa's Talking (or Infobip)
dashboard, set the callback URL to the tenant-scoped URL; the `:tenantId`
path segment is the authentication, so the endpoint stays unauthenticated:

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

It accepts JSON **or** `application/x-www-form-urlencoded` bodies (the default
encoding most African aggregators send) carrying `sessionId`, `phoneNumber`,
`serviceCode`, and the accumulated `text`. Find the tenant id in the dashboard
under Settings → Organization, or as `organizationId` on `GET /api/v1/me`.
When the callback body itself fails validation, the subscriber gets the
graceful `END Service is not available.` body above rather than a hanging
session.

## A session that never advances: re-derive from accumulated text

The aggregator replays the **full accumulated input string** on every
callback, with each prior keypress joined by `*`: the initial dial sends an
empty string, and a subscriber who pressed `1` then `2` arrives on the third
step with `text: "1*2"`.

If your handler (stacked on top of Orbit's menu engine, or a custom layer)
tracks session state of its own, it desyncs the moment a callback is retried
or a replica restarts. Re-derive the current screen from the accumulated
`text` on every callback. Because the text carries the full input history,
no stored state survives correct navigation. The engine consumes one
`*`-separated token per branch and drops stray leading, trailing, or double
`*` separators before walking.

## Simulate before you file an aggregator ticket

`POST /api/v1/ussd/simulate` runs the same pure function the live callback
runs, so a simulated walk is a real regression harness, not an approximation.
Cursor to the failing step with the accumulated `text`, and pass an inline
`menu` to preview an unsaved definition (without it, your tenant's saved
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*2" }'
```

The response returns `action` (`CON`/`END`), `message`, `node_id`, `raw` (the
exact wire body), and on the simulate route also `end_reason`:
`invalid-selection` or `unavailable` when the `END` is an engine fault (a
rejected keypress or a broken route) rather than a clean terminal-screen
close. Walk the whole tree offline, and only open an aggregator ticket when
the simulate walk is green end-to-end.

## No ussd.\* webhook events; key the follow-up on sessionId

There is no `ussd.*` event type in the
[Webhook Events catalog](/reference/webhook-events), so a webhook endpoint
waiting on one never fires. Each session completes over the callback POST
plus its synchronous `CON`/`END` text response, and telemetry lives on that
callback axis: the aggregator's request logs, plus the `sessionId` /
`phoneNumber` correlation it supplies. Any follow-up the terminal screen
triggers (a confirmation send, a log record, a status flip) should be keyed
on `sessionId`, so a carrier-level retry of the final callback cannot
double-fire it.

## What not to do

* **Do not retry `PUT /ussd/menu` without reading `error.details.issues`.**
  The save is rejected atomically and the errors are deterministic, so a
  retried unchanged body returns the same 422 with the same invariant
  failure.
* **Do not file the aggregator ticket before a simulate walk.** If
  `/simulate` returns the `CON`/`END` you expect, the failure is on the
  aggregator's callback URL configuration; if it returns an engine fault
  `END`, the menu itself is broken and the aggregator is not involved.
* **Do not key a follow-up on anything but `sessionId`.** The callback axis
  is the only execution record a USSD session leaves.

## When to escalate

Open a support ticket when one of these holds:

* `GET /api/v1/ussd/menu` returns a saved menu (`data.menu` non-null) and a
  simulate walk on the failing step returns a clean terminal `END`, but the
  live callback still closes with `END Service is not available.`.
* The aggregator callback URL is set exactly as above and the aggregator
  still reports a non-2xx or times out on the POST.

Include all three so support can trace the callback without a back-and-forth:

* Your **tenant ID** (the `:tenantId` path segment on the callback URL).
* The full accumulated `text` string from the failing session's callback
  (for example `"1*2"`), plus the returned plain-text body.
* One **request ID** from a failing `/simulate` response's `meta.request_id`,
  when a simulated-step reproduction exists.

## See also

* [USSD channel page](/channels/ussd): endpoint map, menu shape reference,
  push sessions
* [USSD session model](/concepts/ussd-session-model): the stateless engine
  the callback and simulate routes both run
* [Build an interactive USSD flow](/guides/ussd-flow-builder): provisioning,
  testing, and troubleshooting end to end
* [USSD API reference](/api-reference/ussd): every field on every endpoint
* [Error codes reference](/reference/error-codes): the `VALIDATION_ERROR`
  envelope this page decodes
