# Ontech USSD Gateway — Integration Contract Source of truth: https://ussd.ontech.co.zm/integration-guide Audience: developers and AI coding assistants writing a USSD callback endpoint. ## The whole contract in one line The gateway POSTs JSON to your HTTPS endpoint on every keypress; you reply HTTP 200 with JSON. Nothing else is accepted. ## Request — gateway to you ```http POST https://your-domain.com/api/ussd/callback Content-Type: application/json { "session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "388*101", "is_new_request": true, "mno": "AIRTEL", "shortcode": "388*101", "request_id": "4540c4ce", "session_data": {} } ``` | Field | Type | Meaning | |---|---|---| | `session_id` | string | Unique per conversation. Correlate every keypress by this. | | `msisdn` | string | Subscriber number, e.g. `260971234567`. | | `user_input` | string | First request: the dialled code (`388*101`). Afterwards: only what the user typed (`1`). | | `is_new_request` | boolean | `true` = just dialled, `false` = replying to a menu. **A real JSON boolean.** | | `mno` | string | `AIRTEL`, `MTN` or `ZAMTEL` — uppercase. | | `shortcode` | string | The code that was dialled, e.g. `388*101`. | | `request_id` | string | Per-request id; log it, it is what support traces by. | | `session_data` | object | Gateway-side session scratch. Informational — keep your own state. | ⚠️ **Field names are `session_id` / `user_input` / `is_new_request`.** Operators use different names on their own leg into the gateway (MTN really does send `sessionID`/`input`/`isnewrequest`), but that is the operator→gateway hop and you never see it. Reading `input` gets you an empty string and a missing `isnewrequest` looks like "new session", so the menu renders correctly on the first screen and then never advances — the failure looks like a state bug and is not one. ## Response — you to gateway ```json { "response_string": "Welcome to My Service\n1. Check Balance\n2. Make Payment\n3. Exit", "continue_session": true } ``` | Field | Type | Required | Meaning | |---|---|---|---| | `response_string` | string | yes | Text shown on the handset. `\n` for line breaks. | | `continue_session` | boolean | yes | `true` = keep the session open and wait for input. `false` = show this text and end. | ## Rules that break integrations most often 1. **Do not reply with `CON ` / `END ` plain text.** That is the Africa's Talking convention. This gateway expects JSON and will render "Service error" to the subscriber. 2. **Always return HTTP 200**, even for application errors — put the error in `response_string` and set `continue_session` to `false`. 3. `is_new_request` is a JSON boolean. Do not compare it to `"1"`. 4. **Respond within 5 seconds.** The gateway times out at 10. 5. **Keep each screen under ~160 characters.** USSD screens are small. 6. **Store session state externally** (Redis/DB), keyed by `session_id` — an in-memory dict does not survive a restart or a second worker. 7. Set `continue_session: false` on the final screen, or the session hangs. ## Worked example ``` subscriber dials *388*10# -> {"session_id":"175...","msisdn":"2609...","user_input":"388*10","is_new_request":true} <- {"response_string":"Welcome\n1. Balance\n2. Exit","continue_session":true} subscriber presses 1 -> {"session_id":"175...","msisdn":"2609...","user_input":"1","is_new_request":false} <- {"response_string":"Your balance is K250.00","continue_session":false} ``` ## Python (Flask) ```python from flask import Flask, request, jsonify app = Flask(__name__) sessions = {} # use Redis in production — keyed by session_id @app.post("/api/ussd/callback") def ussd(): data = request.get_json(force=True) session_id = data["session_id"] user_input = data.get("user_input", "") is_new = bool(data.get("is_new_request")) # a real JSON boolean if is_new: sessions[session_id] = {"step": "menu"} return jsonify(response_string="Welcome\n1. Balance\n2. Exit", continue_session=True) if user_input == "1": return jsonify(response_string="Your balance is K250.00", continue_session=False) return jsonify(response_string="Invalid option.", continue_session=False) ``` ## Node.js (Express) ```javascript app.post('/api/ussd/callback', (req, res) => { const { session_id, user_input, is_new_request } = req.body; const isNew = is_new_request === true; // a real JSON boolean if (isNew) { return res.json({ response_string: 'Welcome\n1. Balance\n2. Exit', continue_session: true }); } if (input === '1') { return res.json({ response_string: 'Your balance is K250.00', continue_session: false }); } res.json({ response_string: 'Invalid option.', continue_session: false }); }); ``` ## Getting a shortcode Register at https://ussd.ontech.co.zm/dev/register and verify your email — a live `*388*N#` shortcode is issued automatically, free, with a 5-day proof-of-concept and 120 sessions included. No payment and no approval queue to start. After the POC, extend by mobile money. Weekly: K350 for up to 1,000 sessions on a 3-digit code, K500 for up to 1,700 on a 2-digit. Monthly: K1,200 or K1,800 for up to 10,000 sessions. Prices exclude VAT (16% added at payment); sessions do not roll over — every renewal zeroes the balance and grants the plan afresh. Set your callback URL on the Shortcodes page in the developer portal; the code goes live within about five minutes. ## Testing without a handset The portal Sandbox simulates a full session in the browser, in two modes: straight to your callback URL, or through the live gateway (which also proves your route and the operator-specific encoding). Simulator traffic is tagged as synthetic and never counts against your session balance. ## The one mapping to keep straight `continue_session` is **not** a CON/END flag with a different name. It is a JSON boolean. There is no prefix, ever, on any leg of the conversation — including the middle of a deep menu tree. | Africa's Talking (do NOT emit) | Ontech (correct) | |---|---| | `CON Welcome\n1. Balance` | `{"response_string": "Welcome\n1. Balance", "continue_session": true}` | | `END Goodbye` | `{"response_string": "Goodbye", "continue_session": false}` | If any reply you generate starts with the characters `CON ` or `END `, it is wrong — regardless of how many turns into the flow you are. ## Full worked flow — six legs, nested menu, invalid input, back-navigation Every leg below is a complete HTTP exchange. Note that `continue_session` stays `true` through submenus, re-prompts and back-navigation, and only becomes `false` on the very last screen. ### Leg 1 — subscriber dials `*388*10#` ```json {"session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "388*10", "is_new_request": true} ``` ```json {"response_string": "MyBank\n1. Balance\n2. Transfer\n3. Exit", "continue_session": true} ``` ### Leg 2 — presses `1` (into the Balance submenu) ```json {"session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "1", "is_new_request": false} ``` ```json {"response_string": "Balance\n1. Current\n2. Savings\n9. Back", "continue_session": true} ``` ### Leg 3 — presses `7` (invalid at this level — re-prompt, do NOT end) ```json {"session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "7", "is_new_request": false} ``` ```json {"response_string": "Invalid option.\n1. Current\n2. Savings\n9. Back", "continue_session": true} ``` ### Leg 4 — presses `9` (back to the main menu — still open) ```json {"session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "9", "is_new_request": false} ``` ```json {"response_string": "MyBank\n1. Balance\n2. Transfer\n3. Exit", "continue_session": true} ``` ### Leg 5 — presses `2` (Transfer — asks for input, still open) ```json {"session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "2", "is_new_request": false} ``` ```json {"response_string": "Enter amount in Kwacha:", "continue_session": true} ``` ### Leg 6 — types `250` (final screen — session ends here, and only here) ```json {"session_id": "17739829840001234", "msisdn": "260971234567", "user_input": "250", "is_new_request": false} ``` ```json {"response_string": "K250.00 sent. Ref TX8842.", "continue_session": false} ``` Observations an implementation must reproduce: - `is_new_request` is `true` **only on leg 1**; every later leg is `false`. - `session_id` is identical across all six legs — it is your state key. - `input` on leg 1 is the dialled code; afterwards it is only the keypress. - Five of six replies carry `continue_session: true`. Ending early on an invalid option is the most common bug after CON/END drift. - No reply anywhere in this flow begins with `CON ` or `END `.