Kulcare Docs
General07 integrations

WhatsApp Integration

This document covers the WATI (WhatsApp Business API) integration for WhatsApp messaging with comprehensive delivery tracking and status monitoring.

Overview

The Kulcare platform uses WATI (WhatsApp Team Inbox) for WhatsApp business messaging with advanced features:

  • Template Messaging: DLT-compliant WhatsApp templates for India
  • Delivery Tracking: Probabilistic and direct message tracking via webhooks
  • Multi-Modal Communication: WhatsApp + SMS fallback strategy
  • Business API Integration: Full WhatsApp Business API capabilities
  • Message Analytics: Comprehensive delivery and engagement tracking

Based on the implementation plan in docs/old_docs/wati_message_tracking_implementation_plan.md.

Architecture

WhatsApp Service Structure

parts/core/services/whats_app/
├── base.rb                           # Main WhatsApp service
├── appointment_reminder/             # Appointment-related messages
├── telemedicine_reminder/           # Telemedicine notifications  
├── promotional/                     # Marketing messages
├── notify_doctor_appt/              # Doctor notifications
└── [various domain folders]         # Domain-specific WhatsApp services

Message Tracking Flow

WhatsApp Send → WATI V2 API → Template Message → Webhook Events → Status Updates
     ↓              ↓              ↓                ↓              ↓
  MsgLog        localMessageId   whatsappMessageId  Event Handler  Database

Core Implementation

Base WhatsApp Service

# parts/core/services/whats_app/base.rb
class WhatsApp::Base < ActiveJob::Base
  queue_as :whatsapp_messages

  def perform(phone_number, template_name, parameters = {}, clinic_id = nil, msg_request_id = nil)
    return if phone_number.blank? || template_name.blank?
    
    # Create message log for tracking
    msg_log = log_msg(phone_number, template_name, parameters, clinic_id, msg_request_id)
    return unless msg_log
    
    # Send via WATI V2 API
    response = send_template_message(phone_number, template_name, parameters)
    
    # Process API response
    process_api_response(response, msg_log)
  end
  
  private
  
  def send_template_message(phone_number, template_name, parameters)
    payload = {
      template_name: template_name,
      broadcast_name: "kulcare_#{template_name}",
      parameters: format_parameters(parameters)
    }
    
    headers = {
      'Authorization' => "Bearer #{wati_api_token}",
      'Content-Type' => 'application/json'
    }
    
    HTTParty.post(
      wati_api_endpoint,
      body: payload.to_json,
      headers: headers,
      timeout: 30
    )
  end
  
  def process_api_response(response, msg_log)
    if response.success?
      response_data = JSON.parse(response.body)
      
      if response_data['result'] && response_data['receivers']&.any?
        receiver = response_data['receivers'].first
        
        msg_log.update!(
          msg_status: MsgLog::STATUS_SENT,
          wati_local_message_id: receiver['localMessageId'],
          unique_identifier: response_data['templateName']
        )
      else
        msg_log.update!(
          msg_status: MsgLog::STATUS_FAILED,
          failure_reason: 'WATI API returned unsuccessful result'
        )
      end
    else
      msg_log.update!(
        msg_status: MsgLog::STATUS_FAILED,
        failure_reason: response.body
      )
    end
  end
end

WATI API Integration

API Configuration

# Configuration structure
def wati_api_token
  Rails.application.credentials.wati.api_token
end

def wati_api_endpoint
  "#{Rails.application.credentials.wati.base_url}/api/v1/sendTemplateMessage"
end

def wati_webhook_secret
  Rails.application.credentials.wati.webhook_secret
end

V2 API Request Format

# WATI V2 API request payload
{
  "template_name": "appointment_reminder",
  "broadcast_name": "kulcare_appointment_reminder",
  "parameters": [
    {
      "name": "patient_name",
      "value": "John Doe"
    },
    {
      "name": "doctor_name", 
      "value": "Dr. Smith"
    },
    {
      "name": "appointment_time",
      "value": "2024-01-15 10:30 AM"
    }
  ]
}

API Response Structure

{
  "result": true,
  "templateName": "appointment_reminder",
  "receivers": [
    {
      "localMessageId": "5fcd8a2c-5d72-4962-8768-d896d7757f39",
      "waId": "9888873165",
      "isValidWhatsAppNumber": true
    }
  ]
}

