Session Management
Learn how to manage USSD sessions in your backend application.
Overview
USSD is session-based - users interact through multiple requests within a single session. Your backend needs to:
- Track user state across multiple requests
- Store temporary data (inputs, selections, flow position)
- Clean up expired sessions
- Handle session timeouts
Gateway vs Backend Session Management
What the Gateway Provides
The OLA Gateway provides basic session tracking:
Gateway responsibilities:
- Creates unique
session_idon first dial - Maintains
session_idacross requests - Tracks session TTL (default 5 minutes)
- Auto-expires inactive sessions
What gateway does NOT do:
- Store your application state
- Remember user's menu position
- Track multi-step flows
- Store user inputs
What Your Backend Must Do
Your backend is responsible for:
- Storing application state
- Tracking menu navigation
- Remembering user inputs
- Implementing business logic flows
Session Identifiers
Every request includes an ID:
| ID | Purpose | Lifetime | Uniqueness |
|---|---|---|---|
session_id | Track the USSD session | Entire session | Same across all requests in session |
transaction_id | Track individual request | Single request | Unique for every request |
Example Flow
User dials *365#
├─ Request 1: session_id=sess_abc, transaction_id=txn_001
├─ Request 2: session_id=sess_abc, transaction_id=txn_002 (same session)
├─ Request 3: session_id=sess_abc, transaction_id=txn_003 (same session)
└─ Session ends
User dials *365# again (new session)
└─ Request 1: session_id=sess_xyz, transaction_id=txn_004 (new session)
Note that not all providers follow the same binary ID structure. In those cases, the value of the session_id and the transaction_id will be the same. For your backend implementation, the most important tracker is the session_id.
Backend Session Storage
You have several options for storing session state:
Option 1: In-Memory Storage (Simple)
Best for: Development, low-traffic services, single-server deployments
// Node.js example
const sessions = {}; // In-memory store
app.post('/ussd/callback', (req, res) => {
const { session_id, transaction_id, input } = req.body;
// Get or create session
if (!sessions[session_id]) {
sessions[session_id] = {
step: 'main_menu',
data: {},
created_at: Date.now()
};
}
const session = sessions[session_id];
// Process request
const response = handleRequest(session, input);
// Update session
sessions[session_id] = session;
// Cleanup on end
if (response.end_session) {
delete sessions[session_id];
}
res.json({
session_id,
transaction_id,
output: response.menu,
end_session: response.end_session
});
});
Python example:
sessions = {} # In-memory store
@app.route('/ussd/callback', methods=['POST'])
def ussd_callback():
data = request.json
session_id = data['session_id']
transaction_id = data['transaction_id']
user_input = data['input']
# Get or create session
if session_id not in sessions:
sessions[session_id] = {
'step': 'main_menu',
'data': {},
'created_at': time.time()
}
session = sessions[session_id]
# Process request
response = handle_request(session, user_input)
# Update session
sessions[session_id] = session
# Cleanup on end
if response['end_session']:
del sessions[session_id]
return jsonify({
'session_id': session_id,
'transaction_id': transaction_id,
'output': response['menu'],
'end_session': response['end_session']
})
Pros:
- Fast
- Simple
- No external dependencies
Cons:
- Lost on restart
- Not distributed (can't scale horizontally)
- No persistence
Option 2: Redis (Recommended for Production)
Best for: Production, distributed systems, high traffic
// Node.js with Redis
const redis = require('redis');
const client = redis.createClient();
app.post('/ussd/callback', async (req, res) => {
const { session_id, transaction_id, input } = req.body;
// Get or create session
let session = await client.get(session_id);
if (!session) {
session = {
step: 'main_menu',
data: {},
created_at: Date.now()
};
} else {
session = JSON.parse(session);
}
// Process request
const response = handleRequest(session, input);
// Update session with TTL (5 minutes)
if (!response.end_session) {
await client.setEx(session_id, 300, JSON.stringify(session));
} else {
await client.del(session_id);
}
res.json({
session_id,
transaction_id,
output: response.menu,
end_session: response.end_session
});
});
Python with Redis:
import redis
import json
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
@app.route('/ussd/callback', methods=['POST'])
def ussd_callback():
data = request.json
session_id = data['session_id']
# Get or create session
session_data = r.get(session_id)
if session_data:
session = json.loads(session_data)
else:
session = {
'step': 'main_menu',
'data': {},
'created_at': time.time()
}
# Process request
response = handle_request(session, data['input'])
# Update session with TTL (5 minutes)
if not response['end_session']:
r.setex(session_id, 300, json.dumps(session))
else:
r.delete(session_id)
return jsonify({
'session_id': session_id,
'transaction_id': data['transaction_id'],
'output': response['menu'],
'end_session': response['end_session']
})
Pros:
- Fast (in-memory)
- Persistent
- Distributed (multiple servers can share)
- Built-in TTL (auto-expires sessions)
Cons:
- Requires Redis server
- Additional infrastructure
Option 3: Database (PostgreSQL, MySQL)
Best for: Long-term persistence, audit trails, complex queries
// Node.js with PostgreSQL
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
app.post('/ussd/callback', async (req, res) => {
const { session_id, transaction_id, input, msisdn } = req.body;
// Get or create session
let result = await pool.query(
'SELECT * FROM ussd_sessions WHERE session_id = $1',
[session_id]
);
let session;
if (result.rows.length === 0) {
// Create new session
await pool.query(
`INSERT INTO ussd_sessions (session_id, msisdn, step, data, created_at)
VALUES ($1, $2, $3, $4, NOW())`,
[session_id, msisdn, 'main_menu', JSON.stringify({})]
);
session = { step: 'main_menu', data: {} };
} else {
session = {
step: result.rows[0].step,
data: JSON.parse(result.rows[0].data)
};
}
// Process request
const response = handleRequest(session, input);
// Update session
if (!response.end_session) {
await pool.query(
`UPDATE ussd_sessions
SET step = $1, data = $2, updated_at = NOW()
WHERE session_id = $3`,
[session.step, JSON.stringify(session.data), session_id]
);
} else {
await pool.query(
'DELETE FROM ussd_sessions WHERE session_id = $1',
[session_id]
);
}
res.json({
session_id,
transaction_id,
output: response.menu,
end_session: response.end_session
});
});
Database Schema:
CREATE TABLE ussd_sessions (
id SERIAL PRIMARY KEY,
session_id VARCHAR(100) UNIQUE NOT NULL,
msisdn VARCHAR(20) NOT NULL,
step VARCHAR(50) NOT NULL,
data JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_session_id ON ussd_sessions(session_id);
CREATE INDEX idx_msisdn ON ussd_sessions(msisdn);
CREATE INDEX idx_created_at ON ussd_sessions(created_at);
Pros:
- Persistent
- Queryable (analytics, debugging)
- Audit trail
- Complex data structures
Cons:
- Slower than in-memory
- More complex
- Requires database
Session Data Structure
Recommended Session Structure
{
"session_id": "sess_abc123", // From gateway
"provider": "movitel",
"msisdn": "258823456789", // User's phone
"step": "enter_amount", // Current menu/flow step
"data": { // Application-specific data
"selected_service": "transfer",
"recipient": "258821234567",
"amount": null,
"pin": null
},
"history": [ // Navigation history
"main_menu",
"services",
"transfer"
],
"created_at": "2025-01-20T10:30:00Z",
"updated_at": "2025-01-20T10:32:15Z"
}
Key Fields
step
Tracks the user's current position in the flow.
Examples:
"step": "main_menu" // At main menu
"step": "enter_pin" // Waiting for PIN
"step": "confirm_transfer" // Confirmation screen
"step": "processing" // Processing payment
Use step to route to the correct handler:
function handleRequest(session, input) {
switch (session.step) {
case 'main_menu':
return handleMainMenu(session, input);
case 'enter_amount':
return handleAmountEntry(session, input);
case 'confirm_transfer':
return handleConfirmation(session, input);
default:
return showError();
}
}
data
Stores temporary user inputs and selections.
Examples:
// Transfer flow
{
"data": {
"transfer_type": "mobile",
"recipient": "258821234567",
"amount": "5000",
"reference": "payment for goods"
}
}
// Bill payment
{
"data": {
"account_number": "12345678",
"amount": "1500"
}
}
// Multi-step form
{
"data": {
"name": "John Doe",
"id_number": "123456789",
"email": "john@example.com"
}
}
history
Track navigation for "back" button functionality.
// User navigates: Main → Services → Transfer → Amount
{
"history": ["main_menu", "services", "transfer", "enter_amount"]
}
// User presses "back"
function goBack(session) {
session.history.pop(); // Remove current step
session.step = session.history[session.history.length - 1];
return getMenuForStep(session.step);
}
Session Lifecycle
1. Session Creation (First Dial)
// User dials *365#
// Gateway sends: input = ""
if (!sessions[session_id]) {
sessions[session_id] = {
step: 'main_menu',
data: {},
history: [],
created_at: Date.now()
};
}
2. Session Updates (User Navigation)
// User selects option
// Gateway sends: input = "1"
const session = sessions[session_id];
session.step = 'services';
session.history.push('main_menu');
session.data.last_selection = '1';
3. Session End (User Exits)
// User selects exit or flow completes
// Your backend sends: end_session = true
if (input === '0' || transferComplete) {
delete sessions[session_id]; // Cleanup
return {
menu: ["Thank you!"],
end_session: true
};
}
4. Session Timeout (Inactivity)
The gateway automatically expires sessions after TTL (default 5 minutes).
Your backend should handle:
// Check if session is expired (optional)
function isExpired(session) {
const TTL = 5 * 60 * 1000; // 5 minutes
return (Date.now() - session.created_at) > TTL;
}
if (isExpired(session)) {
delete sessions[session_id];
return {
menu: ["Session expired", "", "Please dial again"],
end_session: true
};
}
Session Cleanup
Manual Cleanup
Remove expired sessions periodically:
// Node.js cleanup job
setInterval(() => {
const now = Date.now();
const TTL = 5 * 60 * 1000; // 5 minutes
for (const [session_id, session] of Object.entries(sessions)) {
if (now - session.created_at > TTL) {
console.log(`Cleaning up expired session: ${session_id}`);
delete sessions[session_id];
}
}
}, 60 * 1000); // Run every minute
Python cleanup:
import threading
import time
def cleanup_sessions():
while True:
now = time.time()
ttl = 5 * 60 # 5 minutes
expired = [
sid for sid, session in sessions.items()
if now - session['created_at'] > ttl
]
for session_id in expired:
print(f'Cleaning up expired session: {session_id}')
del sessions[session_id]
time.sleep(60) # Run every minute
# Start cleanup thread
threading.Thread(target=cleanup_sessions, daemon=True).start()
Redis Auto-Expiry
With Redis, use SETEX for automatic expiry:
// Set with 5-minute TTL
await client.setEx(session_id, 300, JSON.stringify(session));
// Redis automatically deletes after 300 seconds
Database Cleanup
Use a cron job or scheduled task:
-- Delete sessions older than 10 minutes
DELETE FROM ussd_sessions
WHERE updated_at < NOW() - INTERVAL '10 minutes';
Or in application code:
// Run cleanup every 5 minutes
setInterval(async () => {
await pool.query(`
DELETE FROM ussd_sessions
WHERE updated_at < NOW() - INTERVAL '10 minutes'
`);
}, 5 * 60 * 1000);
Best Practices
1. Always Validate Session Data
function validateSession(session) {
if (!session.step) {
console.error('Missing step in session');
session.step = 'main_menu'; // Reset to safe state
}
if (typeof session.data !== 'object') {
console.error('Invalid session data');
session.data = {};
}
return session;
}
2. Handle Missing Sessions Gracefully
let session = await getSession(session_id);
if (!session) {
console.warn(`Session not found: ${session_id}, creating new`);
session = createNewSession(session_id, msisdn);
}
3. Store Minimal Data
// ✅ Good - minimal data
{
"data": {
"amount": "5000",
"recipient": "258821234567"
}
}
// ❌ Bad - too much data
{
"data": {
"user_full_profile": { /* 100 fields */ },
"all_transactions": [ /* 1000 transactions */ ],
"complete_account_history": { /* huge object */ }
}
}
4. Use Consistent Step Names
// ✅ Good - clear naming
"main_menu"
"enter_pin"
"confirm_transfer"
"processing_payment"
// ❌ Bad - unclear
"step1"
"step2"
"confirm"
"proc"
5. Implement Session Recovery
function handleRequest(session, input) {
try {
return routeToHandler(session, input);
} catch (error) {
console.error('Error handling request:', error);
// Reset to safe state
session.step = 'main_menu';
session.data = {};
return {
menu: [
"An error occurred",
"",
"Returning to main menu..."
],
end_session: false
};
}
}
6. Log Session Activity
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
session_id,
msisdn,
step: session.step,
input,
action: 'session_update'
}));
Testing Sessions
Test Session Creation
# First request (creates session)
curl -X POST "https://{your-domain-or-public-ip}/ussd/callback \
-H "Content-Type: application/json" \
-d '{
"provider": "movitel",
"msisdn": "258823456789",
"session_id": "test_sess_001",
"transaction_id": "test_txn_001",
"input": ""
}'
Test Session Persistence
# Second request (should remember session)
curl -X POST "https://{your-domain-or-public-ip}/ussd/callback \
-H "Content-Type: application/json" \
-d '{
"provider": "movitel",
"msisdn": "258823456789",
"session_id": "test_sess_001",
"transaction_id": "test_txn_002",
"input": "1"
}'
Test Session Cleanup
# End session
curl -X POST "https://{your-domain-or-public-ip}/ussd/callback \
-H "Content-Type: application/json" \
-d '{
"provider": "movitel",
"msisdn": "258823456789",
"session_id": "test_sess_001",
"transaction_id": "test_txn_003",
"input": "0"
}'
# Verify session is deleted
# (check your storage - should be gone)
Next Steps
- Menu Flows - Build menu navigation using sessions
- Examples - See real-world session management