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.
Configure a menu
PUT /api/v1/ussd/menu validates and persists the authenticated tenant’s menu, replacing any previously configured one.
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
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.
Response
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:
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. ThepushUrl 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.
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.
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.
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-
finalnodes. - 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.
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.