Ontech USSD Gateway · Zambia

Integration contract for *388*N# services

The gateway POSTs JSON to your HTTPS endpoint on every keypress, and you reply with JSON. That is the entire contract. This page is the reference for developers — and for the AI assistants they write code alongside.

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": {}
}
FieldTypeMeaning
session_idstringUnique per conversation. This is your state key — correlate every keypress by it.
msisdnstringSubscriber number, e.g. 260971234567.
user_inputstringFirst request: the dialled code (388*101). Afterwards: only the keypress (1).
is_new_requestbooleantrue = just dialled, false = replying to a menu. A real JSON boolean.
mnostringAIRTEL, MTN or ZAMTEL — uppercase.
shortcodestringThe code that was dialled, e.g. 388*101.
request_idstringPer-request id. Log it — it is what support traces by.
session_dataobjectGateway-side scratch. Informational; keep your own state.
The 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 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
}
FieldTypeMeaning
response_stringstringText shown on the handset. Use \n for line breaks; keep each screen under ~160 characters.
continue_sessionbooleantrue 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.

Leg 1Subscriber dials *388*10#session open

Gateway →

{"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}
Leg 2Presses 1 — into a submenusession open

Gateway →

{"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}
Leg 3Presses 7 — invalid, re-promptsession open

Gateway →

{"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}
Leg 4Presses 9 — back to mainsession open

Gateway →

{"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}
Leg 5Presses 2 — prompt for inputsession open

Gateway →

{"session_id": "1773982984", "msisdn": "260971234567", "user_input": "2", "is_new_request": false}

← You

{"response_string": "Enter amount in Kwacha:", "continue_session": true}
Leg 6Types 250 — final screensession ends

Gateway →

{"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_request is "1" only on leg 1; every later leg is "0".
  • session_id is identical across all six legs.
  • 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 .

Rules that break integrations most often

  1. Never reply with CON/END plain text. The gateway expects JSON and will show “Service error” to the subscriber.
  2. Always return HTTP 200, even for application errors — put the message in response_string and set continue_session to false.
  3. is_new_request is a JSON boolean. Comparing it to "1" silently fails.
  4. Respond within 5 seconds. The gateway times out at 10.
  5. Keep each screen under ~160 characters.
  6. Store session state externally (Redis or a database), keyed by session_id — an in-memory dict survives neither a restart nor a second worker.
  7. Set continue_session: false on 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.