SMS & Voice Services Integration
This document covers the SMS and voice service integrations in the Kulcare platform, including MSG91 for Indian numbers and Twilio for US/international numbers.
Overview
The Kulcare platform uses a multi-provider SMS strategy to ensure reliable message delivery across different regions:
- MSG91: Primary SMS provider for Indian numbers (+91)
- Twilio: SMS and voice provider for US (+1) and UAE (+971) numbers
- Intelligent Routing: Automatic provider selection based on destination number
- Delivery Tracking: Comprehensive message status tracking and analytics
- Template Management: DLT-compliant templates for Indian regulations
Architecture
SMS Service Structure
parts/core/services/sms/
├── base.rb # Main SMS service with intelligent routing
├── code/ # OTP and verification codes
│ ├── reset_password.rb
│ └── verification.rb
├── appointment/ # Appointment-related SMS
├── promotional/ # Marketing and promotional SMS
├── telemedicine/ # Telemedicine notifications
└── [various domain folders] # Domain-specific SMS servicesMessage Flow
Application Event → SMS Service → Provider Selection → Message Delivery → Status Tracking
↓ ↓
MSG91/Twilio MsgLog DatabaseCore Implementation
Base SMS Service
The Sms::Base class handles intelligent routing and message delivery:
# parts/core/services/sms/base.rb
class Sms::Base < ActiveJob::Base
queue_as :sms_messages
def perform(to_phone_number, message_text, dlt_id = nil, lang = nil, clinic_id = nil, msg_request_id = nil)
return if to_phone_number.blank?
# Environment-specific handling
return if dummy_number?(to_phone_number)
# Log message for tracking
msg_log = log_msg(to_phone_number, message_text, dlt_id, msg_request_id, clinic_id)
return unless msg_log
# Provider selection based on number
if us_or_uae_number?(to_phone_number)
send_via_twilio(to_phone_number, message_text)
else
send_via_msg91(to_phone_number, message_text, dlt_id, lang, msg_log)
end
end
private
def us_or_uae_number?(number)
number.start_with?('1', '+1', '971', '+971')
end
def send_via_twilio(to_phone_number, message_text)
from_phone_number = Rails.application.credentials.us_sender_phone_number
to_phone_number = "+#{to_phone_number}" unless to_phone_number.start_with?('+')
TwilioClient.messages.create(
body: message_text,
to: to_phone_number,
from: from_phone_number
)
end
def send_via_msg91(to_phone_number, message_text, dlt_id, lang, msg_log)
# MSG91 implementation for Indian numbers
# See detailed implementation below
end
endMSG91 Integration
Configuration
MSG91 requires specific configuration for Indian DLT compliance:
# Configuration in Rails credentials
Rails.application.credentials.msg91_auth_key # API authentication key
# Message parameters
params = {
sender: 'KLCARE', # Pre-approved sender ID
route: '4', # Transactional route
country: '91', # India country code
DLT_TE_ID: dlt_id, # DLT Template Entity ID
short_url: '1', # URL shortening enabled
unicode: 1, # For non-English messages
sms: [
{
message: message_text,
to: [phone_number]
}
]
}DLT Compliance
All SMS sent to Indian numbers must comply with TRAI DLT regulations:
# Template registration example
{
template_id: "1707160734083863936",
template_text: "Dear {#var#}, your appointment with Dr. {#var#} is confirmed for {#var#} at {#var#}. Clinic: {#var#}. Contact: {#var#}",
category: "service_explicit",
content_type: "text"
}Message Tracking
Each SMS is logged for delivery tracking:
def log_msg(to_phone_number, message_text, dlt_id, msg_request_id, clinic_id)
# Prevent duplicate messages within 10 seconds
duplicate_msg = MsgLog.where(
phone_number: to_phone_number,
msg_type: 'sms',
msg_text: message_text
).where('created_at > ?', 10.seconds.ago).exists?
return false if duplicate_msg
# Create log entry
if msg_request_id.present?
msg_request = MsgRequest.find_by(id: msg_request_id)
log = msg_request&.msg_logs&.create!
else
log = MsgLog.create!
end
log&.update!(
clinic_id: clinic_id,
phone_number: to_phone_number,
msg_type: 'sms',
msg_text: message_text,
unique_identifier: dlt_id,
msg_status: 'SENT'
)
log
endTwilio Integration
Configuration
Twilio handles US and international numbers:
# Twilio client initialization
TwilioClient = Twilio::REST::Client.new(
Rails.application.credentials.twilio_account_sid,
Rails.application.credentials.twilio_auth_token
)
# Phone number configuration
from_number = Rails.application.credentials.us_sender_phone_numberVoice Services
Twilio also provides voice calling capabilities:
# Voice call example
call = TwilioClient.calls.create(
url: 'http://demo.twilio.com/docs/voice.xml', # TwiML URL
to: '+15558675309',
from: '+15017122661'
)SMS Delivery
def send_via_twilio(to_phone_number, message_text)
message = TwilioClient.messages.create(
body: message_text,
to: to_phone_number,
from: Rails.application.credentials.us_sender_phone_number
)
# Twilio provides immediate delivery status
Rails.logger.info "Twilio message SID: #{message.sid}, Status: #{message.status}"
endMessage Templates
Template Categories
The system supports various message templates organized by functionality:
Appointment Messages
# parts/core/services/sms/appointment/booking_confirmation.rb
class Sms::Appointment::BookingConfirmation < Sms::Base
def perform(appointment_id)
appointment = Appointment.find(appointment_id)
message = "Dear #{appointment.patient_name}, your appointment with " \
"Dr. #{appointment.doctor.name} is confirmed for " \
"#{appointment.scheduled_at.strftime('%d %b %Y at %I:%M %p')}."
super(
appointment.phone_number,
message,
dlt_template_id('appointment_confirmation'),
'en',
appointment.clinic_id
)
end
endVerification Codes
# parts/core/services/sms/code/verification.rb
class Sms::Code::Verification < Sms::Base
def perform(phone_number, code, user_name = nil)
message = "Dear #{user_name || 'User'}, your Kulcare verification code is: #{code}. " \
"This code is valid for 10 minutes."
super(
phone_number,
message,
dlt_template_id('verification_code'),
'en'
)
end
endPromotional Messages
# parts/core/services/sms/promotional/custom_sms.rb
class Sms::Promotional::CustomSms < Sms::Base
def perform(phone_number, custom_message, clinic_id)
super(
phone_number,
custom_message,
dlt_template_id('promotional'),
'en',
clinic_id
)
end
endEnvironment-Specific Behavior
Development & Staging
# Non-production environment handling
if Rails.env.staging? || Rails.env.development?
# Send Slack notification instead of SMS
SlackKulcareSms.perform_now(to_phone_number, message_text)
# Use test phone numbers for specific prefixes
if to_phone_number[0] == '5'
test_phone_number = Rails.application.credentials.test_phone_number
to_phone_number = test_phone_number if test_phone_number.present?
end
end
# Skip dummy patient numbers
return if sliced_phone_number(to_phone_number)[0..3] == Rails.application.credentials.dummy_patient_prefixProduction Safeguards
# Prevent SMS to specific number ranges in non-production
if !Rails.env.production? && us_or_uae_number?(to_phone_number)
# Skip numbers starting with 4 or 5 in position 2/3
return if number_in_skip_range?(to_phone_number)
endMessage Status Tracking
Status Types
class MsgLog < ApplicationRecord
# Status constants
STATUS_SENT = 'SENT'
STATUS_DELIVERED = 'DELIVERED'
STATUS_FAILED = 'FAILED'
STATUS_REJECTED = 'REJECTED'
# Columns for tracking
# phone_number: string
# msg_type: string ('sms', 'whatsapp')
# msg_text: text
# msg_status: string
# unique_identifier: string (DLT ID)
# sms_request_id: string (Provider message ID)
# failure_reason: text
# clinic_id: integer
# created_at: datetime
endResponse Handling
def handle_msg91_response(response, msg_log)
return if msg_log.blank?
if response.success?
response_body = JSON.parse(response.body)
status = response_body['type']
sms_id = status == 'success' ? response_body['message'] : nil
msg_log.update!(
sms_request_id: sms_id,
msg_status: status == 'success' ? 'SENT' : 'FAILED'
)
else
msg_log.update!(
msg_status: 'FAILED',
failure_reason: response.body
)
end
endUsage Examples
Basic SMS Sending
# Send verification code
Sms::Code::Verification.perform_later(
'919876543210',
'12345',
'Dr. John Smith'
)
# Send appointment confirmation
Sms::Appointment::BookingConfirmation.perform_later(appointment.id)
# Send promotional message
Sms::Promotional::CustomSms.perform_later(
'919876543210',
'Special offer: 20% off consultations this week!',
clinic.id
)Bulk SMS Campaign
# Send bulk promotional SMS
patients = Clinic.find(clinic_id).patients.active
patients.find_each do |patient|
Sms::Promotional::CustomSms.set(wait: rand(60).seconds).perform_later(
patient.phone_number,
promotional_message,
clinic_id
)
endScheduled SMS Reminders
# Schedule appointment reminder
Sms::Appointment::FollowUpReminder.set(
wait_until: appointment.scheduled_at - 1.hour
).perform_later(appointment.id)Error Handling & Monitoring
Error Scenarios
- Invalid Phone Numbers: Validation and formatting
- DLT Template Issues: Template ID validation
- Provider API Failures: Timeout and connection errors
- Rate Limiting: Provider quota management
- Delivery Failures: Network and carrier issues
Error Logging
begin
response = HTTParty.post(api_endpoint, options)
rescue StandardError => e
msg_log&.update!(
msg_status: 'FAILED',
failure_reason: e.message
)
Rails.logger.error "SMS API Error: #{e.message}"
Sentry.capture_exception(e) if defined?(Sentry)
return nil
endMonitoring Metrics
Track key SMS metrics:
def sms_health_metrics
{
total_sms_today: MsgLog.where('created_at > ?', 1.day.ago).count,
delivery_rate: calculate_delivery_rate,
failed_messages: MsgLog.where(msg_status: 'FAILED').where('created_at > ?', 1.day.ago).count,
msg91_success_rate: provider_success_rate('msg91'),
twilio_success_rate: provider_success_rate('twilio'),
avg_response_time: calculate_avg_response_time
}
endConfiguration & Deployment
Required Credentials
# Rails credentials structure
production:
msg91_auth_key: "your_msg91_key"
twilio_account_sid: "your_twilio_sid"
twilio_auth_token: "your_twilio_token"
us_sender_phone_number: "+1234567890"
test_phone_number: "9876543210"
dummy_patient_prefix: "9999"DLT Template Management
# Template ID mapping
DLT_TEMPLATES = {
'verification_code' => '1707160734083863936',
'appointment_confirmation' => '1707160734083863937',
'appointment_reminder' => '1707160734083863938',
'promotional' => '1707160734083863939'
}.freeze
def dlt_template_id(template_type)
DLT_TEMPLATES[template_type]
endRate Limiting
Implement rate limiting to comply with provider limits:
class SmsRateLimiter
def self.can_send?(phone_number)
# Check if we can send SMS to this number
recent_count = MsgLog.where(
phone_number: phone_number
).where('created_at > ?', 1.hour.ago).count
recent_count < 5 # Max 5 SMS per hour per number
end
endTesting
Test Helpers
# spec/support/sms_helpers.rb
module SmsHelpers
def mock_msg91_success
stub_request(:post, "https://api.msg91.com/api/v2/sendsms")
.to_return(
status: 200,
body: { type: 'success', message: 'SMS_ID_123' }.to_json
)
end
def mock_twilio_success
allow(TwilioClient.messages).to receive(:create).and_return(
OpenStruct.new(sid: 'SM123', status: 'queued')
)
end
endIntegration Tests
RSpec.describe Sms::Base do
include SmsHelpers
describe '#perform' do
context 'with Indian number' do
it 'sends via MSG91' do
mock_msg91_success
described_class.new.perform(
'919876543210',
'Test message',
'template_123'
)
expect(MsgLog.last.msg_status).to eq('SENT')
end
end
context 'with US number' do
it 'sends via Twilio' do
mock_twilio_success
described_class.new.perform(
'+15551234567',
'Test message'
)
expect(TwilioClient.messages).to have_received(:create)
end
end
end
endBest Practices
Message Content
- Keep messages concise and actionable
- Include clear call-to-action when needed
- Use consistent branding and tone
- Comply with local regulations (DLT, TCPA)
Delivery Optimization
- Use appropriate message timing
- Implement retry logic for failures
- Monitor delivery rates by provider
- A/B test message templates
Security & Privacy
- Never log sensitive information in messages
- Implement opt-out mechanisms
- Comply with data protection regulations
- Secure credential storage
Troubleshooting
Common Issues
- DLT Template Errors: Verify template registration
- Number Format Issues: Ensure proper country code formatting
- Rate Limit Exceeded: Implement proper rate limiting
- Delivery Failures: Check provider status and account balance
Debug Commands
# Check recent message logs
MsgLog.where('created_at > ?', 1.hour.ago).order(:created_at)
# Test SMS sending manually
Sms::Base.perform_now('919876543210', 'Test message')
# Check provider response times
MsgLog.where(msg_status: 'FAILED').limit(10)Related Documentation
- WhatsApp Integration - WhatsApp messaging setup
- Push Notifications - Mobile push notification system
- Background Jobs - Sidekiq configuration
- Error Tracking - Error monitoring and alerting