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

# Send network-initiated USSD push sessions end to end

> Open a USSD session on a subscriber's handset from your application: configure the aggregator push endpoint, dispatch push sessions, handle idempotency across aggregator retries, sequence push with your menu tree, and triage the 409 / 422 / 502 failures.

A push (network-initiated) USSD session is the inverse of a dial-in session: your application asks the aggregator to open a session on the subscriber's handset instead of waiting for the subscriber to dial the short code. This guide takes one from zero to live: configure the provider URL, dispatch the session, make retries safe, and handle the three error classes the endpoint returns by design. The primitives are few (`PUT /ussd/push/config`, `POST /ussd/push`) and the pitfalls are known (unconfigured provider, URL-safety rejects, aggregator failures). Follow it step for step.

## 1. What a push session is

Dial-in USSD begins on the handset — the subscriber dials the short code and the network opens the session. A push session begins at your application: you send the dispatch once, and the subscriber's handset rings into the menu with the first screen already on it. That is the only primitive behind three workflows the dial-in model cannot do:

* **Balance prompts** — push a screen showing a balance or statement straight to the handset, no short code to memorize.
* **Mobile-money confirmations** — a payment step that must reach the subscriber immediately, with the menu answer feeding straight back into a callback your application owns.
* **Feature-phone OTP** — deliver a one-shot code or a confirmation yes/no to a device that cannot run your app.

Pick `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 the disposition at dispatch time.

## 2. Configure the provider

The push provider is your own USSD aggregator endpoint — Africa's Talking, Infobip, or any HTTP-fronted gateway. You register it per tenant; nothing is global and no company-wide credential exists. Set it once with `PUT /api/v1/ussd/push/config`:

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/ussd/push/config \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "pushUrl": "https://aggregator.example.com/ussd/push",
    "serviceCode": "*384*1#",
    "authHeaderName": "apiKey",
    "authHeaderValue": "agg_live_your_key"
  }'
```

Two fields matter at write time:

* `pushUrl` must be an `https://` URL. The validator also rejects URLs that resolve into internal address space, so an aggregator endpoint behind a private hostname fails at write time rather than silently.
* `authHeaderValue` is a write-only credential. Read back the config with `GET /api/v1/ussd/push/config` and the response shows `authConfigured: true` instead of the secret — the dashboard's push panel mirrors it the same way (the push card shows the saved endpoint plus an "auth header configured" flag, never the value).

Optional fields: `serviceCode` sets the default code every push opens against; `senderId` carries the sender / channel id some aggregators require; `authHeaderName` picks the header the credential travels in.

## 3. Dispatch a session

Dispatch one push session per call to `POST /api/v1/ussd/push`:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/ussd/push \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+234801234567",
    "message": "Your balance is 1,250 NGN. Reply 1 to top up.",
    "action": "CON",
    "clientRequestId": "bal-20260910-001"
  }'
```

`phoneNumber` accepts E.164 with or without the leading `+` (normalized on the way in). `message` is capped at the 182-character USSD payload ceiling. `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.

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

## 5. Sequence with the USSD menu tree

Push and dial-in share the same menu tree — a push session whose `action` is `CON` opens on the tree's root, and the subscriber's keypad answer routes exactly as it would for a dial-in session. That is how "a menu answer feeds back into a push-driven callback": the push originated the session; the tree resolves every step after.

So a balance prompt that expects a numeric reply goes: `POST /ussd/push` with `action: "CON"` opens the root; the subscriber taps `1` for top-up; the aggregator hits your callback (`POST /api/v1/ussd/callback/<tenantId>` — the same one the [USSD flow-builder guide](/guides/ussd-flow-builder) wires for dial-in) with the accumulated input, and your handler confirms the top-up. The push and the menu are two ends of one session — the push begins it, the menu carries it.

This sequencing only matters when `action` is `CON`. For an `END` one-shot notice there is no tree step after — no keypad answer exists, and your session side-effects belong on the dispatch call itself.

## 6. Troubleshooting

| Symptom                                                                                  | Status         | Cause                                                                                                         | Fix                                                                                                                                                                                                            |
| ---------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dispatch returns `CHANNEL_NOT_CONFIGURED`                                                | 409            | No push provider is set for the tenant.                                                                       | Run `PUT /api/v1/ussd/push/config` first (section 2), then retry.                                                                                                                                              |
| Dispatch returns a body or URL-safety reject                                             | 422            | The request body failed validation, or `pushUrl` was rejected by URL safety validation.                       | For the body: check `phoneNumber` is E.164 and `message` is 1–182 chars. For the URL: it must be `https://` and must not resolve into internal address space — write time and dispatch time both enforce this. |
| Dispatch returns `MESSAGE_SEND_FAILED`                                                   | 502            | The aggregator endpoint was unreachable, timed out, or returned non-2xx.                                      | Retry the dispatch, then check the provider's own console; a rejected signature/cert or a provider outage shows there first.                                                                                   |
| Aggregator answers the push callback but the dial menu shows "Service is not available." | terminal `END` | The menu tree isn't saved for the tenant (a push session still needs the menu for any step after the prompt). | Save the menu tree (`PUT /api/v1/ussd/menu`) — the flow-builder guide covers the shape; a saved menu is what a push session's `CON` steps resolve against.                                                     |

Two more shapes worth knowing:

**A retry that returns a different error class.** The call layer orders checks config → body → dispatch, so a second retry may legitimately move from `409` (config once missing, now saved) to `422` (body or URL) or `502` (dispatch) as each layer passes. Triage them in that order, not by which one you saw first.

**The dashboard push panel as the first check.** Before constructing curl retries, open **Numbers → USSD** (`/numbers/ussd`) and look at the push card. If it shows no saved endpoint, you have a 409 to fix; if it shows the endpoint, the next failure is almost never the config — take the `422` / `502` split instead.

## See Also

* [USSD flow builder](/guides/ussd-flow-builder) — the menu tree dispatch sessions resume against, and the callback the menu answers feed.
* [USSD channel](/channels/ussd) — the session protocol (`CON`/`END` first token, accumulated input) and the full endpoint map.
* [USSD API reference](/api-reference/ussd) — every field on every endpoint used here.
