Skip to main content

Interactive SMS with Custom UI

Build interactive SMS workflows with two-way customer communication, status tracking, and a simple web UI for managing conversations.

Tags: SMS, Integrations

What You'll Build

An interactive SMS system that:

  • Sends SMS messages using the Exotel SMS API with DLT compliance
  • Tracks delivery status via status callbacks
  • Receives and processes incoming SMS replies via webhooks
  • Provides a simple web interface for managing SMS conversations
┌────────────────────────────────────────────────────────────┐
│ SMS Conversations │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ +919876543210 — Ravi Kumar 2 msgs │ │
│ │ Last: "Yes, I confirm my appointment" 2m ago │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ +919876543211 — Priya Sharma 3 msgs │ │
│ │ Last: "Can you reschedule to 4 PM?" 15m ago │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ +919876543212 — Amit Patel 1 msg │ │
│ │ Last: Delivered (no reply) 1h ago │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ [Send New Message] [Export Conversations] │
└────────────────────────────────────────────────────────────┘

Prerequisites

  • An Exotel account (Sign up here)
  • API credentials (API Key, API Token, Account SID) — see Authentication
  • An ExoPhone or approved Sender ID
  • Node.js 18+ or Python 3.7+
  • A publicly accessible URL for receiving webhooks
  • For India: DLT Entity ID and registered templates (see DLT Compliance)

Step 1: Send SMS Messages

Use the Exotel SMS API to send messages to customers. The API supports transactional, promotional, and OTP message types.

const API_KEY = process.env.EXOTEL_API_KEY;
const API_TOKEN = process.env.EXOTEL_API_TOKEN;
const ACCOUNT_SID = process.env.EXOTEL_ACCOUNT_SID;
const SUBDOMAIN = process.env.EXOTEL_SUBDOMAIN || 'api.exotel.com';

const authHeader = 'Basic ' + Buffer.from(API_KEY + ':' + API_TOKEN).toString('base64');

async function sendSms({ to, body, from, dltEntityId, dltTemplateId, callbackUrl }) {
const url = `https://${SUBDOMAIN}/v1/Accounts/${ACCOUNT_SID}/Sms/send`;

const params = new URLSearchParams({
From: from || process.env.EXOTEL_SENDER_ID,
To: to,
Body: body,
});

// DLT parameters (mandatory for India)
if (dltEntityId) params.set('DltEntityId', dltEntityId);
if (dltTemplateId) params.set('DltTemplateId', dltTemplateId);

// Status callback URL
if (callbackUrl) params.set('StatusCallback', callbackUrl);

const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`SMS send failed: ${response.status} - ${errorText}`);
}

const data = await response.json();
return {
sid: data.SMSMessage?.Sid,
status: data.SMSMessage?.Status,
to: data.SMSMessage?.To,
dateCreated: data.SMSMessage?.DateCreated,
};
}

// Example: Send an appointment confirmation
const result = await sendSms({
to: '+919876543210',
body: 'Your appointment is confirmed for Jan 20 at 3 PM. Reply YES to confirm or NO to cancel.',
from: 'EXOTEL',
dltEntityId: '1234567890123456789',
dltTemplateId: '9876543210987654321',
callbackUrl: 'https://your-server.com/webhook/sms-status',
});

console.log(`SMS sent: ${result.sid}`);

Step 2: Set Up SMS Status Callbacks

Track delivery status in real-time by configuring a status callback URL. Exotel sends a POST request whenever the SMS status changes.

import express from 'express';

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// In-memory conversation store (use a database in production)
const conversations = new Map();

function getOrCreateConversation(phoneNumber) {
if (!conversations.has(phoneNumber)) {
conversations.set(phoneNumber, {
phoneNumber,
messages: [],
lastActivity: null,
});
}
return conversations.get(phoneNumber);
}

// SMS status callback webhook
app.post('/webhook/sms-status', (req, res) => {
const { SmsSid, To, Status, DetailedStatus, SmsType } = req.body;

console.log(`[SMS Status] ${SmsSid} to ${To}: ${Status} (${DetailedStatus})`);

// Update the message status in the conversation
const convo = getOrCreateConversation(To);
const message = convo.messages.find((m) => m.sid === SmsSid);

if (message) {
message.status = Status;
message.detailedStatus = DetailedStatus;
message.updatedAt = new Date().toISOString();
}

convo.lastActivity = new Date().toISOString();

res.status(200).json({ received: true });
});

