ONTECH
USSD Gateway Docs
Sign In

USSD Gateway Integration Guide

How to build applications that work with the Ontech *388# USSD Gateway

Overview

The Ontech USSD Gateway routes USSD requests from Zambian mobile network operators to your application via HTTP. When a subscriber dials *388*XX#, the gateway forwards the request to your callback URL and returns your response to the subscriber's phone.

DetailValue
Supported MNOsAirtel Zambia, MTN Zambia, Zamtel
Shortcode Range388*XX (sub-codes under *388#)
ProtocolHTTPS POST with JSON body
Response Timeout10 seconds
Max Response Length160 characters recommended

Quick Start

  1. Build a callback endpoint — an HTTP endpoint that accepts POST with JSON
  2. Request a shortcode — contact Ontech for a 388*XX allocation
  3. We configure the route — your URL gets registered in the gateway
  4. Test it — use the USSD Simulator or dial from a real handset
  5. Go live — subscribers dial *388*XX# and reach your app

Request Format

The gateway sends a POST request with a JSON body to your endpoint:

POST https://your-domain.com/api/ussd/callback
Content-Type: application/json

{
    "session_id": "17739829840001234",
    "msisdn": "260971234567",
    "user_input": "388*10",
    "is_new_request": true
}
FieldTypeDescription
session_idstringUnique session identifier from the MNO. Correlate every keypress of a conversation by this value.
msisdnstringSubscriber's phone number (e.g. 260971234567)
user_inputstringFirst request: the dialed code (e.g. 388*101). Follow-up: the user's menu selection (e.g. 1, 2)
is_new_requestbooleantrue = subscriber just dialed (new session). false = subscriber is responding to a menu. A real JSON boolean, not a string.
mnostringAIRTEL, MTN or ZAMTEL — uppercase
shortcodestringThe code that was dialed (e.g. 388*101)
request_idstringPer-request identifier. Log it — it is what support traces by.
session_dataobjectGateway-side session 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 a new session, so your first screen renders correctly and then never advances. The failure looks like a state bug and is not one.

Response Format

Your endpoint must return a JSON response with exactly two fields:

{
    "response_string": "Welcome to My Service\n1. Check Balance\n2. Make Payment\n3. Exit",
    "continue_session": true
}
FieldTypeRequiredDescription
response_stringstringYesThe USSD text to display. Use \n for line breaks.
continue_sessionbooleanYestrue = show menu, wait for input. false = show message, end session.
Always return HTTP 200 with valid JSON containing both fields. Any other response will show "Service error" to the subscriber.

HTTP Headers

The gateway includes these headers with every request:

HeaderDescription
Content-TypeAlways application/json
X-Request-IDUnique request identifier for tracing
X-MNOMobile network operator name
X-MSISDNSubscriber phone number

Conversation Flow Example

Step 1 — User dials *388*10#

// Gateway sends:
{"session_id": "sess_001", "msisdn": "260971234567", "user_input": "388*10", "is_new_request": true}

// Your response:
{"response_string": "Welcome\n1. Check Balance\n2. Pay\n3. Statement", "continue_session": true}

Subscriber sees the menu on their phone and enters 1.

Step 2 — User selects option 1

// Gateway sends:
{"session_id": "sess_001", "msisdn": "260971234567", "user_input": "1", "is_new_request": false}

// Your response:
{"response_string": "Enter your account number:", "continue_session": true}

Step 3 — User enters 12345

// Gateway sends:
{"session_id": "sess_001", "msisdn": "260971234567", "user_input": "12345", "is_new_request": false}

// Your response (final):
{"response_string": "Your balance is ZMW 5,230.00\nThank you!", "continue_session": false}

Session ends because continue_session is false.

Python (Flask)

from flask import Flask, request, jsonify

app = Flask(__name__)
sessions = {}

@app.route('/api/ussd/callback', methods=['POST'])
def ussd_callback():
    data = request.get_json()
    session_id = data['session_id']
    user_input = data.get('user_input', '')

    if data.get('is_new_request'):
        sessions[session_id] = {'step': 'menu'}
        return jsonify({
            'response_string': 'Welcome\n1. Balance\n2. Pay\n3. Exit',
            'continue_session': True
        })

    step = sessions.get(session_id, {}).get('step')

    if step == 'menu' and user_input == '1':
        return jsonify({
            'response_string': 'Your balance is ZMW 1,500.00',
            'continue_session': False
        })

    return jsonify({
        'response_string': 'Thank you for using our service.',
        'continue_session': False
    })

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

Node.js (Express)

const express = require('express');
const app = express();
app.use(express.json());

const sessions = {};

app.post('/api/ussd/callback', (req, res) => {
    const { session_id, user_input, is_new_request } = req.body;

    if (is_new_request === true) {
        sessions[session_id] = { step: 'menu' };
        return res.json({
            response_string: 'Welcome\n1. Balance\n2. Pay\n3. Exit',
            continue_session: true
        });
    }

    if (sessions[session_id]?.step === 'menu' && user_input === '1') {
        return res.json({
            response_string: 'Your balance is ZMW 1,500.00',
            continue_session: false
        });
    }

    res.json({ response_string: 'Thank you.', continue_session: false });
});

app.listen(8080);

PHP (Laravel)

Route::post('/api/ussd/callback', function (Request $request) {
    $sessionId = $request->input('session_id');
    $userInput = $request->input('user_input');

    if ($request->boolean('is_new_request')) {
        session([$sessionId => ['step' => 'menu']]);
        return response()->json([
            'response_string' => "Welcome\n1. Balance\n2. Pay",
            'continue_session' => true
        ]);
    }

    if (session("$sessionId.step") === 'menu' && $userInput === '1') {
        return response()->json([
            'response_string' => 'Balance: ZMW 1,500.00',
            'continue_session' => false
        ]);
    }

    return response()->json([
        'response_string' => 'Thank you.',
        'continue_session' => false
    ]);
});
Do not reply with CON / END plain text. That is the Africa's Talking convention, and it is the single most common mistake when porting a service or generating code with an AI assistant. This gateway expects JSON: continue_session: true replaces CON, and continue_session: false replaces END. A CON /END prefix shows "Service error" to the subscriber — on any leg of the conversation, however deep into a menu tree you are.

Error Handling

Subscriber SeesCauseFix
"Service error. Please try again."Your endpoint returned non-JSON or crashed — most often a CON/END plain-text replyAlways return HTTP 200 with valid JSON containing response_string and continue_session
"Service timeout."Response took longer than 10 secondsOptimize to respond within 5 seconds
"Service temporarily unavailable"HTTP status was not 200Always return 200, even for app-level errors
No response / blank screenYour server is unreachableCheck server is publicly accessible, URL is correct

Best Practices

Webhooks

Webhooks push events to your server after the fact — they are separate from the live USSD callback. Register a URL under Developer Portal → Webhooks, optionally with a secret.

Events

EventFired when
session.completedA USSD session finished with no errors
session.errorA session recorded an error (timeout, bad response, unreachable endpoint)
trial.startedYour POC was issued (shortcode reserved, sessions granted)
trial.low_sessionsSession balance fell to the warning threshold — at most once every 24h
trial.expiringService expires within 24 hours — at most once every 24h
trial.suspendedTime or sessions ran out; your shortcode is now offline
trial.extendedAn extension applied — sessions reset, shortcode back online
payment.receivedA weekly extension payment settled

trial.suspended and trial.extended are the two worth acting on: they tell your system when your service went offline and came back. Every trial event carries extension_price, extension_sessions and extension_days so you can surface the cost of restoring service.

Session events are emitted once the session has been quiet for about 90 seconds, so they arrive up to ~2 minutes after the subscriber hangs up. They are a record of what happened — never reply to a subscriber from a webhook.

Payload

POST https://your-server/webhook
X-Ontech-Event: session.completed
X-Ontech-Delivery: 4711
X-Ontech-Signature: <hex hmac-sha256 of the raw body, keyed with your secret>

{
  "event": "session.completed",
  "session_id": "1760012345678",
  "shortcode": "388*101",
  "msisdn": "260979669350",
  "mno": "AIRTEL",
  "requests": 3,
  "started_at": "2026-07-25T08:03:46+00:00",
  "ended_at": "2026-07-25T08:04:16+00:00",
  "duration_ms": 30000,
  "avg_response_ms": 99,
  "error": null
}

Verifying the signature

import hmac, hashlib

expected = hmac.new(SECRET.encode(), raw_body_bytes, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Ontech-Signature"]):
    abort(401)

Delivery & retries

Testing

Test your endpoint before going live — no handset and no SIM required.

The Sandbox (recommended)

Developer Portal → Sandbox gives you a phone emulator in the browser with two modes. Neither costs you trial sessions: simulator traffic is tagged as synthetic and excluded from billing, your session balance and your SLA figures.

ModeWhat it doesUse it when
Direct to my callback POSTs the gateway's exact JSON payload straight at your callback URL, bypassing the gateway. Building and iterating on your menus — it works even before your shortcode is routed.
Through live gateway Dials the real gateway exactly as Airtel, MTN or Zamtel do, then shows you the raw request and reply. Before you go live — this is the only way to prove your route, the operator-specific encoding and your continue_session flags actually work end to end.

If gateway mode answers with the OnTech Services demo menu instead of your service, your shortcode has no live route yet — set your callback URL on the Shortcodes page. The simulator tells you when this happens.

Using cURL

# New session
curl -X POST https://your-domain.com/api/ussd/callback \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "test_001",
    "msisdn": "260971234567",
    "user_input": "388*10",
    "is_new_request": true
  }'

# Follow-up (user selects 1)
curl -X POST https://your-domain.com/api/ussd/callback \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "test_001",
    "msisdn": "260971234567",
    "user_input": "1",
    "is_new_request": false
  }'

Using the USSD Simulator

If you have access to the management portal, use the built-in Test USSD page to simulate real USSD interactions with your endpoint.