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
}
| Field | Type | Description |
|---|---|---|
session_id | string | Unique session identifier from the MNO. Correlate every keypress of a conversation by this value. |
msisdn | string | Subscriber's phone number (e.g. 260971234567) |
user_input | string | First request: the dialed code (e.g. 388*101). 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. A real JSON boolean, not a string. |
mno | string | AIRTEL, MTN or ZAMTEL — uppercase |
shortcode | string | The code that was dialed (e.g. 388*101) |
request_id | string | Per-request identifier. Log it — it is what support traces by. |
session_data | object | Gateway-side session 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 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
}
| 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", "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
]);
});
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 Sees | Cause | Fix |
|---|---|---|
| "Service error. Please try again." | Your endpoint returned non-JSON or crashed — most often a CON/END plain-text reply | Always return HTTP 200 with valid JSON containing response_string and continue_session |
| "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
session_id— correlate the steps of a conversation and trace issues - 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
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
| Event | Fired when |
|---|---|
session.completed | A USSD session finished with no errors |
session.error | A session recorded an error (timeout, bad response, unreachable endpoint) |
trial.started | Your POC was issued (shortcode reserved, sessions granted) |
trial.low_sessions | Session balance fell to the warning threshold — at most once every 24h |
trial.expiring | Service expires within 24 hours — at most once every 24h |
trial.suspended | Time or sessions ran out; your shortcode is now offline |
trial.extended | An extension applied — sessions reset, shortcode back online |
payment.received | A 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
- Return any 2xx to acknowledge. Anything else is a failure.
- Failures retry 5 times with backoff: 1 min, 5 min, 15 min, 1 hr, 6 hr.
- Deliveries and their responses are visible under Webhooks → Recent Deliveries.
- A webhook bound to a specific shortcode only receives that code's events; leave it unbound to receive events for all your codes.
- Deduplicate on
X-Ontech-Delivery— a retry after your server accepted but failed to reply would arrive twice.
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.
| Mode | What it does | Use 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.