USSD Gateway Guide
This guide covers the OmniSS7 USSD Gateway, which bridges SS7/MAP USSD dialogues to HTTP/JSON callbacks, enabling 3rd-party developers to build USSD applications with a simple HTTP endpoint.
Table of Contents
- Overview
- Architecture
- Enabling the USSD Gateway
- Configuration
- HTTP Callback Protocol
- Network-Originated USSD (Push API)
- Session Lifecycle
- Error Handling
- Metrics and Monitoring
- Example Callback Server
- Troubleshooting
Overview
The USSD Gateway handles two directions of USSD traffic:
- Mobile-Originated (Inbound) — A subscriber dials a short code (e.g.
*100#). The gateway receives the MAPprocessUnstructuredSS-Request(opcode 59), forwards it to your HTTP callback, and relays your response back over SS7. - Network-Originated (Outbound) — Your application pushes a USSD message to a subscriber via the REST API. The gateway sends a MAP
unstructuredSS-Request(opcode 60) over SS7 and routes the subscriber's reply to your callback.
Both directions support multi-turn dialogues — interactive menus where the subscriber replies and receives follow-up prompts.
Key Characteristics
| Property | Value |
|---|---|
| Transport | Synchronous HTTP POST per turn |
| Encoding | GSM 7-bit default alphabet (DCS 0x0F) per 3GPP TS 23.038 |
| Max text length | 182 characters (configurable) |
| Session tracking | Gateway-generated UUID per dialogue |
| Authentication | None (trusts the SS7 network) |
| Routing | Short code prefix matching to callback URLs |
3GPP References
| Specification | Relevance |
|---|---|
| 3GPP TS 23.090 | USSD Stage 2 — architecture and procedures |
| 3GPP TS 24.090 | USSD Stage 3 — protocol details |
| 3GPP TS 29.002 | MAP protocol — USSD-Arg, USSD-Res, opcodes 59/60/61 |
| 3GPP TS 23.038 | GSM 7-bit default alphabet and data coding scheme |
Architecture
Mobile-Originated Flow (Inbound)
Network-Originated Flow (Outbound Push)
Component Overview
Enabling the USSD Gateway
The USSD Gateway requires MAP Client mode to be enabled, plus its own feature flag.
config :omniss7,
map_client_enabled: true,
ussd_gateway_enabled: true
The gateway also requires a working M3UA connection (see MAP Client Guide for M3UA setup).
Configuration
USSD Gateway Parameters
config :omniss7,
ussd_gateway_enabled: true,
ussd_gateway: %{
# Short code routing — longest prefix match
routes: [
%{pattern: "*100", url: "http://balance-app:9000/ussd"},
%{pattern: "*200", url: "http://topup-app:9000/ussd"},
%{pattern: "*", url: "http://default-app:9000/ussd"}
],
# Session timeouts
session_timeout_ms: 180_000, # Total session lifetime (3 minutes)
turn_timeout_ms: 30_000, # Max wait for subscriber reply per turn (30 seconds)
# HTTP callback settings
http_timeout_ms: 5_000, # Timeout for HTTP POST to your app (5 seconds)
# Text limits
max_text_length: 182 # GSM 7-bit max (truncates with warning if exceeded)
}
Parameter Reference
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
ussd_gateway_enabled | Boolean | Yes | false | Master switch for the USSD Gateway feature |
ussd_gateway.routes | List of Maps | Yes | [] | Short code prefix routing rules. Each entry has pattern (prefix string) and url (callback URL). Longest prefix match wins. |
ussd_gateway.session_timeout_ms | Integer | No | 180_000 | Maximum total session duration in milliseconds. Session is terminated with an error if exceeded. |
ussd_gateway.turn_timeout_ms | Integer | No | 30_000 | Maximum time to wait for the subscriber's reply in a multi-turn dialogue, in milliseconds. |
ussd_gateway.http_timeout_ms | Integer | No | 5_000 | HTTP request timeout for callbacks to your application, in milliseconds. Covers both connect and response time. |
ussd_gateway.max_text_length | Integer | No | 182 | Maximum characters in a USSD text string. Texts exceeding this are truncated and a warning is logged. |
Route Parameters
Each entry in the routes list is a map:
| Parameter | Type | Required | Description |
|---|---|---|---|
pattern | String | Yes | Short code prefix to match. Use "*" as a catch-all fallback. Longer prefixes take priority. |
url | String | Yes | HTTP endpoint URL to receive callback POSTs for matching short codes. |
Route Matching
Routes are matched by longest prefix first. For the dial string *100#:
"*100"matches (length 4) — selected"*10"matches (length 3) — skipped, shorter"*"matches (length 1) — fallback
If no route matches, the gateway returns a MAP error to the mobile and logs a warning.
HTTP Callback Protocol
Your application receives HTTP POST requests from the gateway and responds with JSON instructions.
Request from Gateway to Your Application
Content-Type: application/json
First turn (session initiation):
{
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"msisdn": "+254712345678",
"type": "initiation",
"text": "*100#",
"turn": 1
}
Subsequent turns (subscriber replied):
{
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"msisdn": "+254712345678",
"type": "response",
"text": "1",
"turn": 2
}
Request Fields
| Field | Type | Description |
|---|---|---|
session_id | String | Gateway-generated UUID. Unique per USSD dialogue. Use this to correlate turns. |
msisdn | String | Subscriber's MSISDN (if available from the MAP message). May be empty for some networks. |
type | String | "initiation" for the first turn, "response" for subsequent subscriber replies. |
text | String | The dial string (e.g. *100#) on initiation, or the subscriber's input (e.g. 1) on response. |
turn | Integer | Turn counter starting at 1. Increments with each subscriber interaction. |
Response from Your Application
Your application must respond with JSON containing an action and text:
Continue (show menu, wait for subscriber input):
{
"action": "continue",
"text": "1. Balance\n2. Top up\n3. Transfer"
}
End (show final message, close session):
{
"action": "end",
"text": "Your balance is $5.00"
}
Response Fields
| Field | Type | Required | Description |
|---|---|---|---|
action | String | Yes | "continue" to keep the session open and wait for subscriber input, or "end" to display a final message and close the session. |
text | String | Yes | Text to display on the subscriber's handset. Maximum length governed by max_text_length (default 182). Use \n for line breaks. |
Network-Originated USSD (Push API)
Push a USSD message to a subscriber from your application.
Endpoint
POST /api/ussd/send
Request
{
"msisdn": "+254712345678",
"text": "You have a pending bill. Reply 1 to pay.",
"callback_url": "http://billing-app:9000/ussd"
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
msisdn | String | Yes | Destination subscriber MSISDN in international format. |
text | String | Yes | Initial USSD text to display. Encoded as GSM 7-bit. |
callback_url | String | Yes | URL to receive the subscriber's reply via the standard callback protocol. |
Response
Success (200 OK):
{
"session_id": "xyz-789-abc-123",
"status": "sent"
}
Error responses:
| HTTP Status | Body | Cause |
|---|---|---|
| 400 | {"error": "invalid request", "required": ["msisdn", "text", "callback_url"]} | Missing one or more required fields, or the body is not valid JSON |
| 500 | {"error": "{:gsm7_encode_failed, ...}"} | Text contains characters not in the GSM 7-bit alphabet. The encode failure is surfaced through the generic error branch, so the status is 500, not 400. |
| 500 | {"error": "..."} | Any other send failure (e.g. M3UA connectivity). The error field carries the inspected internal reason. |
| 503 | {"error": "USSD gateway not enabled"} | ussd_gateway_enabled is false |
Note on validation vs. send errors: Field-validation problems (missing keys, malformed JSON) return 400
invalid request. Once validation passes, any failure from the send path — including GSM 7-bit encoding failures — is reported as 500 with the inspected reason in theerrorfield.
cURL Example
curl -X POST http://localhost:8080/api/ussd/send \
-H "Content-Type: application/json" \
-d '{
"msisdn": "+254712345678",
"text": "You have a pending bill. Reply 1 to pay.",
"callback_url": "http://billing-app:9000/ussd"
}'
Session Lifecycle
Each USSD dialogue is tracked as a session with a unique session_id.
Session States
Single-Turn Session
If your callback returns "end" on the first turn, no persistent session is created. The gateway sends a MAP End with the result and returns immediately.
Multi-Turn Session
If your callback returns "continue", the gateway:
- Creates a Session GenServer registered in
UssdGateway.Registry - Sends a MAP Continue with opcode 60 (unstructuredSS-Request) to the mobile
- Waits for the subscriber's reply (up to
turn_timeout_ms) - Forwards the reply to your callback
- Repeats until your callback returns
"end"or a timeout occurs
Timeout Behaviour
| Timeout | Default | Effect |
|---|---|---|
| Turn timeout | 30 seconds | If the subscriber doesn't reply within this window, the session is terminated with a MAP error. |
| Session timeout | 3 minutes | Total session lifetime. Terminates the session regardless of activity. |
| HTTP callback timeout | 5 seconds | If your application doesn't respond in time, the gateway sends a MAP error to the mobile and terminates the session. |
Error Handling
The gateway handles failures gracefully and always attempts to send a MAP error response to the mobile so the subscriber sees a meaningful message rather than a network timeout.
| Scenario | Gateway Action | MAP Error Code |
|---|---|---|
| HTTP callback timeout or 5xx | Terminate session, send MAP End with error | 34 (systemFailure) |
| Invalid JSON from callback | Terminate session, send MAP End with error | 34 (systemFailure) |
USSD text exceeds max_text_length | Truncate text, log warning, continue normally | N/A (truncated, not an error) |
| Subscriber timeout (no reply) | Terminate session, send MAP End with error | 34 (systemFailure) |
| No route matches short code | Send MAP End with error, log warning | 34 (systemFailure) |
| Session GenServer crash | Session dies, subscriber sees network timeout | N/A (process exit) |
| USSD gateway not enabled | Return Facility Not Supported | 21 (facilityNotSupported) |
Metrics and Monitoring
The USSD Gateway exposes Prometheus metrics on the standard /metrics endpoint (port 8080).
USSD Metrics
Metric: ussd_requests_total
Type: Counter
Description: Total USSD requests processed
Labels:
direction—"inbound"(mobile-originated) or"outbound"(network-originated push)
Metric: ussd_active_sessions
Type: Gauge
Description: Declared as a gauge for the number of active USSD sessions.
Caveat: This gauge is declared but never updated in the current build — it always reads
0. Do not rely on it for live session counts. Track session activity viaussd_requests_totalinstead.
Metric: map_request_duration_milliseconds
Type: Histogram
Description: Duration of USSD send operations in milliseconds
Labels:
operation—"ussd_send"for outbound push requests
Example Prometheus Queries
# USSD request rate by direction (inbound vs outbound)
rate(ussd_requests_total[5m])
# Outbound USSD latency (p95)
histogram_quantile(0.95, rate(map_request_duration_milliseconds_bucket{operation="ussd_send"}[5m]))
Example Callback Server
Python (Flask)
from flask import Flask, request, jsonify
app = Flask(__name__)
sessions = {}
@app.route('/ussd', methods=['POST'])
def ussd():
data = request.json
session_id = data['session_id']
text = data['text']
turn = data['turn']
if data['type'] == 'initiation':
sessions[session_id] = {'state': 'main_menu'}
return jsonify({
'action': 'continue',
'text': 'Welcome!\n1. Check balance\n2. Buy airtime\n3. Transfer'
})
state = sessions.get(session_id, {}).get('state')
if state == 'main_menu':
if text == '1':
del sessions[session_id]
return jsonify({
'action': 'end',
'text': 'Your balance is $5.00'
})
elif text == '2':
sessions[session_id]['state'] = 'buy_airtime'
return jsonify({
'action': 'continue',
'text': 'Enter amount:'
})
else:
del sessions[session_id]
return jsonify({
'action': 'end',
'text': 'Invalid option. Goodbye.'
})
elif state == 'buy_airtime':
del sessions[session_id]
return jsonify({
'action': 'end',
'text': f'You purchased ${text} airtime. Thank you!'
})
return jsonify({'action': 'end', 'text': 'Session expired.'})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=9000)
Node.js (Express)
const express = require('express');
const app = express();
app.use(express.json());
const sessions = new Map();
app.post('/ussd', (req, res) => {
const { session_id, text, type } = req.body;
if (type === 'initiation') {
sessions.set(session_id, { state: 'main_menu' });
return res.json({
action: 'continue',
text: 'Welcome!\n1. Check balance\n2. Buy airtime'
});
}
const session = sessions.get(session_id);
if (!session) {
return res.json({ action: 'end', text: 'Session expired.' });
}
if (session.state === 'main_menu' && text === '1') {
sessions.delete(session_id);
return res.json({ action: 'end', text: 'Your balance is $5.00' });
}
sessions.delete(session_id);
return res.json({ action: 'end', text: 'Goodbye.' });
});
app.listen(9000, () => console.log('USSD callback on port 9000'));
Troubleshooting
USSD Dial Returns "Service Not Available"
Symptoms: Subscriber dials a short code and immediately gets a network error.
Possible causes:
ussd_gateway_enabledisfalse- No route matches the dialled short code
- M3UA connection is down
Resolution:
- Verify
ussd_gateway_enabled: truein configuration - Check that a route pattern matches the short code (remember to include
*catch-all) - Check M3UA peer status in the Web UI (Peers page)
Callback Not Receiving Requests
Symptoms: Gateway logs show USSD begin but your application never receives the HTTP POST.
Possible causes:
- Callback URL is unreachable from the OmniSS7 host
- Firewall blocking outbound HTTP from OmniSS7
- Callback application not running
Resolution:
- Test connectivity:
curl -v http://your-app:9000/ussdfrom the OmniSS7 host - Check firewall rules for outbound HTTP
- Verify your callback application is listening on the configured port
Sessions Timing Out Prematurely
Symptoms: Multi-turn sessions end with "systemFailure" before the subscriber can reply.
Possible causes:
turn_timeout_msis too short for your subscriber basehttp_timeout_msis too short for your application's processing time- Network latency between OmniSS7 and your callback server
Resolution:
- Increase
turn_timeout_ms(default 30 seconds should suffice for most cases) - Increase
http_timeout_msif your application needs more processing time - Deploy callback server close to OmniSS7 to reduce latency
GSM 7-bit Encoding Errors
Symptoms: gsm7_encode_failed errors in logs, or a 500 response from /api/ussd/send whose error field contains {:gsm7_encode_failed, ...}.
Possible causes:
- Text contains characters outside the GSM 7-bit default alphabet (e.g. emoji, CJK characters)
Resolution:
- Restrict USSD text to the GSM basic character set: ASCII letters, digits, common punctuation, and a few Greek/Nordic characters
- See 3GPP TS 23.038 Section 6.2.1 for the complete character table
Related Documentation
- API Guide — Full REST API reference (all endpoints including
/api/ussd/send) - MAP Client Guide — M3UA connection setup required for USSD
- Configuration Reference — All configuration parameters
- Common Features Guide — Web UI, monitoring, and Prometheus setup