Building Real-Time SMS Chat with Azure Functions, Twilio, and SignalR
For a CRM product, we needed to enable sales teams to have SMS conversations with customers directly in the app. The challenge: build a real-time, scalable chat system that integrates with SMS providers while maintaining a great user experience.
We built a serverless microservice using Azure Functions, Twilio for SMS, and SignalR for real-time web communication. Here’s the complete architecture and implementation.
Requirements
- Send and receive SMS via Twilio
- Real-time updates in web app (no refresh needed)
- Message history stored and searchable
- Multi-user support (multiple team members viewing same conversation)
- Scalable (handle hundreds of concurrent conversations)
- Cost-effective (serverless, pay per use)
Architecture
┌─────────────────────────────────────────────────┐
│ Web Client │
│ (React + SignalR Client) │
└────────────┬───────────────────────┬─────────────┘
│ │
↓ ↓
┌────────────────┐ ┌──────────────┐
│ Azure SignalR │←─────│ Azure Func │
│ Service │ │ (SignalR) │
└────────────────┘ └──────────────┘
↑ ↑
│ │
└───────────┬───────────┘
│
┌────┴─────┐
│ Azure │
│ Functions│
└────┬─────┘
│
┌────────────────┼────────────────┐
↓ ↓ ↓
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Twilio │ │ Cosmos │ │ Azure │
│ API │ │ DB │ │ Storage │
└─────────┘ └──────────┘ └──────────┘
↑
│ Webhooks
│
[Customer's Phone]
Implementation
1. Azure Functions Setup
// functions/sendSms/index.ts
import { AzureFunction, Context, HttpRequest } from '@azure/functions';
import * as twilio from 'twilio';
import * as signalR from '@azure/functions';
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
const httpTrigger: AzureFunction = async (
context: Context,
req: HttpRequest
): Promise<void> => {
const { to, from, message, conversationId } = req.body;
try {
// Send SMS via Twilio
const sms = await twilioClient.messages.create({
to,
from,
body: message,
});
// Store in database
const messageDoc = {
id: sms.sid,
conversationId,
direction: 'outbound',
from,
to,
body: message,
status: sms.status,
createdAt: new Date(),
};
await context.bindings.cosmosDb.push(messageDoc);
// Broadcast to connected clients via SignalR
context.bindings.signalR = {
target: 'messageReceived',
arguments: [messageDoc],
};
context.res = {
status: 200,
body: { success: true, messageId: sms.sid },
};
} catch (error) {
context.log.error('Failed to send SMS:', error);
context.res = {
status: 500,
body: { error: error.message },
};
}
};
export default httpTrigger;2. Receiving SMS (Twilio Webhook)
// functions/receiveSms/index.ts
import { AzureFunction, Context, HttpRequest } from '@azure/functions';
const httpTrigger: AzureFunction = async (
context: Context,
req: HttpRequest
): Promise<void> => {
// Twilio sends SMS data as form-urlencoded
const { From, To, Body, MessageSid } = req.body;
try {
// Find or create conversation
const conversationId = await findOrCreateConversation(From, To, context);
// Store message
const messageDoc = {
id: MessageSid,
conversationId,
direction: 'inbound',
from: From,
to: To,
body: Body,
status: 'received',
createdAt: new Date(),
};
await context.bindings.cosmosDb.push(messageDoc);
// Broadcast via SignalR to all connected clients
context.bindings.signalR = {
target: 'messageReceived',
arguments: [messageDoc],
};
// Respond to Twilio
context.res = {
status: 200,
headers: { 'Content-Type': 'text/xml' },
body: '<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
};
} catch (error) {
context.log.error('Failed to process incoming SMS:', error);
context.res = { status: 500 };
}
};
async function findOrCreateConversation(
customerPhone: string,
businessPhone: string,
context: Context
): Promise<string> {
// Query existing conversation
const existing = await context.bindings.cosmosDbInput
.find((c: any) =>
c.customerPhone === customerPhone && c.businessPhone === businessPhone
);
if (existing) return existing.id;
// Create new conversation
const conversation = {
id: generateId(),
customerPhone,
businessPhone,
createdAt: new Date(),
lastMessageAt: new Date(),
};
await context.bindings.conversationsDb.push(conversation);
return conversation.id;
}
export default httpTrigger;3. SignalR Connection (for Real-Time Updates)
// functions/negotiate/index.ts
import { AzureFunction, Context, HttpRequest } from '@azure/functions';
const httpTrigger: AzureFunction = async (
context: Context,
req: HttpRequest
): Promise<void> => {
// Azure SignalR Service binding automatically negotiates connection
context.res = {
body: context.bindings.signalRConnectionInfo,
};
};
export default httpTrigger;4. React Client with SignalR
// client/src/services/chatService.ts
import * as signalR from '@microsoft/signalr';
class ChatService {
private connection: signalR.HubConnection;
private messageHandlers: ((message: Message) => void)[] = [];
async connect() {
// Get connection info from Azure Function
const connectionInfo = await fetch('/api/negotiate').then(r => r.json());
// Create SignalR connection
this.connection = new signalR.HubConnectionBuilder()
.withUrl(connectionInfo.url, {
accessTokenFactory: () => connectionInfo.accessToken,
})
.withAutomaticReconnect()
.build();
// Listen for messages
this.connection.on('messageReceived', (message: Message) => {
this.messageHandlers.forEach(handler => handler(message));
});
await this.connection.start();
console.log('SignalR connected');
}
onMessage(handler: (message: Message) => void) {
this.messageHandlers.push(handler);
}
async sendMessage(to: string, from: string, message: string, conversationId: string) {
await fetch('/api/sendSms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to, from, message, conversationId }),
});
}
async disconnect() {
await this.connection.stop();
}
}
export const chatService = new ChatService();5. React Chat Component
// client/src/components/SmsChat.tsx
import React, { useEffect, useState } from 'react';
import { chatService } from '../services/chatService';
interface Message {
id: string;
direction: 'inbound' | 'outbound';
body: string;
createdAt: string;
}
export function SmsChat({ conversationId }: { conversationId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
useEffect(() => {
// Connect to SignalR
chatService.connect();
// Load message history
loadMessages();
// Listen for new messages
chatService.onMessage((message) => {
if (message.conversationId === conversationId) {
setMessages(prev => [...prev, message]);
}
});
return () => chatService.disconnect();
}, [conversationId]);
async function loadMessages() {
const response = await fetch(`/api/messages/${conversationId}`);
const data = await response.json();
setMessages(data);
}
async function handleSend() {
if (!newMessage.trim()) return;
await chatService.sendMessage(
conversation.customerPhone,
conversation.businessPhone,
newMessage,
conversationId
);
setNewMessage('');
}
return (
<div className="chat-container">
<div className="messages">
{messages.map(msg => (
<div
key={msg.id}
className={`message ${msg.direction}`}
>
<div className="message-body">{msg.body}</div>
<div className="message-time">
{new Date(msg.createdAt).toLocaleTimeString()}
</div>
</div>
))}
</div>
<div className="input-area">
<input
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleSend()}
placeholder="Type a message..."
/>
<button onClick={handleSend}>Send</button>
</div>
</div>
);
}Production Considerations
Error Handling
// Retry failed messages
interface QueuedMessage {
to: string;
from: string;
message: string;
retryCount: number;
}
async function sendWithRetry(message: QueuedMessage) {
try {
await twilioClient.messages.create({
to: message.to,
from: message.from,
body: message.message,
});
} catch (error) {
if (message.retryCount < 3) {
// Add back to queue with exponential backoff
await queueStorage.enqueue({
...message,
retryCount: message.retryCount + 1,
}, {
visibilityTimeout: Math.pow(2, message.retryCount) * 60, // 1min, 2min, 4min
});
} else {
// Move to dead letter queue
await deadLetterQueue.enqueue(message);
}
}
}Rate Limiting
// Prevent spam
const rateLimiter = new Map<string, number>();
function canSendMessage(conversationId: string): boolean {
const now = Date.now();
const lastSent = rateLimiter.get(conversationId) || 0;
// Max 10 messages per minute
if (now - lastSent < 6000) {
return false;
}
rateLimiter.set(conversationId, now);
return true;
}Results
- Response time: <500ms for message delivery
- Scalability: Handled 500+ concurrent conversations
- Reliability: 99.8% message delivery rate
- Cost: $0.02 per conversation (Azure Functions + Twilio)
- User satisfaction: 4.6/5 rating
Key Learnings
- SignalR Service is essential: Don’t try to manage WebSocket connections yourself
- Cosmos DB works well: Serverless, scales automatically, good for chat data
- Twilio webhooks are reliable: But implement retry logic anyway
- Monitor everything: Set up Application Insights for Azure Functions
Building real-time features in your application? I’d love to discuss architecture approaches. Connect on LinkedIn.