ussd/callback, PUT /ussd/menu, PUT /ussd/push/config, POST /ussd/push) and the pitfalls are known (unconfigured provider, URL-safety rejects, aggregator failures, replay sessions). Follow it step for step.
1. What USSD is, and when push beats SMS or voice
USSD is the only channel that reaches a feature phone with no data plan, and the only session channel that works on 2G coverage. A session is synchronous: the network holds it open and every screen round-trips as plain text under ~182 characters, with the carrier closing the session after tens of seconds of inactivity. Dial-in USSD begins on the handset — the subscriber dials the short code. A push session begins at your application: you dispatch once, and the subscriber’s handset rings into the menu with the first screen already on it. Push is the right call when the subscriber did not ask for the interaction but must see it now:- Balance and statement prompts — push a balance screen straight to the handset, no short code to memorize.
- Mobile-money confirmations — a payment step that must reach the subscriber immediately, with the keypad answer feeding back into a callback your application owns.
- Feature-phone OTP — a one-shot code or a yes/no confirmation to a device that cannot run your app.
END for the one-shot notice (“Your balance is 1,250 NGN.”) or CON for a session that stays open awaiting the subscriber’s reply. The same channel powers both — choose at dispatch time.
2. Point the aggregator at your callback URL
A push session only lives for one screen without one more piece of wiring. The dispatch (POST /api/v1/ussd/push) opens the session; every keypad step after still resolves through the same tenant-scoped callback a dial-in session uses. In your aggregator dashboard (Africa’s Talking, Infobip, or any HTTP-fronted gateway), set the session callback to:
sessionId, phoneNumber, serviceCode, and the accumulated text. The end-to-end picture is: your application dispatches the push → the aggregator opens the session on the handset → every further keypress POSTs to that URL → Orbit replies with the next screen as plain text. Without this URL registered, an END push still delivers its one-shot notice, but a CON push has nowhere to take the reply.
3. Shape the menu the push resumes against
A push whoseaction is CON opens on the menu tree’s root, and every step after resolves against that tree exactly as a dial-in step would. The tree is one tenant-scoped definition persisted with PUT /api/v1/ussd/menu:
serviceCode— optional, documentation only: the dial code this menu answers.root— the id of the node shown when a session (dial-in or push withCON) opens.nodes[]— 1 to 200 screens. Each node carries a uniqueid(≤64 chars) andprompttext (1–160 chars; a USSD page caps at roughly 182 including carrier framing).- Default to terminal — a node with no
optionsends the session withEND. A node withoptionsrepliesCONand waits.final: trueforces termination even when options exist. options[]— up to 12 branches. Each option has a DTMF-onlykey(digits,*,#;^[0-9#*]+$— a real handset can never send a letter), alabel, and anextpointer.
root plus every option next resolves before anything is saved — a broken reference returns 422, so a malformed tree can never answer live traffic. GET /api/v1/ussd/menu reads the current definition back under data.menu (or null when none is set). Skip this step only when every push you dispatch is a one-shot END — the moment a CON push reaches the callback with no menu saved, the carrier shows “Service is not available.”
4. Worked example: a three-branch balance-and-topup tree
The menu below is the same three-branch shape the flow-builder guide uses for dial-in — which is the point: push and dial-in sessions share one tree, so a push that opens withCON lands on this root and offers balance, top up, and support. Save it once and both directions resolve:
balance, topup, support) hang off one root; the topup leaf is marked final explicitly so the engine terminates on it. When the push dispatch (section 6) sends action: "CON", the subscriber’s 1 reply POSTs to the callback from section 2 with accumulated text: "1" and the engine answers END Your balance is 1,250 NGN. Keep the answer as close to the root as a few keypresses allow — the carrier’s inactivity window applies to push sessions exactly as it does to dial-in.
5. Configure the push provider
The push provider is your own aggregator endpoint — registered per tenant; nothing is global and no company-wide credential exists. Set it once withPUT /api/v1/ussd/push/config:
pushUrlmust be anhttps://URL. The validator also rejects URLs that resolve into internal address space, and it is re-validated and DNS-pinned against the SSRF guard on every dispatch — an aggregator endpoint behind a private hostname fails at write time rather than silently.authHeaderValueis a write-only credential. Read back the config withGET /api/v1/ussd/push/configand the response showsauthConfigured: trueinstead of the secret — the dashboard’s push panel mirrors it the same way (the push card on Numbers → USSD shows the saved endpoint plus an “auth header configured” flag, never the value).
serviceCode sets the default code every push opens against (section 4’s tree answers it); senderId carries the sender / channel id some aggregators require; authHeaderName picks the header the credential travels in.
6. Dispatch a session
Dispatch one push session per call toPOST /api/v1/ussd/push:
phoneNumber accepts E.164 with or without the leading + (normalized on the way in). message is capped at the 182-character USSD payload ceiling, so a too-long prompt fails fast here rather than being silently truncated by the GSM network. action defaults to CON; send END for the one-shot notice. A successful call returns a 200 envelope with { "status": "accepted", "providerStatus": 200, "phoneNumber": "+234801234567" } — “accepted” is the aggregator confirming receipt, which is as far as the synchronous API can see. The session itself opens on the handset asynchronously after that.
7. Idempotency and retries
Push aggregators retry. Assume a dispatch can arrive twice at the provider and design for it.POST /api/v1/ussd/push accepts clientRequestId — a caller-supplied key echoed to the aggregator in the dispatch envelope — so a replayed request keys on the same value and your side can deduplicate. Key the id on what the session means (bal-20260910-001 above), not on a random nonce per attempt.
The “exactly-once per hangup” rule for a push leg means the session lifecycle across retries resolves to one terminal step: a replayed CON that is open must close, not re-open; a replayed END must not re-show. With clientRequestId in the envelope you can assert both from your own callback — a duplicate key means the leg is already in flight or already closed.
For END one-shot notices: key the dispatch by a tenant-side id, treat a duplicate END as a no-op, and never fire a second dispatch for the same logical prompt. Keep the side effects your callback records for the session (a balance lookup, a top-up confirmation) idempotent too, keyed on the sessionId the aggregator sends — the same callback discipline the dial-in side needs, because the push and the menu are two ends of one session.
8. Test before a carrier ever sees traffic
Three layers of testing, all before the first live dispatch. Simulate the menu in the dashboard. The Numbers → USSD page (/numbers/ussd) runs the same menu editor and session simulator the flow-builder guide documents, wired to POST /api/v1/ussd/simulate. Run the screen your push is about to open in a browser instead of burning live pushes: type the accumulated input exactly as the aggregator would send it (1 for the balance branch, empty for the opening screen) and check the CON/END disposition and the exact raw text the live callback would emit. The push card on the same page shows the saved provider endpoint — one surface, both halves.
Simulate from curl. The same endpoint accepts a text and optionally an inline menu to preview an unsaved candidate:
node_id, the pre-split action (CON/END) and message, and the exact raw body the aggregator would receive (e.g. "END Your balance is 1,250 NGN."). Walk every path the push can land on in a cheap loop before you re-point the aggregator — a red simulation is a failing test.
Sandbox numbers for the follow-up leg. When a push step triggers a follow-up SMS, email, or voice confirmation (common on a top-up branch), exercise that leg in sandbox mode with the sandbox magic numbers: the trailing digit of the recipient deterministically selects the delivery outcome (delivered, undelivered, carrier-reject, expired), so your handler for the follow-up receipt is tested against every terminal state before a real MSISDN runs through it. The simulator covers the session screens; the magic numbers cover whatever the session kicks off.
9. Troubleshooting carrier-handled failures
Triage in the
409 → 422 → 502 order. The call layer checks config → body → dispatch, so a second retry may legitimately move from one class to the next as each layer passes.
See Also
- USSD flow builder — the menu-tree editor and callback walkthrough for the dial-in side, in more depth.
- USSD channel — the session protocol (
CON/ENDfirst token, accumulated input) and the full endpoint map. - USSD session model — the stateless session semantics the simulator and live callback both rely on.
- Sandbox magic numbers — deterministic delivery outcomes for the follow-up leg a push session kicks off.
- USSD API reference — every field on every endpoint used here.