// Send an outbound SMS and track it
app.post('/api/sms/send', async (req, res) => {
try {
const { to, body, dltEntityId, dltTemplateId } = req.body;

const result = await sendSms({
to,
body,
dltEntityId,
dltTemplateId,
callbackUrl: 'https://your-server.com/webhook/sms-status',
});

// Store the outbound message in the conversation
const convo = getOrCreateConversation(to);
convo.messages.push({
sid: result.sid,
direction: 'outbound',
body,
status: result.status,
timestamp: new Date().toISOString(),
});
convo.lastActivity = new Date().toISOString();

res.json({ success: true, sid: result.sid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

SMS Status Values

StatusDescription
queuedMessage accepted and queued for delivery
sendingMessage is being sent to the carrier
submittedMessage submitted to the carrier
sentCarrier accepted the message
deliveredMessage delivered to the recipient
failedMessage delivery failed
failed-dndBlocked due to DND (Do Not Disturb)

Step 3: Handle Incoming SMS Replies

Configure a webhook to receive incoming SMS replies from customers. Set this up in your Exotel Dashboard under the ExoPhone settings.

// Incoming SMS webhook receiver
app.post('/webhook/sms-incoming', (req, res) => {
const { From, To, Body, SmsSid, DateCreated } = req.body;

console.log(`[Incoming SMS] From: ${From}, Body: "${Body}"`);

// Store the incoming message in the conversation
const convo = getOrCreateConversation(From);
convo.messages.push({
sid: SmsSid,
direction: 'inbound',
body: Body,
from: From,
to: To,
status: 'received',
timestamp: DateCreated || new Date().toISOString(),
});
convo.lastActivity = new Date().toISOString();

// Process the reply (simple keyword matching)
const replyText = Body.trim().toUpperCase();

if (replyText === 'YES' || replyText === 'CONFIRM') {
handleConfirmation(From);
} else if (replyText === 'NO' || replyText === 'CANCEL') {
handleCancellation(From);
} else if (replyText === 'HELP') {
handleHelpRequest(From);
} else {
// Store as a general message for manual review
console.log(`[Unrecognized reply] From ${From}: "${Body}"`);
}

res.status(200).json({ received: true });
});

function handleConfirmation(phoneNumber) {
console.log(`[Confirmed] ${phoneNumber} confirmed their appointment`);
// Update your database, send confirmation SMS, etc.
}

function handleCancellation(phoneNumber) {
console.log(`[Cancelled] ${phoneNumber} cancelled their appointment`);
// Update your database, trigger rebooking flow, etc.
}

function handleHelpRequest(phoneNumber) {
console.log(`[Help] ${phoneNumber} requested help`);
// Send help SMS or route to agent
}
info

To receive incoming SMS, configure the SMS URL on your ExoPhone in the Exotel Dashboard. Navigate to ExoPhones > select your number > SMS Settings > set the incoming SMS webhook URL.

Step 4: Build the Conversation UI API

Expose API endpoints that a frontend application can consume to display and manage SMS conversations.

// List all conversations
app.get('/api/conversations', (req, res) => {
const convos = Array.from(conversations.values())
.map((convo) => ({
phoneNumber: convo.phoneNumber,
messageCount: convo.messages.length,
lastMessage: convo.messages[convo.messages.length - 1] || null,
lastActivity: convo.lastActivity,
hasUnread: convo.messages.some(
(m) => m.direction === 'inbound' && !m.read,
),
}))
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));

res.json({ conversations: convos, total: convos.length });
});

// Get a single conversation with full message history
app.get('/api/conversations/:phoneNumber', (req, res) => {
const { phoneNumber } = req.params;
const convo = conversations.get(phoneNumber);

if (!convo) {
return res.status(404).json({ error: 'Conversation not found' });
}

// Mark inbound messages as read
convo.messages.forEach((m) => {
if (m.direction === 'inbound') m.read = true;
});

res.json(convo);
});

// Send a reply within a conversation
app.post('/api/conversations/:phoneNumber/reply', async (req, res) => {
try {
const { phoneNumber } = req.params;
const { body, dltEntityId, dltTemplateId } = req.body;

const result = await sendSms({
to: phoneNumber,
body,
dltEntityId,
dltTemplateId,
callbackUrl: 'https://your-server.com/webhook/sms-status',
});

const convo = getOrCreateConversation(phoneNumber);
convo.messages.push({
sid: result.sid,
direction: 'outbound',
body,
status: result.status,
timestamp: new Date().toISOString(),
});
convo.lastActivity = new Date().toISOString();

res.json({ success: true, sid: result.sid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

// Bulk send to multiple recipients
app.post('/api/sms/bulk', async (req, res) => {
const { recipients, body, dltEntityId, dltTemplateId } = req.body;

const results = [];
for (const to of recipients) {
try {
const result = await sendSms({
to,
body,
dltEntityId,
dltTemplateId,
callbackUrl: 'https://your-server.com/webhook/sms-status',
});
results.push({ to, success: true, sid: result.sid });
} catch (error) {
results.push({ to, success: false, error: error.message });
}
}

res.json({
total: recipients.length,
sent: results.filter((r) => r.success).length,
failed: results.filter((r) => !r.success).length,
results,
});
});

app.listen(3000, () => console.log('SMS service running on :3000'));

DLT Compliance for India

If you are sending SMS to Indian phone numbers, DLT (Distributed Ledger Technology) registration is mandatory as per TRAI regulations.

What You Need

RequirementDescription
DLT Entity IDYour registered entity ID from a DLT portal
DLT Template IDApproved template ID for each message format
Sender IDApproved header (e.g., "EXOTEL") registered with DLT

Registration Steps

  1. Register on a DLT portal: Jio, Airtel, Vodafone-Idea, or BSNL
  2. Get your Entity ID approved
  3. Register your Sender ID (header)
  4. Submit message templates and get template IDs approved
  5. Pass DltEntityId and DltTemplateId in every SMS API request
warning

Without DLT registration, SMS to Indian numbers will be blocked by telecom operators. Ensure all your message templates are pre-approved before sending.

Template Example

DLT Template: "Your appointment is confirmed for {#var#} at {#var#}. Reply YES to confirm or NO to cancel."
Template ID: 9876543210987654321

When sending, the Body must match the registered template format with variable values filled in.

API Endpoints Summary

MethodEndpointDescription
POST/api/sms/sendSend a single SMS
POST/api/sms/bulkSend SMS to multiple recipients
GET/api/conversationsList all conversations
GET/api/conversations/:phoneGet full conversation history
POST/api/conversations/:phone/replyReply within a conversation
POST/webhook/sms-statusSMS delivery status callback
POST/webhook/sms-incomingIncoming SMS webhook

Next Steps