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.
| Detail | Value |
|---|---|
| Supported MNOs | Airtel Zambia, MTN Zambia, Zamtel |
| Shortcode Range | 388*XX (sub-codes under *388#) |
| Protocol | HTTPS POST with JSON body |
| Response Timeout | 10 seconds |
| Max Response Length | 160 characters recommended |
Quick Start
- Build a callback endpoint — an HTTP endpoint that accepts POST with JSON
- Request a shortcode — contact Ontech for a
388*XXallocation - We configure the route — your URL gets registered in the gateway
- Test it — use the USSD Simulator or dial from a real handset
- 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,
"mno": "AIRTEL",
"shortcode": "388*10",
"request_id": "a2813ed9",
"session_data": {}
}
| Field | Type | Description |
|---|---|---|
session_id | string | Unique session identifier from the MNO. Use this to track the conversation. |
msisdn | string | Subscriber's phone number in international format (e.g. 260971234567) |
user_input | string | First request: the dialed shortcode (e.g. 388*10). Follow-up: the user's menu selection (e.g. 1, 2) |
is_new_request | boolean | true = subscriber just dialed (new session). false = subscriber is responding to a menu |
mno | string | Network operator: AIRTEL, MTN, or ZAMTEL |
shortcode | string | The matched shortcode route |
request_id | string | Unique request tracking ID (log this for debugging) |
session_data | object | Session context from previous interactions (empty on first request) |
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
}
| Field | Type | Required | Description |
|---|---|---|---|
response_string | string | Yes | The USSD text to display. Use \n for line breaks. |
continue_session | boolean | Yes | true = show menu, wait for input. false = show message, end session. |
HTTP Headers
The gateway includes these headers with every request:
| Header | Description |
|---|---|
Content-Type | Always application/json |
X-Request-ID | Unique request identifier for tracing |
X-MNO | Mobile network operator name |
X-MSISDN | Subscriber phone number |
Conversation Flow Example
Step 1 — User dials *388*10#
// Gateway sends:
{"session_id": "sess_001", "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", "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", "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['user_input']
if data['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) {
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->input('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
]);
});
Error Handling
| Subscriber Sees | Cause | Fix |
|---|---|---|
| "Service error. Please try again." | Your endpoint returned non-JSON or crashed | Always return HTTP 200 with valid JSON |
| "Service timeout." | Response took longer than 10 seconds | Optimize to respond within 5 seconds |
| "Service temporarily unavailable" | HTTP status was not 200 | Always return 200, even for app-level errors |
| No response / blank screen | Your server is unreachable | Check server is publicly accessible, URL is correct |
Best Practices
- Always return valid JSON — even when your app encounters an error internally
- Respond within 5 seconds — the gateway has a 10-second hard timeout
- Keep text under 160 characters — USSD screens are small
- Use Redis or a database for session state — not in-memory (won't survive restarts)
- Log the
request_id— we can trace issues using this - Test with all 3 MNOs — Airtel, MTN, Zamtel may behave slightly differently
- Use
\nfor line breaks — notor other HTML - Always set
continue_session: falseon the final step to properly end sessions
Testing
Test your endpoint before going live:
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,
"mno": "AIRTEL",
"shortcode": "388*10",
"request_id": "test_req_001",
"session_data": {}
}'
# 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,
"mno": "AIRTEL",
"shortcode": "388*10",
"request_id": "test_req_002",
"session_data": {}
}'
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.