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 servicesMessage Tracking Flow
WhatsApp Send → WATI V2 API → Template Message → Webhook Events → Status Updates
↓ ↓ ↓ ↓ ↓
MsgLog localMessageId whatsappMessageId Event Handler DatabaseCore 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
endWATI 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
endV2 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
endWebhook 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
endWebhook 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
endTelemedicine 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
endPayment 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
endAdvanced 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
endFallback 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
)
endMonitoring & 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)
endWebhook 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
endError Handling
Common Error Scenarios
- Template Not Approved: WhatsApp template pending approval
- Invalid Phone Number: Number not registered with WhatsApp
- Rate Limiting: WATI API rate limits exceeded
- Webhook Delivery: Webhook endpoint failures
- 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
endConfiguration & 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 availableWebhook 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'
endRate 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
endTesting
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
endWebhook 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
endBest 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
- Template Rejection: Review WhatsApp template policies
- Webhook Not Received: Check webhook URL configuration
- Message Not Matched: Verify probabilistic matching logic
- High Failure Rate: Check phone number formatting and validity
Related Documentation
- SMS & Voice Services - SMS integration and fallback
- Push Notifications - Mobile push notifications
- Background Jobs - Sidekiq job configuration
- Analytics Services - Message analytics and tracking