Kulcare Docs
General07 integrations

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 services

Message Flow

Application Event → SMS Service → Provider Selection → Message Delivery → Status Tracking
                                     ↓                        ↓
                                MSG91/Twilio              MsgLog Database

Core 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
end

MSG91 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
end

Twilio 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_number

Voice 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}"
end

Message 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
end

Verification 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
end

Promotional 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
end

Environment-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_prefix

Production 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)
end

Message 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
end

Response 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
end

Usage 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
  )
end

Scheduled 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

  1. Invalid Phone Numbers: Validation and formatting
  2. DLT Template Issues: Template ID validation
  3. Provider API Failures: Timeout and connection errors
  4. Rate Limiting: Provider quota management
  5. 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
end

Monitoring 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
  }
end

Configuration & 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]
end

Rate 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
end

Testing

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
end

Integration 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
end

Best 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

  1. DLT Template Errors: Verify template registration
  2. Number Format Issues: Ensure proper country code formatting
  3. Rate Limit Exceeded: Implement proper rate limiting
  4. 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)

On this page