DEV Community

Yogesh Rawat
Yogesh Rawat

Posted on

How We Built a WhatsApp CRM for Indian SMBs Using the WhatsApp Business API

When we started building Insell — a WhatsApp API + CRM platform for Indian SMBs — we ran into every integration challenge you'd expect: webhook setup, message templates, session handling, and dealing with Meta's approval process. This post covers exactly how we built it and what we learned.

Why WhatsApp API for Indian SMBs?

India has over 500 million WhatsApp users. For small businesses — coaching institutes, e-commerce shops, real estate agents, clinics — WhatsApp is not just a messaging app. It's where their customers actually are and where they expect to be contacted.

Email open rates in India hover around 15-20%. WhatsApp messages get 90%+ open rates within 3 minutes. The business case writes itself.

Setting Up the WhatsApp Business API

Meta provides the WhatsApp Business API through approved Business Solution Providers (BSPs). Here's the flow we implemented:

1. Meta App Setup

# You need a Meta Developer account and a verified Business Manager
# Create an app at developers.facebook.com
# Add WhatsApp product to your app
Enter fullscreen mode Exit fullscreen mode

2. Phone Number Registration

Every WhatsApp Business account needs a dedicated phone number. The number cannot be actively used on regular WhatsApp.

import requests

def register_phone_number(access_token, phone_number_id):
    url = f"https://graph.facebook.com/v18.0/{phone_number_id}/register"
    headers = {"Authorization": f"Bearer {access_token}"}
    payload = {
        "messaging_product": "whatsapp",
        "pin": "YOUR_6_DIGIT_PIN"
    }
    response = requests.post(url, headers=headers, json=payload)
    return response.json()
Enter fullscreen mode Exit fullscreen mode

3. Webhook Configuration

Webhooks are the backbone of the integration. Meta sends all incoming messages, delivery receipts, and read receipts to your webhook endpoint.

from flask import Flask, request, jsonify
import hashlib
import hmac

app = Flask(__name__)

@app.route('/webhook', methods=['GET'])
def verify_webhook():
    """Meta sends a GET request to verify webhook ownership"""
    verify_token = request.args.get('hub.verify_token')
    challenge = request.args.get('hub.challenge')

    if verify_token == 'YOUR_VERIFY_TOKEN':
        return challenge
    return 'Verification failed', 403

@app.route('/webhook', methods=['POST'])
def receive_message():
    """Handle incoming WhatsApp messages"""
    data = request.json

    if data.get('object') == 'whatsapp_business_account':
        for entry in data.get('entry', []):
            for change in entry.get('changes', []):
                value = change.get('value', {})
                messages = value.get('messages', [])

                for message in messages:
                    handle_message(message)

    return jsonify({'status': 'ok'})

def handle_message(message):
    message_type = message.get('type')
    from_number = message.get('from')

    if message_type == 'text':
        text = message.get('text', {}).get('body', '')
        # Process and respond
        send_reply(from_number, f"Received: {text}")
    elif message_type == 'interactive':
        # Handle button clicks, list selections
        handle_interactive(message)
Enter fullscreen mode Exit fullscreen mode

4. Sending Messages

WhatsApp API distinguishes between two types of messages:

Template Messages (for business-initiated conversations):

def send_template_message(to_number, template_name, language_code='en_US', params=None):
    url = f"https://graph.facebook.com/v18.0/{PHONE_NUMBER_ID}/messages"
    headers = {
        "Authorization": f"Bearer {ACCESS_TOKEN}",
        "Content-Type": "application/json"
    }

    payload = {
        "messaging_product": "whatsapp",
        "to": to_number,
        "type": "template",
        "template": {
            "name": template_name,
            "language": {"code": language_code},
            "components": []
        }
    }

    if params:
        payload["template"]["components"].append({
            "type": "body",
            "parameters": [{"type": "text", "text": p} for p in params]
        })

    response = requests.post(url, headers=headers, json=payload)
    return response.json()

# Example: Send OTP template
send_template_message(
    to_number="+919876543210",
    template_name="otp_verification",
    params=["482916"]
)
Enter fullscreen mode Exit fullscreen mode

Session Messages (replying within 24-hour window):

def send_text_reply(to_number, message_text):
    url = f"https://graph.facebook.com/v18.0/{PHONE_NUMBER_ID}/messages"
    payload = {
        "messaging_product": "whatsapp",
        "to": to_number,
        "type": "text",
        "text": {"body": message_text}
    }
    # ... same headers as above
    response = requests.post(url, headers=headers, json=payload)
    return response.json()