Message Tracking System

Database Schema

Enhanced MsgLog Model

# Migration: add_wati_tracking_fields_to_msg_logs.rb
add_column :msg_logs, :wati_local_message_id, :string  # V2 API localMessageId
add_column :msg_logs, :wati_message_id, :string        # whatsappMessageId from webhooks
add_index :msg_logs, :wati_local_message_id
add_index :msg_logs, :wati_message_id
add_index :msg_logs, [:phone_number, :created_at]      # For probabilistic matching
add_index :msg_logs, [:unique_identifier, :phone_number]

WatiWebhook Model

# Migration: add_msg_log_reference_to_wati_webhooks.rb  
add_reference :wati_webhooks, :msg_log, foreign_key: true, null: true
add_index :wati_webhooks, :msg_log_id

# Enhanced WatiWebhook model
class WatiWebhook < ApplicationRecord
  belongs_to :msg_log, optional: true
  
  # Columns:
  # path: string              - Webhook endpoint path
  # payload: jsonb           - Complete webhook payload  
  # request_id: string       - Associated request ID
  # msg_log_id: integer      - Linked message log
  # processed: boolean       - Processing status
  
  def delivery_status_event?
    %w[templateMessageSent sentMessageDELIVERED templateMessageFailed].include?(event_type)
  end
  
  def event_type
    payload['eventType']
  end
  
  def whatsapp_message_id
    payload['whatsappMessageId']
  end
  
  def local_message_id
    payload['localMessageId']
  end
end

Webhook Processing

Webhook Controller

# parts/core/controllers/wati_webhooks_controller.rb
class WatiWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_webhook_signature
  
  def create
    webhook_data = params.except(:controller, :action)
    
    # Store webhook for processing
    webhook = WatiWebhook.create!(
      path: request.path,
      payload: webhook_data.to_unsafe_h
    )
    
    # Process delivery status events
    process_delivery_webhook(webhook) if webhook.delivery_status_event?
    
    # Send Slack notification for monitoring
    SlackWatiMsg.perform_later(webhook_data.to_json)
    
    head :ok
  end
  
  private
  
  def process_delivery_webhook(webhook)
    case webhook.event_type
    when 'templateMessageSent'
      handle_message_sent(webhook)
    when 'sentMessageDELIVERED'
      handle_message_delivered(webhook)
    when 'templateMessageFailed'
      handle_message_failed(webhook)
    end
  end
  
  def handle_message_sent(webhook)
    # Direct matching using whatsappMessageId
    msg_log = find_matching_msg_log_by_whatsapp_id(webhook)
    
    if msg_log
      msg_log.update!(
        wati_message_id: webhook.whatsapp_message_id,
        msg_status: MsgLog::STATUS_SENT
      )
      webhook.update!(msg_log: msg_log)
    else
      Rails.logger.warn "Unmatched templateMessageSent webhook: #{webhook.payload}"
    end
  end
  
  def handle_message_delivered(webhook)
    # Direct matching using stored wati_message_id
    msg_log = MsgLog.find_by(wati_message_id: webhook.whatsapp_message_id)
    
    if msg_log
      msg_log.update!(msg_status: MsgLog::STATUS_DELIVERED)
      webhook.update!(msg_log: msg_log)
    else
      Rails.logger.warn "Unmatched sentMessageDELIVERED webhook: #{webhook.payload}"
    end
  end
  
  def handle_message_failed(webhook)
    # Direct matching using localMessageId
    msg_log = MsgLog.find_by(wati_local_message_id: webhook.local_message_id)
    
    if msg_log
      failure_detail = "#{webhook.payload['failedCode']}: #{webhook.payload['failedDetail']}"
      msg_log.update!(
        msg_status: MsgLog::STATUS_FAILED,
        failure_reason: failure_detail,
        wati_message_id: webhook.whatsapp_message_id
      )
      webhook.update!(msg_log: msg_log)
    else
      Rails.logger.warn "Unmatched templateMessageFailed webhook: #{webhook.payload}"
    end
  end
  
  def find_matching_msg_log_by_whatsapp_id(webhook)
    # Probabilistic matching for templateMessageSent events
    phone_number = extract_phone_from_webhook(webhook)
    webhook_timestamp = extract_timestamp_from_webhook(webhook)
    
    return nil if phone_number.blank?
    
    # Find potential matches within time window (5 minutes)
    time_window = 5.minutes
    candidates = MsgLog.where(
      phone_number: phone_number,
      msg_type: 'whatsapp',
      msg_status: MsgLog::STATUS_SENT,
      created_at: (webhook_timestamp - time_window)..(webhook_timestamp + time_window)
    ).order(:created_at)
    
    # Apply template matching if available
    template_name = webhook.payload['templateName']
    if template_name.present?
      template_matches = candidates.where(unique_identifier: template_name)
      return template_matches.first if template_matches.any?
    end
    
    # Return closest match by timestamp
    candidates.first
  end
  
  def verify_webhook_signature
    # Implement WATI webhook signature verification if available
    # Currently WATI doesn't provide signature verification
    true
  end
