SMS and USSD Integration
SMS and USSD services provide communication channels that function on basic feature phones without internet connectivity, reaching populations where smartphone penetration and data coverage remain limited. This task covers gateway configuration, message routing, USSD menu development, and integration with data collection platforms.
- SMS Gateway
- An intermediary service that connects applications to mobile network operators, handling message routing, delivery status tracking, and protocol translation between HTTP APIs and carrier networks.
- USSD
- Unstructured Supplementary Service Data, a session-based protocol that enables real-time, menu-driven interactions over GSM networks. Unlike SMS, USSD sessions maintain state and require no message storage on devices.
- Shortcode
- A shortened phone number (typically 4-6 digits) used for high-volume messaging. Shortcodes require carrier approval and incur higher costs than long numbers but offer better throughput and recognition.
- Long Number
- A standard phone number (with country code) used for SMS services. Long numbers have lower throughput limits but require minimal setup and work across carriers without individual agreements.
- Aggregator
- A service provider maintaining connections to multiple mobile network operators, enabling message delivery across carriers through a single integration point.
Prerequisites
Before beginning integration, verify the following requirements are satisfied:
| Requirement | Detail |
|---|---|
| Provider account | Active account with SMS/USSD aggregator (Africa’s Talking, Twilio, Infobip, or regional provider) |
| Number or shortcode | Dedicated long number or approved shortcode for the target country |
| Platform access | Administrative access to data collection platform (KoboToolbox, ODK Central, CommCare) |
| Server environment | Linux server or cloud function capability for webhook endpoints |
| TLS certificate | Valid certificate for HTTPS webhook endpoints |
| Budget approval | Cost estimate approved based on expected message volume |
| Network assessment | Carrier coverage verified in target areas |
Confirm your aggregator account has API credentials:
# Test API connectivity (Africa's Talking example)curl -X GET "https://api.africastalking.com/version1/user?username=YOUR_USERNAME" \ -H "apiKey: YOUR_API_KEY" \ -H "Accept: application/json"Expected response:
{ "UserData": { "balance": "KES 1500.00" }}If the request returns a 401 error, regenerate API credentials from the provider dashboard.
Architecture
SMS and USSD integration connects three components: the aggregator gateway, your application server, and the data collection platform. The aggregator maintains carrier connections and exposes HTTP APIs for sending messages while delivering inbound messages and USSD requests to your webhook endpoints.
+------------------------------------------------------------------+| MOBILE NETWORK LAYER |+------------------------------------------------------------------+| || +-------------+ +-------------+ +-------------+ || | Carrier A | | Carrier B | | Carrier C | || | (Safaricom) | | (MTN) | | (Airtel) | || +------+------+ +------+------+ +------+------+ || | | | || +----------------+----------------+ || | |+------------------------------------------------------------------+ | v+------------------------------------------------------------------+| AGGREGATOR LAYER |+------------------------------------------------------------------+| || +----------------------------------------------------------+ || | SMS/USSD Aggregator | || | | || | +------------------+ +------------------+ | || | | SMS Gateway | | USSD Gateway | | || | | | | | | || | | - Send API | | - Session mgmt | | || | | - Receive webhook| | - Menu routing | | || | | - Delivery status| | - Timeout handle | | || | +--------+---------+ +--------+---------+ | || | | | | || +-----------+---------------------+------------------------+ || | | |+------------------------------------------------------------------+ | | v v+------------------------------------------------------------------+| APPLICATION LAYER |+------------------------------------------------------------------+| || +---------------------------+ +---------------------------+ || | Application Server | | Data Collection Platform | || | | | | || | +---------------------+ | | +---------------------+ | || | | Webhook Endpoints | | | | Form Engine | | || | | | | | | | | || | | POST /sms/incoming +--+--+->| Process submission | | || | | POST /ussd/callback | | | | Store response | | || | | POST /sms/status | | | | Trigger workflow | | || | +---------------------+ | | +---------------------+ | || | | | | || | +---------------------+ | | +---------------------+ | || | | Message Queue | | | | Notification Engine | | || | | | | | | | | || | | - Outbound SMS |<-+--+--+ Reminder triggers | | || | | - Retry logic | | | | Bulk campaigns | | || | | - Rate limiting | | | | Alert dispatch | | || | +---------------------+ | | +---------------------+ | || +---------------------------+ +---------------------------+ || |+------------------------------------------------------------------+Figure 1: SMS/USSD integration architecture showing carrier, aggregator, and application layers
The application server handles webhook requests from the aggregator and queues outbound messages. Separating the webhook handler from the data collection platform allows independent scaling and provides a buffer against platform downtime affecting message receipt.
Procedure
Provider configuration
- Log into your aggregator dashboard and create a new application. For Africa’s Talking, navigate to Apps > Create App and enter your application name. Record the generated username:
Application: fielddata-project Username: fielddata Environment: Production- Generate API credentials under Settings > API Key. Store the key securely:
# Store in environment variable (do not commit to version control) export AT_API_KEY="atsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" export AT_USERNAME="fielddata"Acquire a phone number or shortcode. For long numbers, purchase through the dashboard under SMS > Shortcodes & Sender IDs > Buy Number. For shortcodes, submit a request with:
- Organisation registration documents
- Use case description
- Expected message volume
- Sample message content
Shortcode approval takes 2-4 weeks depending on the carrier and country.
Configure the callback URL for inbound SMS. Navigate to SMS > SMS Callback URLs and enter your webhook endpoint:
Incoming Messages: https://sms.example.org/api/v1/sms/incoming Delivery Reports: https://sms.example.org/api/v1/sms/statusBoth endpoints must be HTTPS with a valid certificate.
- For USSD, create a service code mapping. Navigate to USSD > Create Channel and configure:
Service Code: *384*123# Callback URL: https://sms.example.org/api/v1/ussd/callbackUSSD service codes require carrier approval. Submit the application with your menu structure and expected session volume.
Webhook server deployment
- Create the webhook server. This example uses Node.js with Express:
const express = require('express'); const bodyParser = require('body-parser'); const crypto = require('crypto');
const app = express(); app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json());
// Verify webhook signature (Africa's Talking) function verifySignature(req, secret) { const signature = req.headers['x-africastalking-signature']; if (!signature) return false;
const payload = JSON.stringify(req.body); const expected = crypto .createHmac('sha256', secret) .update(payload) .digest('base64');
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); }
// Inbound SMS endpoint app.post('/api/v1/sms/incoming', (req, res) => { const { from, to, text, date, id } = req.body;
console.log(`SMS received: ${id}`); console.log(`From: ${from}, To: ${to}`); console.log(`Text: ${text}`); console.log(`Date: ${date}`);
// Forward to data collection platform processInboundSMS({ from, to, text, date, id });
res.status(200).send('OK'); });
// Delivery status endpoint app.post('/api/v1/sms/status', (req, res) => { const { id, status, failureReason } = req.body;
console.log(`Delivery status: ${id} -> ${status}`); if (failureReason) { console.log(`Failure reason: ${failureReason}`); }
updateDeliveryStatus(id, status, failureReason);
res.status(200).send('OK'); });
const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Webhook server running on port ${PORT}`); });- Deploy the server with process management:
# Install dependencies npm install express body-parser
# Install PM2 for process management npm install -g pm2
# Start the server pm2 start server.js --name sms-webhook
# Configure startup script pm2 startup pm2 save- Configure nginx as a reverse proxy with TLS:
server { listen 443 ssl http2; server_name sms.example.org;
ssl_certificate /etc/letsencrypt/live/sms.example.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/sms.example.org/privkey.pem;
location /api/v1/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
# Timeout for slow network conditions proxy_connect_timeout 30s; proxy_send_timeout 30s; proxy_read_timeout 30s; } }- Enable the site and reload nginx:
sudo ln -s /etc/nginx/sites-available/sms-webhook /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx- Test the webhook endpoint:
curl -X POST https://sms.example.org/api/v1/sms/incoming \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "from=%2B254712345678&to=12345&text=TEST&date=2024-01-15T10:30:00Z&id=msg123"Expected response: OK with HTTP 200.
SMS sending configuration
- Create the SMS sending module:
const https = require('https'); const querystring = require('querystring');
const AT_API_KEY = process.env.AT_API_KEY; const AT_USERNAME = process.env.AT_USERNAME;
async function sendSMS(recipients, message, options = {}) { const params = { username: AT_USERNAME, to: Array.isArray(recipients) ? recipients.join(',') : recipients, message: message, from: options.senderId || null, bulkSMSMode: options.bulkMode || 1, enqueue: options.enqueue || 1 };
// Remove null values Object.keys(params).forEach(key => params[key] === null && delete params[key] );
const postData = querystring.stringify(params);
const requestOptions = { hostname: 'api.africastalking.com', port: 443, path: '/version1/messaging', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData), 'apiKey': AT_API_KEY, 'Accept': 'application/json' } };
return new Promise((resolve, reject) => { const req = https.request(requestOptions, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const response = JSON.parse(data); resolve(response); } catch (e) { reject(new Error(`Invalid response: ${data}`)); } }); });
req.on('error', reject); req.write(postData); req.end(); }); }
module.exports = { sendSMS };- Test SMS sending:
const { sendSMS } = require('./sms-sender');
async function testSend() { try { const result = await sendSMS( '+254712345678', 'Test message from field data system' ); console.log('Send result:', JSON.stringify(result, null, 2)); } catch (error) { console.error('Send failed:', error.message); } }
testSend();Expected response:
{ "SMSMessageData": { "Message": "Sent to 1/1 Total Cost: KES 0.80", "Recipients": [ { "statusCode": 101, "number": "+254712345678", "status": "Success", "cost": "KES 0.80", "messageId": "ATXid_xxxxxxxxxxxxxxxxxxxxxxx" } ] } }- Implement rate limiting to avoid carrier throttling:
class RateLimiter { constructor(maxPerSecond = 10) { this.maxPerSecond = maxPerSecond; this.queue = []; this.processing = false; }
async add(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject }); this.process(); }); }
async process() { if (this.processing || this.queue.length === 0) return;
this.processing = true; const batch = this.queue.splice(0, this.maxPerSecond);
for (const { task, resolve, reject } of batch) { try { const result = await task(); resolve(result); } catch (error) { reject(error); } }
this.processing = false;
if (this.queue.length > 0) { setTimeout(() => this.process(), 1000); } } }
module.exports = { RateLimiter };- Configure bulk sending with rate limiting:
const { sendSMS } = require('./sms-sender'); const { RateLimiter } = require('./rate-limiter');
const limiter = new RateLimiter(10); // 10 messages per second
async function sendBulkSMS(recipients, message) { const results = [];
for (const recipient of recipients) { const result = await limiter.add(() => sendSMS(recipient, message) ); results.push(result); }
return results; }SMS receiving configuration
- Extend the webhook handler to parse and route inbound messages:
const KEYWORDS = { 'REGISTER': handleRegistration, 'STOP': handleOptOut, 'HELP': handleHelp, 'STATUS': handleStatusQuery };
async function processInboundSMS({ from, to, text, date, id }) { // Log for audit await logMessage({ from, to, text, date, id, direction: 'inbound' });
// Parse first word as keyword const parts = text.trim().split(/\s+/); const keyword = parts[0].toUpperCase(); const payload = parts.slice(1).join(' ');
// Route to handler const handler = KEYWORDS[keyword]; if (handler) { return handler(from, payload, id); }
// Default: forward to data collection platform return forwardToDataCollection(from, text, id); }
async function handleRegistration(from, payload, messageId) { // Parse registration data: REGISTER <name> <location> const match = payload.match(/^(\S+)\s+(.+)$/); if (!match) { await sendSMS(from, 'Registration format: REGISTER <name> <location>. Example: REGISTER John Nairobi' ); return; }
const [, name, location] = match;
// Store registration await storeRegistration({ phone: from, name, location, messageId });
// Confirm await sendSMS(from, `Thank you ${name}. You are registered for ${location}. Reply STOP to unsubscribe.` ); }
async function handleOptOut(from, payload, messageId) { await markOptedOut(from); await sendSMS(from, 'You have been unsubscribed. You will not receive further messages.' ); }
async function handleHelp(from, payload, messageId) { await sendSMS(from, 'Commands: REGISTER <name> <location>, STATUS, STOP, HELP. For support call +254700123456.' ); }- Implement opt-out list checking before sending:
const optedOut = new Set();
async function loadOptOutList() { // Load from database on startup const records = await db.query('SELECT phone FROM opt_outs WHERE active = true'); records.forEach(r => optedOut.add(normalisePhone(r.phone))); }
function isOptedOut(phone) { return optedOut.has(normalisePhone(phone)); }
async function markOptedOut(phone) { const normalised = normalisePhone(phone); optedOut.add(normalised); await db.query( 'INSERT INTO opt_outs (phone, opted_out_at) VALUES ($1, NOW()) ON CONFLICT (phone) DO UPDATE SET active = true', [normalised] ); }
function normalisePhone(phone) { // Remove spaces, dashes, leading zeros return phone.replace(/[\s-]/g, '').replace(/^0+/, '+'); }
// Wrap sendSMS to check opt-out async function sendSMSSafe(recipient, message, options) { if (isOptedOut(recipient)) { console.log(`Blocked send to opted-out number: ${recipient}`); return { blocked: true, reason: 'opted_out' }; } return sendSMS(recipient, message, options); }- Configure message logging for audit and troubleshooting:
-- Create message log table CREATE TABLE sms_messages ( id SERIAL PRIMARY KEY, message_id VARCHAR(64) UNIQUE, direction VARCHAR(10) NOT NULL, -- 'inbound' or 'outbound' from_number VARCHAR(20) NOT NULL, to_number VARCHAR(20) NOT NULL, message_text TEXT NOT NULL, status VARCHAR(20) DEFAULT 'pending', failure_reason TEXT, cost DECIMAL(10, 4), sent_at TIMESTAMP, received_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() );
CREATE INDEX idx_sms_from ON sms_messages(from_number); CREATE INDEX idx_sms_status ON sms_messages(status); CREATE INDEX idx_sms_created ON sms_messages(created_at);USSD menu development
USSD sessions maintain state throughout an interaction, unlike SMS. Each user request includes a session ID, and responses indicate whether the session continues (CON) or ends (END). Sessions timeout after 30-120 seconds of inactivity depending on the carrier.
+------------------------------------------------------------------+| USSD SESSION FLOW |+------------------------------------------------------------------+| || User dials *384*123# || | || v || +------------------+ || | Session Start | || | sessionId: new | || | text: "" | || +--------+---------+ || | || v || +------------------+ Response: CON || | Main Menu | "Welcome to FieldData || | | 1. Register || | | 2. Submit Report || | | 3. Check Status" || +--------+---------+ || | || User selects 2 || | || v || +------------------+ || | Session Continue | || | sessionId: same | || | text: "2" | || +--------+---------+ || | || v || +------------------+ Response: CON || | Report Type | "Select report type: || | | 1. Health || | | 2. Water || | | 3. Education" || +--------+---------+ || | || User selects 1 || | || v || +------------------+ || | Session Continue | || | sessionId: same | || | text: "2*1" | || +--------+---------+ || | || v || +------------------+ Response: CON || | Data Entry | "Enter number of patients || | | seen today:" || +--------+---------+ || | || User enters 47 || | || v || +------------------+ || | Session Continue | || | sessionId: same | || | text: "2*1*47" | || +--------+---------+ || | || v || +------------------+ Response: END || | Confirmation | "Report submitted. || | | Patients: 47 || | | Reference: RPT-2847" || +------------------+ || |+------------------------------------------------------------------+Figure 2: USSD session flow showing menu navigation and data entry
- Implement the USSD callback handler:
// Session storage (use Redis for production) const sessions = new Map();
app.post('/api/v1/ussd/callback', async (req, res) => { const { sessionId, serviceCode, phoneNumber, text } = req.body;
// Parse navigation path const inputs = text ? text.split('*') : [];
let response;
try { if (inputs.length === 0) { // New session - show main menu response = showMainMenu(); } else { response = await processInput(sessionId, phoneNumber, inputs); } } catch (error) { console.error('USSD error:', error); response = 'END An error occurred. Please try again.'; }
res.set('Content-Type', 'text/plain'); res.send(response); });
function showMainMenu() { return `CON Welcome to FieldData 1. Register 2. Submit Report 3. Check Status 4. Help`; }
async function processInput(sessionId, phone, inputs) { const mainChoice = inputs[0];
switch (mainChoice) { case '1': return handleRegistrationFlow(sessionId, phone, inputs.slice(1)); case '2': return handleReportFlow(sessionId, phone, inputs.slice(1)); case '3': return handleStatusFlow(phone); case '4': return 'END For help call +254700123456 or SMS HELP to 12345'; default: return 'END Invalid selection. Please dial again.'; } }- Implement the report submission flow:
async function handleReportFlow(sessionId, phone, inputs) { // Level 0: Select report type if (inputs.length === 0) { return `CON Select report type: 1. Health facility 2. Water point 3. School`; }
const reportType = inputs[0]; const reportTypes = { '1': 'health', '2': 'water', '3': 'school' };
if (!reportTypes[reportType]) { return 'END Invalid report type. Please dial again.'; }
// Level 1: Enter location if (inputs.length === 1) { return 'CON Enter location name:'; }
const location = inputs[1];
// Level 2: Enter count if (inputs.length === 2) { const prompts = { '1': 'Enter number of patients seen today:', '2': 'Enter litres distributed today:', '3': 'Enter student attendance today:' }; return `CON ${prompts[reportType]}`; }
const count = parseInt(inputs[2], 10);
if (isNaN(count) || count < 0) { return 'END Invalid number. Please dial again.'; }
// Level 3: Confirm and submit if (inputs.length === 3) { // Store pending submission sessions.set(sessionId, { phone, type: reportTypes[reportType], location, count, timestamp: new Date() });
return `CON Confirm submission: Type: ${reportTypes[reportType]} Location: ${location} Count: ${count}
1. Submit 2. Cancel`; }
const confirm = inputs[3];
if (confirm === '1') { const data = sessions.get(sessionId); sessions.delete(sessionId);
// Submit to data collection platform const reference = await submitReport(data);
return `END Report submitted successfully. Reference: ${reference} Thank you.`; } else { sessions.delete(sessionId); return 'END Submission cancelled.'; } }- Handle session timeouts and edge cases:
// Clean up expired sessions setInterval(() => { const now = Date.now(); const timeout = 120000; // 2 minutes
for (const [sessionId, data] of sessions) { if (now - data.timestamp > timeout) { sessions.delete(sessionId); } } }, 60000); // Check every minute
// Handle network-initiated session end app.post('/api/v1/ussd/timeout', (req, res) => { const { sessionId } = req.body;
if (sessions.has(sessionId)) { const data = sessions.get(sessionId); console.log(`Session timeout for ${data.phone}: ${sessionId}`); sessions.delete(sessionId); }
res.status(200).send('OK'); });Data collection platform integration
Integration with data collection platforms enables SMS and USSD data to flow into existing monitoring systems. The integration approach depends on the platform’s API capabilities.
- For KoboToolbox, use the submission API to create records:
const KOBO_URL = 'https://kf.kobotoolbox.org'; const KOBO_TOKEN = process.env.KOBO_TOKEN; const FORM_UID = 'aXbYcZdEfGhIjKlM';
async function submitToKobo(data) { const submission = { 'formhub/uuid': FORM_UID, 'meta/instanceID': `uuid:${generateUUID()}`, 'start': data.timestamp, 'end': new Date().toISOString(), 'phone_number': data.phone, 'report_type': data.type, 'location': data.location, 'count': data.count, 'source': 'ussd' };
const response = await fetch(`${KOBO_URL}/api/v2/assets/${FORM_UID}/submissions/`, { method: 'POST', headers: { 'Authorization': `Token ${KOBO_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify(submission) });
if (!response.ok) { throw new Error(`Kobo submission failed: ${response.status}`); }
return await response.json(); }- For ODK Central, use the submissions endpoint:
const ODK_URL = 'https://odk.example.org/v1'; const ODK_PROJECT = 5; const ODK_FORM = 'field_report';
async function submitToODK(data, authToken) { const xml = buildSubmissionXML(data);
const response = await fetch( `${ODK_URL}/projects/${ODK_PROJECT}/forms/${ODK_FORM}/submissions`, { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/xml', 'X-OpenRosa-Version': '1.0' }, body: xml } );
if (!response.ok) { throw new Error(`ODK submission failed: ${response.status}`); }
return response.headers.get('X-OpenRosa-Accept-Content-Length'); }
function buildSubmissionXML(data) { return `<?xml version="1.0" encoding="UTF-8"?> <data id="field_report" version="2024011501"> <meta> <instanceID>uuid:${generateUUID()}</instanceID> </meta> <phone>${escapeXML(data.phone)}</phone> <report_type>${escapeXML(data.type)}</report_type> <location>${escapeXML(data.location)}</location> <count>${data.count}</count> <source>ussd</source> <submitted_at>${new Date().toISOString()}</submitted_at> </data>`; }- Configure outbound notifications triggered by platform events:
// Webhook receiver for platform events app.post('/api/v1/platform/webhook', async (req, res) => { const { event, data } = req.body;
switch (event) { case 'submission.created': // Check if SMS notification configured for this form if (data.form_uid === FORM_UID && data.notify_supervisor) { await notifySupervisor(data); } break;
case 'reminder.due': await sendReminder(data.phone, data.message); break;
case 'alert.threshold': await sendAlert(data.recipients, data.message); break; }
res.status(200).json({ received: true }); });
async function notifySupervisor(submission) { const supervisor = await getSupervisorForLocation(submission.location); if (!supervisor || !supervisor.phone) return;
const message = `New ${submission.type} report from ${submission.location}: ${submission.count}. Ref: ${submission.id}`;
await sendSMSSafe(supervisor.phone, message); }Data protection
SMS content transits carrier networks unencrypted. Do not transmit personal identifiers, health information, or protection data via SMS. Use SMS for references and notifications; retrieve sensitive data through authenticated platform access.
Cost management
SMS costs accumulate quickly at scale. A campaign sending 10,000 messages at KES 0.80 per message costs KES 8,000 (approximately USD 60). USSD sessions cost per interaction, typically KES 1-3 per session regardless of length.
Implement cost controls:
const DAILY_BUDGET = 5000; // KESconst dailyCost = { date: null, amount: 0 };
async function checkBudget(estimatedCost) { const today = new Date().toDateString();
if (dailyCost.date !== today) { dailyCost.date = today; dailyCost.amount = 0; }
if (dailyCost.amount + estimatedCost > DAILY_BUDGET) { throw new Error(`Daily budget exceeded: ${dailyCost.amount}/${DAILY_BUDGET}`); }
return true;}
async function recordCost(amount) { dailyCost.amount += amount;
// Alert at 80% threshold if (dailyCost.amount > DAILY_BUDGET * 0.8) { await alertFinance(`SMS budget at ${Math.round(dailyCost.amount / DAILY_BUDGET * 100)}%`); }}Monitor costs through the aggregator dashboard and set up alerts for unusual spending patterns.
Verification
Confirm the integration functions correctly:
| Test | Expected result |
|---|---|
| Send test SMS | Message delivered, status callback received |
| Receive inbound SMS | Webhook triggered, message logged |
| Complete USSD flow | Session navigates correctly, submission recorded |
| Opt-out handling | STOP keyword processed, future sends blocked |
| Platform integration | Submissions appear in data collection platform |
| Rate limiting | Bulk sends throttled to configured rate |
| Cost tracking | Daily spend accurately recorded |
Run an end-to-end test:
# 1. Send test messagecurl -X POST https://sms.example.org/api/v1/test/send \ -H "Content-Type: application/json" \ -d '{"to": "+254712345678", "message": "Integration test"}'
# 2. Check delivery status after 30 secondscurl https://sms.example.org/api/v1/messages/latest
# 3. Verify platform received submissioncurl -H "Authorization: Token $KOBO_TOKEN" \ "https://kf.kobotoolbox.org/api/v2/assets/$FORM_UID/submissions/?query={\"source\":\"ussd\"}"Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| SMS not delivered, status “Queued” | Carrier congestion or invalid number format | Verify number format includes country code (+254 not 0); retry after 5 minutes |
| Delivery status “Failed” with “InvalidPhoneNumber” | Number not in service or wrong carrier | Verify number is active; check if number has been ported |
| Delivery status “Failed” with “InsufficientBalance” | Aggregator account balance depleted | Top up account; configure low-balance alerts |
| Webhook not receiving callbacks | Incorrect URL, TLS error, or firewall blocking | Verify URL in aggregator dashboard; check certificate validity; confirm port 443 open |
| USSD session timeout before completion | Menu depth too deep or user delay | Reduce menu levels to 3 maximum; combine related inputs |
| USSD returns “Service not available” | Service code not approved or routing misconfigured | Verify carrier approval status; check service code mapping |
| Duplicate submissions received | Webhook called multiple times due to timeout | Implement idempotency using message ID; deduplicate in handler |
| Messages delayed by hours | Carrier congestion or routing issue | Contact aggregator support; check carrier status pages |
| High cost per message in specific region | Routing through expensive carrier | Request direct routing to target carrier; negotiate bulk rates |
| Inbound messages missing | Webhook endpoint down or returning errors | Check server logs; verify endpoint returns HTTP 200 |
| Character encoding issues | Non-ASCII characters mishandled | Ensure UTF-8 encoding throughout; test with local language characters |
| Opt-out not processed | Keyword parsing case-sensitive or whitespace issue | Normalise input: trim, uppercase, remove extra spaces |
| Platform submission fails | Authentication expired or API changed | Refresh API tokens; verify platform API version |
| Rate limit errors from aggregator | Exceeding carrier throughput limits | Reduce batch size; increase delay between messages |
| USSD menu text truncated | Exceeding 160-character limit per screen | Shorten menu text; use abbreviations; split across screens |
Security considerations
Webhook endpoints must validate incoming requests to prevent spoofing. Implement signature verification using the secret provided by your aggregator:
function validateWebhook(req, secret) { const signature = req.headers['x-signature']; const timestamp = req.headers['x-timestamp'];
// Reject old requests (replay protection) const age = Date.now() - parseInt(timestamp, 10); if (age > 300000) { // 5 minutes return false; }
const payload = timestamp + '.' + JSON.stringify(req.body); const expected = crypto .createHmac('sha256', secret) .update(payload) .digest('hex');
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) );}Store phone numbers as hashed values where full numbers are not required for operations. Log message IDs and timestamps rather than full message content for audit purposes.
See also
- Mobile Network Utilisation -for mobile connectivity concepts
- Accountability and Feedback Systems -for feedback system integration
- Data Collection Tools -for platform selection
- Low-Bandwidth Optimisation -for constrained network contexts