Skip to main content

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:

RequirementDetail
Provider accountActive account with SMS/USSD aggregator (Africa’s Talking, Twilio, Infobip, or regional provider)
Number or shortcodeDedicated long number or approved shortcode for the target country
Platform accessAdministrative access to data collection platform (KoboToolbox, ODK Central, CommCare)
Server environmentLinux server or cloud function capability for webhook endpoints
TLS certificateValid certificate for HTTPS webhook endpoints
Budget approvalCost estimate approved based on expected message volume
Network assessmentCarrier coverage verified in target areas

Confirm your aggregator account has API credentials:

Terminal window
# 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

  1. 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
  1. Generate API credentials under Settings > API Key. Store the key securely:
Terminal window
# Store in environment variable (do not commit to version control)
export AT_API_KEY="atsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export AT_USERNAME="fielddata"
  1. 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.

  2. 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/status

Both endpoints must be HTTPS with a valid certificate.

  1. 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/callback

USSD service codes require carrier approval. Submit the application with your menu structure and expected session volume.

Webhook server deployment

  1. Create the webhook server. This example uses Node.js with Express:
server.js
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}`);
});
  1. Deploy the server with process management:
Terminal window
# 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
  1. Configure nginx as a reverse proxy with TLS:
/etc/nginx/sites-available/sms-webhook
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;
}
}
  1. Enable the site and reload nginx:
Terminal window
sudo ln -s /etc/nginx/sites-available/sms-webhook /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
  1. Test the webhook endpoint:
Terminal window
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

  1. Create the SMS sending module:
sms-sender.js
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 };
  1. 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"
}
]
}
}
  1. Implement rate limiting to avoid carrier throttling:
rate-limiter.js
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 };
  1. 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

  1. Extend the webhook handler to parse and route inbound messages:
inbound-handler.js
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.'
);
}
  1. Implement opt-out list checking before sending:
opt-out.js
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);
}
  1. 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

  1. Implement the USSD callback handler:
ussd-handler.js
// 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.';
}
}
  1. 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.';
}
}
  1. 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.

  1. For KoboToolbox, use the submission API to create records:
kobo-integration.js
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();
}
  1. For ODK Central, use the submissions endpoint:
odk-integration.js
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>`;
}
  1. Configure outbound notifications triggered by platform events:
notification-handler.js
// 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:

cost-control.js
const DAILY_BUDGET = 5000; // KES
const 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:

TestExpected result
Send test SMSMessage delivered, status callback received
Receive inbound SMSWebhook triggered, message logged
Complete USSD flowSession navigates correctly, submission recorded
Opt-out handlingSTOP keyword processed, future sends blocked
Platform integrationSubmissions appear in data collection platform
Rate limitingBulk sends throttled to configured rate
Cost trackingDaily spend accurately recorded

Run an end-to-end test:

Terminal window
# 1. Send test message
curl -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 seconds
curl https://sms.example.org/api/v1/messages/latest
# 3. Verify platform received submission
curl -H "Authorization: Token $KOBO_TOKEN" \
"https://kf.kobotoolbox.org/api/v2/assets/$FORM_UID/submissions/?query={\"source\":\"ussd\"}"

Troubleshooting

SymptomCauseResolution
SMS not delivered, status “Queued”Carrier congestion or invalid number formatVerify number format includes country code (+254 not 0); retry after 5 minutes
Delivery status “Failed” with “InvalidPhoneNumber”Number not in service or wrong carrierVerify number is active; check if number has been ported
Delivery status “Failed” with “InsufficientBalance”Aggregator account balance depletedTop up account; configure low-balance alerts
Webhook not receiving callbacksIncorrect URL, TLS error, or firewall blockingVerify URL in aggregator dashboard; check certificate validity; confirm port 443 open
USSD session timeout before completionMenu depth too deep or user delayReduce menu levels to 3 maximum; combine related inputs
USSD returns “Service not available”Service code not approved or routing misconfiguredVerify carrier approval status; check service code mapping
Duplicate submissions receivedWebhook called multiple times due to timeoutImplement idempotency using message ID; deduplicate in handler
Messages delayed by hoursCarrier congestion or routing issueContact aggregator support; check carrier status pages
High cost per message in specific regionRouting through expensive carrierRequest direct routing to target carrier; negotiate bulk rates
Inbound messages missingWebhook endpoint down or returning errorsCheck server logs; verify endpoint returns HTTP 200
Character encoding issuesNon-ASCII characters mishandledEnsure UTF-8 encoding throughout; test with local language characters
Opt-out not processedKeyword parsing case-sensitive or whitespace issueNormalise input: trim, uppercase, remove extra spaces
Platform submission failsAuthentication expired or API changedRefresh API tokens; verify platform API version
Rate limit errors from aggregatorExceeding carrier throughput limitsReduce batch size; increase delay between messages
USSD menu text truncatedExceeding 160-character limit per screenShorten 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