Request — gateway to you
One POST per keypress. The first carries the dialled code; every one after carries only what the subscriber typed.
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. This is your state key — correlate every keypress by it. |
| msisdn | string | Subscriber number, e.g. 260971234567. |
| user_input | string | First request: the dialled code (388*101). Afterwards: only the keypress (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 scratch. Informational; keep your own state. |
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 your endpoint never sees it. Reading input yields an
empty string, and a missing isnewrequest looks like “new session”, so
the first screen renders correctly and then never advances. The failure looks like a
state bug and is not one.
Response — you to gateway
Always HTTP 200, always both fields.
{
"response_string": "Welcome to My Service\n1. Check Balance\n2. Make Payment\n3. Exit",
"continue_session": true
}
| Field | Type | Meaning |
|---|---|---|
| response_string | string | Text shown on the handset. Use \n for line breaks; keep each screen under ~160 characters. |
| continue_session | boolean | true keeps the session open and waits for input. false shows this text and ends. |
The one mapping to keep straight
continue_session is not a CON/END flag under a different name. It is a JSON boolean. No reply carries a text prefix — on any leg, at any depth of the menu tree.
Do not emit — Africa's Talking style
CON Welcome 1. Balance END Goodbye
Correct — Ontech
{"response_string": "Welcome\n1. Balance",
"continue_session": true}
{"response_string": "Goodbye",
"continue_session": false}
If a reply you generate begins with the characters CON or END , it is wrong — regardless of how many turns into the conversation you are.
Worked flow — six legs
A nested menu with an invalid option and back-navigation. The session stays open through all of it and closes only on the last screen.
*388*10#session openGateway →
{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "388*10", "is_new_request": true}← You
{"response_string": "MyBank\n1. Balance\n2. Transfer\n3. Exit", "continue_session": true}1 — into a submenusession openGateway →
{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "1", "is_new_request": false}← You
{"response_string": "Balance\n1. Current\n2. Savings\n9. Back", "continue_session": true}7 — invalid, re-promptsession openGateway →
{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "7", "is_new_request": false}← You
{"response_string": "Invalid option.\n1. Current\n2. Savings\n9. Back", "continue_session": true}9 — back to mainsession openGateway →
{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "9", "is_new_request": false}← You
{"response_string": "MyBank\n1. Balance\n2. Transfer\n3. Exit", "continue_session": true}2 — prompt for inputsession openGateway →
{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "2", "is_new_request": false}← You
{"response_string": "Enter amount in Kwacha:", "continue_session": true}250 — final screensession endsGateway →
{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "250", "is_new_request": false}← You
{"response_string": "K250.00 sent. Ref TX8842.", "continue_session": false}What an implementation must reproduce
is_new_requestis"1"only on leg 1; every later leg is"0".session_idis identical across all six legs.inputon 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
CONorEND.
Rules that break integrations most often
- Never reply with
CON/ENDplain text. The gateway expects JSON and will show “Service error” to the subscriber. - Always return HTTP 200, even for application errors — put the message in
response_stringand setcontinue_sessiontofalse. is_new_requestis a JSON boolean. Comparing it to"1"silently fails.- Respond within 5 seconds. The gateway times out at 10.
- Keep each screen under ~160 characters.
- Store session state externally (Redis or a database), keyed by
session_id— an in-memory dict survives neither a restart nor a second worker. - Set
continue_session: falseon the final screen, or the session hangs.
Reference code
Python — Flask
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
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 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, choose a plan: weekly K350 (up to 1,000 sessions) or K500 (up to 1,700) by code tier, or monthly K1,200 or K1,800 for up to 10,000 sessions. All prices exclude VAT; sessions do not roll over between cycles.
Set your callback URL on the Shortcodes page in the developer portal and the code goes live within about five minutes. The portal Sandbox then simulates a full session in the browser — no handset, no SIM — either straight to your callback or through the live gateway. Simulator traffic is tagged as synthetic and never counts against your session balance.