Enter fullscreen mode Exit fullscreen mode

The CRM Layer: Linking Conversations to Contacts

The raw API gives you messages, but building a CRM requires linking conversations to contact records. Here's our data model:

# models.py (Django ORM)
from django.db import models

class Contact(models.Model):
    phone_number = models.CharField(max_length=20, unique=True)
    name = models.CharField(max_length=255, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    tags = models.JSONField(default=list)
    custom_fields = models.JSONField(default=dict)

class Conversation(models.Model):
    contact = models.ForeignKey(Contact, on_delete=models.CASCADE)
    status = models.CharField(
        max_length=20,
        choices=[('open', 'Open'), ('resolved', 'Resolved'), ('pending', 'Pending')],
        default='open'
    )
    assigned_to = models.ForeignKey('auth.User', null=True, blank=True, on_delete=models.SET_NULL)
    last_message_at = models.DateTimeField(null=True)

class Message(models.Model):
    conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE)
    wa_message_id = models.CharField(max_length=100, unique=True)
    direction = models.CharField(choices=[('inbound', 'Inbound'), ('outbound', 'Outbound')], max_length=10)
    message_type = models.CharField(max_length=20)  # text, image, audio, etc.
    content = models.JSONField()
    timestamp = models.DateTimeField()
    status = models.CharField(max_length=20, default='sent')  # sent, delivered, read, failed
Enter fullscreen mode Exit fullscreen mode

Handling the 24-Hour Session Window

This is where most developers trip up. Meta only allows free-form messages within 24 hours of the last customer message. After that, you must use approved templates.

from datetime import datetime, timedelta
import pytz

def can_send_session_message(conversation):
    """Check if we're within the 24-hour session window"""
    if not conversation.last_inbound_at:
        return False

    now = datetime.now(pytz.UTC)
    window_end = conversation.last_inbound_at + timedelta(hours=24)
    return now < window_end

def send_smart_message(conversation, message_text, template_name=None):
    """Send session or template message based on window"""
    if can_send_session_message(conversation):
        return send_text_reply(conversation.contact.phone_number, message_text)
    elif template_name:
        return send_template_message(
            conversation.contact.phone_number,
            template_name
        )
    else:
        raise Exception("Outside 24h window and no template provided")
Enter fullscreen mode Exit fullscreen mode

Broadcast Campaigns for Lead Nurturing

One of the highest-value features for Indian SMBs is broadcast messaging — sending approved templates to opted-in contacts for follow-ups, reminders, and offers.

import time

def send_broadcast(contact_list, template_name, params_func, rate_limit=80):
    """
    Send template broadcasts with rate limiting
    Meta allows ~80 messages/second on standard tier
    """
    results = {'sent': 0, 'failed': 0, 'errors': []}

    for i, contact in enumerate(contact_list):
        try:
            params = params_func(contact)  # Personalize per contact
            result = send_template_message(
                contact.phone_number,
                template_name,
                params=params
            )

            if 'messages' in result:
                results['sent'] += 1
            else:
                results['failed'] += 1
                results['errors'].append({
                    'contact': contact.phone_number,
                    'error': result.get('error', {}).get('message', 'Unknown')
                })

        except Exception as e:
            results['failed'] += 1

        # Rate limiting
        if (i + 1) % rate_limit == 0:
            time.sleep(1)

    return results
Enter fullscreen mode Exit fullscreen mode

Key Lessons After Building This

  1. Phone number quality rating matters — Meta assigns quality ratings to your number based on user blocks and complaints. Keep templates relevant and honour opt-outs immediately.

  2. Template approval takes 1-48 hours — Build your template library before you need it urgently.

  3. Webhook reliability is critical — Use a queue (Celery + Redis works well) to process webhooks asynchronously. Don't process in the webhook handler itself.

  4. Indian phone numbers need country code — Always store and send numbers with +91 prefix.

  5. Test with actual Indian SIM cards — Some message types render differently on older Android versions common in India.

What We Shipped

We turned all of this into Insell — a ready-to-use WhatsApp CRM built specifically for Indian SMBs. If you're an agency or developer looking to offer WhatsApp automation to your clients without building from scratch, check it out.

The platform handles the API complexity (webhooks, session management, template approvals, broadcasts) so your clients can focus on conversations, not infrastructure.


Happy to answer any questions about the WhatsApp Business API integration in the comments. This stuff has a lot of edge cases that aren't documented well.

Tags: whatsapp, python, api, crm, india

Top comments (0)