end

Webhook Event Types

1. templateMessageSent

Triggered when WhatsApp confirms message was sent to their servers.

{
  "eventType": "templateMessageSent",
  "whatsappMessageId": "wamid.HBgMOAE4NjY4NDkzNjAxFAIAERgSOTEENzFCNjEwMkNDNENGQUJGAA==",
  "waId": "919888873165", 
  "templateName": "appointment_reminder",
  "statusString": "SENT",
  "timestamp": "1665645642"
}

2. sentMessageDELIVERED

Triggered when message is delivered to recipient's device.

{
  "eventType": "sentMessageDELIVERED",
  "whatsappMessageId": "wamid.HBgMOAE4NjY4NDkzNjAxFAIAERgSOTEENzFCNjEwMkNDNENGQUJGAA==",
  "statusString": "Delivered",
  "timestamp": "1665645702"
}

3. templateMessageFailed

Triggered when message delivery fails.

{
  "eventType": "templateMessageFailed", 
  "whatsappMessageId": "wamid.HBgMOAE4NjY4NDkzNjAxFAIAERgSOTEENzFCNjEwMkNDNENGQUJGAA==",
  "localMessageId": "5fcd8a2c-5d72-4962-8768-d896d7757f39",
  "failedCode": "131026",
  "failedDetail": "Message undeliverable",
  "statusString": "Failed"
}

Message Templates

Template Structure

WhatsApp templates must be pre-approved by WhatsApp/WATI:

# Template registration example
{
  template_name: "appointment_reminder",
  category: "UTILITY", 
  language: "en",
  components: [
    {
      type: "BODY",
      text: "Dear {{1}}, your appointment with Dr. {{2}} is scheduled for {{3}} at {{4}}. Please arrive 15 minutes early."
    }
  ]
}

Common Templates

Appointment Reminder

# parts/core/services/whats_app/appointment_reminder/future_appointment_reminder.rb
class WhatsApp::AppointmentReminder::FutureAppointmentReminder < WhatsApp::Base
  def perform(appointment_id)
    appointment = Appointment.find(appointment_id)
    
    parameters = {
      patient_name: appointment.patient_name,
      doctor_name: appointment.doctor.name,
      appointment_date: appointment.scheduled_at.strftime('%d %b %Y'),
      appointment_time: appointment.scheduled_at.strftime('%I:%M %p'),
      clinic_name: appointment.clinic.name
    }
    
    super(
      appointment.phone_number,
      'future_appointment_reminder',
      parameters,
      appointment.clinic_id
    )
  end
end

Telemedicine Invitation

# parts/core/services/whats_app/telemedicine_reminder/telemed_booked.rb
class WhatsApp::TelemedicineReminder::TelemedBooked < WhatsApp::Base
  def perform(telemedicine_order_id)
    order = TelemedicineOrder.find(telemedicine_order_id)
    
    parameters = {
      patient_name: order.patient_name,
      doctor_name: order.doctor.name,
      consultation_time: order.scheduled_at.strftime('%I:%M %p'),
      join_link: order.consultation_link
    }
    
    super(
      order.phone_number,
      'telemed_consultation_ready',
      parameters,
      order.clinic_id
    )
  end
end

Payment Notifications

# parts/core/services/whats_app/payments/payment_complete.rb  
class WhatsApp::Payments::PaymentComplete < WhatsApp::Base
  def perform(payment_id)
    payment = BillingPayment.find(payment_id)
    
    parameters = {
      patient_name: payment.customer.name,
      amount: payment.amount.to_s,
      payment_date: payment.created_at.strftime('%d %b %Y'),
      invoice_number: payment.invoice_number
    }
    
    super(
      payment.customer.phone_number,
      'payment_confirmation',
      parameters,
      payment.clinic_id
    )
  end
