Skip to main content

USSD Gateway Guide

← Back to Main Documentation

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

  1. Overview
  2. Architecture
  3. Enabling the USSD Gateway
  4. Configuration
  5. HTTP Callback Protocol
  6. Network-Originated USSD (Push API)
  7. Session Lifecycle
  8. Error Handling
  9. Metrics and Monitoring
  10. Example Callback Server
  11. 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 MAP processUnstructuredSS-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

PropertyValue
TransportSynchronous HTTP POST per turn
EncodingGSM 7-bit default alphabet (DCS 0x0F) per 3GPP TS 23.038
Max text length182 characters (configurable)
Session trackingGateway-generated UUID per dialogue
AuthenticationNone (trusts the SS7 network)
RoutingShort code prefix matching to callback URLs

3GPP References

SpecificationRelevance
3GPP TS 23.090USSD Stage 2 — architecture and procedures
3GPP TS 24.090USSD Stage 3 — protocol details
3GPP TS 29.002MAP protocol — USSD-Arg, USSD-Res, opcodes 59/60/61
3GPP TS 23.038GSM 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

ParameterTypeRequiredDefaultDescription
ussd_gateway_enabledBooleanYesfalseMaster switch for the USSD Gateway feature
ussd_gateway.routesList of MapsYes[]Short code prefix routing rules. Each entry has pattern (prefix string) and url (callback URL). Longest prefix match wins.
ussd_gateway.session_timeout_msIntegerNo180_000Maximum total session duration in milliseconds. Session is terminated with an error if exceeded.
ussd_gateway.turn_timeout_msIntegerNo30_000Maximum time to wait for the subscriber's reply in a multi-turn dialogue, in milliseconds.
ussd_gateway.http_timeout_msIntegerNo5_000HTTP request timeout for callbacks to your application, in milliseconds. Covers both connect and response time.
ussd_gateway.max_text_lengthIntegerNo182Maximum 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:

ParameterTypeRequiredDescription
patternStringYesShort code prefix to match. Use "*" as a catch-all fallback. Longer prefixes take priority.
urlStringYesHTTP endpoint URL to receive callback POSTs for matching short codes.

Route Matching

Routes are matched by longest prefix first. For the dial string *100#:

  1. "*100" matches (length 4) — selected
  2. "*10" matches (length 3) — skipped, shorter
  3. "*" 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

FieldTypeDescription
session_idStringGateway-generated UUID. Unique per USSD dialogue. Use this to correlate turns.
msisdnStringSubscriber's MSISDN (if available from the MAP message). May be empty for some networks.
typeString"initiation" for the first turn, "response" for subsequent subscriber replies.
textStringThe dial string (e.g. *100#) on initiation, or the subscriber's input (e.g. 1) on response.
turnIntegerTurn 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

FieldTypeRequiredDescription
actionStringYes"continue" to keep the session open and wait for subscriber input, or "end" to display a final message and close the session.
textStringYesText 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

FieldTypeRequiredDescription
msisdnStringYesDestination subscriber MSISDN in international format.
textStringYesInitial USSD text to display. Encoded as GSM 7-bit.
callback_urlStringYesURL 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 StatusBodyCause
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 the error field.

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:

  1. Creates a Session GenServer registered in UssdGateway.Registry
  2. Sends a MAP Continue with opcode 60 (unstructuredSS-Request) to the mobile
  3. Waits for the subscriber's reply (up to turn_timeout_ms)
  4. Forwards the reply to your callback
  5. Repeats until your callback returns "end" or a timeout occurs

Timeout Behaviour

TimeoutDefaultEffect
Turn timeout30 secondsIf the subscriber doesn't reply within this window, the session is terminated with a MAP error.
Session timeout3 minutesTotal session lifetime. Terminates the session regardless of activity.
HTTP callback timeout5 secondsIf 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.

ScenarioGateway ActionMAP Error Code
HTTP callback timeout or 5xxTerminate session, send MAP End with error34 (systemFailure)
Invalid JSON from callbackTerminate session, send MAP End with error34 (systemFailure)
USSD text exceeds max_text_lengthTruncate text, log warning, continue normallyN/A (truncated, not an error)
Subscriber timeout (no reply)Terminate session, send MAP End with error34 (systemFailure)
No route matches short codeSend MAP End with error, log warning34 (systemFailure)
Session GenServer crashSession dies, subscriber sees network timeoutN/A (process exit)
USSD gateway not enabledReturn Facility Not Supported21 (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 via ussd_requests_total instead.

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_enabled is false
  • No route matches the dialled short code
  • M3UA connection is down

Resolution:

  1. Verify ussd_gateway_enabled: true in configuration
  2. Check that a route pattern matches the short code (remember to include * catch-all)
  3. 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:

  1. Test connectivity: curl -v http://your-app:9000/ussd from the OmniSS7 host
  2. Check firewall rules for outbound HTTP
  3. 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_ms is too short for your subscriber base
  • http_timeout_ms is too short for your application's processing time
  • Network latency between OmniSS7 and your callback server

Resolution:

  1. Increase turn_timeout_ms (default 30 seconds should suffice for most cases)
  2. Increase http_timeout_ms if your application needs more processing time
  3. 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