Troubleshooting: USSD menu, callback, and simulate
A USSD session fails at one of three layers: at menu save time (a 422 onPUT /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:
- Broken references:
root "<id>" does not reference an existing node, or an option pointeroption "<key>" points to unknown node "<next>". Everyrootand every optionnextmust 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 likebalanceis unreachable on any live session, and the engine answers every real keypress there with “Invalid selection” (see the engine faultENDs above).
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:
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):
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 noussd.* 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/menuwithout readingerror.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
/simulatereturns theCON/ENDyou expect, the failure is on the aggregator’s callback URL configuration; if it returns an engine faultEND, 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/menureturns a saved menu (data.menunon-null) and a simulate walk on the failing step returns a clean terminalEND, but the live callback still closes withEND 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.
- Your tenant ID (the
:tenantIdpath segment on the callback URL). - The full accumulated
textstring from the failing session’s callback (for example"1*2"), plus the returned plain-text body. - One request ID from a failing
/simulateresponse’smeta.request_id, when a simulated-step reproduction exists.
See also
- USSD channel page: endpoint map, menu shape reference, push sessions
- USSD session model: the stateless engine the callback and simulate routes both run
- Build an interactive USSD flow: provisioning, testing, and troubleshooting end to end
- USSD API reference: every field on every endpoint
- Error codes reference: the
VALIDATION_ERRORenvelope this page decodes