end

Advanced Features

Message Scheduling

# Schedule WhatsApp reminder 1 hour before appointment
WhatsApp::AppointmentReminder::FutureAppointmentReminder.set(
  wait_until: appointment.scheduled_at - 1.hour
).perform_later(appointment.id)

Bulk Messaging

# Send bulk promotional WhatsApp messages
def send_bulk_promotion(clinic_id, template_name, parameters)
  clinic = Clinic.find(clinic_id)
  
  clinic.patients.active.find_each do |patient|
    # Stagger messages to avoid rate limiting
    WhatsApp::Promotional::CustomWhatsApp.set(
      wait: rand(60).seconds
    ).perform_later(
      patient.phone_number,
      template_name,
      parameters.merge(patient_name: patient.name),
      clinic_id
    )
  end
end

Fallback Strategy

Implement SMS fallback for WhatsApp delivery failures:

def send_with_fallback(phone_number, message, template_id)
  # First attempt: WhatsApp
  whatsapp_job = WhatsApp::Base.perform_later(
    phone_number, template_id, message_params
  )
  
  # Schedule SMS fallback after 10 minutes if WhatsApp fails
  Sms::Base.set(wait: 10.minutes).perform_later(
    phone_number,
    fallback_message_text,
    sms_template_id
  )
end

Monitoring & Analytics

Delivery Metrics

def whatsapp_health_metrics
  {
    total_whatsapp_today: MsgLog.where(msg_type: 'whatsapp')
                                .where('created_at > ?', 1.day.ago).count,
    delivery_rate: calculate_whatsapp_delivery_rate,
    failed_messages: MsgLog.where(msg_type: 'whatsapp', msg_status: 'FAILED')
                           .where('created_at > ?', 1.day.ago).count,
    webhook_processing_rate: calculate_webhook_match_rate,
    avg_delivery_time: calculate_avg_delivery_time,
    template_performance: template_success_rates
  }
end

def calculate_whatsapp_delivery_rate
  sent = MsgLog.where(msg_type: 'whatsapp', msg_status: 'SENT')
              .where('created_at > ?', 1.day.ago).count
  delivered = MsgLog.where(msg_type: 'whatsapp', msg_status: 'DELIVERED')  
                   .where('created_at > ?', 1.day.ago).count
  
  return 0 if sent.zero?
  (delivered.to_f / sent * 100).round(2)
end

Webhook Analytics

def webhook_health_check
  {
    webhooks_today: WatiWebhook.where('created_at > ?', 1.day.ago).count,
    matched_webhooks: WatiWebhook.where('msg_log_id IS NOT NULL')
                                .where('created_at > ?', 1.day.ago).count,
    unmatched_webhooks: WatiWebhook.where('msg_log_id IS NULL')
                                  .where('created_at > ?', 1.day.ago).count,
    event_type_distribution: webhook_event_distribution
  }
end

def webhook_event_distribution  
  WatiWebhook.where('created_at > ?', 1.day.ago)
            .group("payload->>'eventType'")
            .count
end

Error Handling

Common Error Scenarios

  1. Template Not Approved: WhatsApp template pending approval
  2. Invalid Phone Number: Number not registered with WhatsApp
  3. Rate Limiting: WATI API rate limits exceeded
  4. Webhook Delivery: Webhook endpoint failures
  5. Message Matching: Webhook-to-message matching failures

Error Recovery

class WhatsAppErrorHandler
  def self.handle_api_error(error, msg_log)
    case error
    when 'TEMPLATE_NOT_FOUND'
      msg_log.update!(
        msg_status: MsgLog::STATUS_FAILED,
        failure_reason: 'WhatsApp template not approved or not found'
      )
    when 'INVALID_WHATSAPP_NUMBER'
      # Fallback to SMS
      Sms::Base.perform_later(msg_log.phone_number, fallback_message)
    when 'RATE_LIMIT_EXCEEDED'
      # Retry after delay
      WhatsApp::Base.set(wait: 5.minutes).perform_later(msg_log.id)
    else
      msg_log.update!(
        msg_status: MsgLog::STATUS_FAILED,
        failure_reason: error
      )
    end
  end
