Skip to main content

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 page; the endpoint map is on the USSD channel page.

Symptom map

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

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 messages with the index path:
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 ENDs 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:
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):
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, 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