end

Configuration & Deployment

Required Environment Variables

# Rails credentials structure
production:
  wati:
    api_token: "your_wati_api_token"
    base_url: "https://live-server.wati.io"
    webhook_secret: "your_webhook_secret"  # If available

Webhook Endpoint Setup

# config/routes.rb
Rails.application.routes.draw do
  # WATI webhook endpoint
  post '/webhooks/wati', to: 'wati_webhooks#create'
  
  # Health check for webhooks
  get '/webhooks/wati/health', to: 'wati_webhooks#health'
end

Rate Limiting Configuration

# Implement rate limiting for WhatsApp API
class WatiRateLimiter
  def self.can_send?(clinic_id = nil)
    # Global rate limit: 100 messages per minute
    recent_global = MsgLog.where(msg_type: 'whatsapp')
                         .where('created_at > ?', 1.minute.ago).count
    return false if recent_global >= 100
    
    # Per-clinic rate limit: 20 messages per minute
    if clinic_id.present?
      recent_clinic = MsgLog.where(
        msg_type: 'whatsapp', 
        clinic_id: clinic_id
      ).where('created_at > ?', 1.minute.ago).count
      return false if recent_clinic >= 20
    end
    
    true
  end
end

Testing

Mock WATI API

# spec/support/wati_helpers.rb
module WatiHelpers
  def mock_wati_success(local_message_id = 'test-message-id-123')
    stub_request(:post, /wati.*sendTemplateMessage/)
      .to_return(
        status: 200,
        body: {
          result: true,
          templateName: 'test_template',
          receivers: [
            {
              localMessageId: local_message_id,
              waId: '919876543210',
              isValidWhatsAppNumber: true
            }
          ]
        }.to_json
      )
  end
  
  def mock_wati_failure
    stub_request(:post, /wati.*sendTemplateMessage/)
      .to_return(status: 400, body: 'Invalid template')
  end
end

Webhook Testing

RSpec.describe WatiWebhooksController do
  describe 'POST #create' do
    context 'with delivery webhook' do
      let(:webhook_payload) do
        {
          eventType: 'sentMessageDELIVERED',
          whatsappMessageId: 'wamid_test_123',
          statusString: 'Delivered'
        }
      end
      
      it 'updates message status to delivered' do
        msg_log = create(:msg_log, 
          wati_message_id: 'wamid_test_123',
          msg_status: 'SENT'
        )
        
        post :create, params: webhook_payload
        
        expect(msg_log.reload.msg_status).to eq('DELIVERED')
        expect(response).to have_http_status(:ok)
      end
    end
  end
end

Best Practices

Template Management

  • Keep templates simple and clear
  • Use dynamic parameters for personalization
  • Test templates thoroughly before approval
  • Monitor template approval status

Message Timing

  • Respect user time zones and preferences
  • Avoid sending messages during late hours
  • Space out bulk messages to avoid spam perception
  • Use appropriate urgency levels

Delivery Optimization

  • Monitor delivery rates by template
  • Implement fallback to SMS when appropriate
  • Use read receipts for critical messages
  • Track user engagement and adjust strategy

Compliance & Privacy

  • Obtain proper consent for WhatsApp messaging
  • Provide clear opt-out mechanisms
  • Comply with WhatsApp Business Policy
  • Maintain message content standards

Troubleshooting

Debug Commands

# Check recent WhatsApp message logs
MsgLog.where(msg_type: 'whatsapp')
      .where('created_at > ?', 1.hour.ago)
      .order(:created_at)

# Test WhatsApp sending manually
WhatsApp::Base.perform_now(
  '919876543210',
  'test_template',
  { patient_name: 'Test User' }
)

# Check webhook matching rate
matched = WatiWebhook.where('msg_log_id IS NOT NULL').count
total = WatiWebhook.count
puts "Webhook match rate: #{(matched.to_f / total * 100).round(2)}%"

# Debug failed messages
MsgLog.where(msg_type: 'whatsapp', msg_status: 'FAILED')
      .limit(10)
      .pluck(:failure_reason)

Common Issues

  1. Template Rejection: Review WhatsApp template policies
  2. Webhook Not Received: Check webhook URL configuration
  3. Message Not Matched: Verify probabilistic matching logic
  4. High Failure Rate: Check phone number formatting and validity

On this page