WhatsApp is where your customers already are. With over two billion active users, it is the default communication channel in most markets outside North America, and increasingly a preferred channel inside them. Businesses that respond to customer inquiries on WhatsApp in minutes close deals faster, reduce churn, and earn higher satisfaction scores than those routing customers through email or web forms.
The WhatsApp Business API (now part of the Meta Cloud API) makes it possible to build production-grade integrations: automated support bots, lead qualification flows, appointment booking, CRM synchronization, order status notifications, and multi-agent support dashboards. Unlike the WhatsApp Business App, the API has no GUI, it is designed entirely for programmatic access and scales to millions of messages.
This guide is built for the people who build and operate these systems. Business owners and operations leaders will find the architecture section and automation examples most useful. Developers and architects will find working code for Node.js, NestJS, Laravel, and FastAPI, along with webhook security, CRM integration patterns, and a production checklist.
Overview
The WhatsApp Business API is a cloud-based API provided by Meta that lets businesses send and receive WhatsApp messages programmatically. Access is through the Meta Cloud API (hosted by Meta) or through a Business Solution Provider (BSP). The Cloud API is the recommended path for new integrations, it is free to use, does not require third-party hosting fees, and gives you direct access to the latest features.
The pricing model is conversation-based. Meta charges per 24-hour conversation window, not per message. Conversations are categorized as marketing, utility, authentication, or service. Service conversations (user-initiated) are free in most regions. Marketing conversations (business-initiated promotions) carry a per-conversation charge that varies by country. You can find current rates on the Meta for Developers pricing page.
- Automated customer support that handles FAQs and common requests without human agents
- Lead qualification flows that collect information and score leads before routing to sales
- Appointment booking with real-time availability checks and confirmation messages
- Order status updates, shipping notifications, and payment confirmations
- Multi-agent support dashboards where human agents handle escalations from the bot
- CRM synchronization that creates and updates contact records from WhatsApp conversations
- AI-powered assistants that use your knowledge base to answer product and support questions
Business Benefits
Automated Customer Support
A WhatsApp bot handles the questions your team answers dozens of times per day, pricing, availability, policy questions, order status, without human involvement. The bot responds in seconds, at any hour, in any volume. Support teams focus on the conversations that actually require human judgment, and first-response times drop from hours to seconds across the board.
Faster Lead Qualification
Inbound leads often go cold while they wait for a sales rep to respond. A WhatsApp qualification flow asks the right questions immediately, scores the lead based on the answers, and routes high-intent prospects to a sales rep within minutes of their first message. Low-intent leads enter a nurture sequence automatically. The pipeline fills faster with better-qualified opportunities.
Improved Customer Experience
Customers prefer WhatsApp over web chat, email, and phone for everyday support interactions. They are already on the app. The conversation is persistent, they can pick it up hours later. Responses arrive as push notifications. WhatsApp integration meets customers where they are, in the channel they already use, which consistently produces higher satisfaction scores than any other support channel.
Reduced Operational Costs
Automation handles the high-volume, low-complexity interactions that currently consume support capacity. A single WhatsApp bot integration typically deflects 40 to 60 percent of inbound tickets in the first 90 days. The support team handles more complex conversations in the same headcount. For businesses paying per-agent SaaS fees on support platforms, the cost reduction is immediate and measurable.
Automated Notifications
WhatsApp's open rate for business messages exceeds 90 percent, far above email's 20 to 25 percent average. Order confirmations, appointment reminders, payment receipts, shipping updates, and renewal notices delivered via WhatsApp are read almost immediately. Template messages (pre-approved by Meta) enable business-initiated outbound notifications to users who have opted in.
CRM Synchronization
Every WhatsApp conversation is a source of customer data. Phone number, intent, conversation history, lead score, support ticket content, all of this can be written to your CRM in real time. Contacts created from WhatsApp flows arrive in HubSpot, Salesforce, or Zoho already enriched with context, eliminating the manual data entry that slows every sales and support team.
Architecture Overview
WhatsApp API integration follows a webhook-driven architecture. When a customer sends a message, Meta POSTs the event to your webhook endpoint. Your backend processes the message, applies business logic, and responds by calling the WhatsApp Graph API. The key constraint: your webhook must respond with HTTP 200 within five seconds. All processing that takes longer must be handed off to an async queue.
flowchart LR
Customer["Customer"] --> WA["WhatsApp"]
WA --> Hook["Webhook\nPOST /webhook"]
Hook --> BE["Backend API\nNode.js / FastAPI"]
BE --> SIG["Signature\nValidation"]
BE --> Queue["Message Queue\nRedis / BullMQ"]
Queue --> AI["AI Engine\nOpenAI / Claude"]
Queue --> CRM["CRM\nHubSpot / Salesforce"]
AI --> Send["Send Message\nGraph API"]
CRM --> Send
Send --> WA
BE --> DB["Database\nPostgreSQL"]
BE --> MON["Monitoring\nSentry / Datadog"]The webhook must return HTTP 200 within 5 seconds. If it does not, Meta retries the delivery. Move all processing, AI calls, CRM writes, database operations, to an async queue after acknowledging receipt.
Message Flow
- 1Customer sends a WhatsApp message to your business number
- 2Meta delivers the message as a POST request to your webhook URL
- 3Your backend validates the X-Hub-Signature-256 header using your App Secret
- 4Webhook handler returns HTTP 200 immediately and enqueues the message for processing
- 5Queue worker processes the message: calls AI, queries the database, checks session state
- 6Backend calls the Graph API to send the response back to the customer
- 7Conversation state, contact data, and logs are persisted to the database and CRM
Frontend Integration
The frontend for a WhatsApp integration is typically an internal dashboard, used by support agents to read conversation history, assign chats, and send replies. Customers interact entirely through WhatsApp; the frontend is for your team. Here are the key patterns for building that dashboard across popular stacks.
React, Conversation Dashboard
import { useState, useEffect } from 'react'
interface Conversation {
id: string
phone: string
contactName: string
lastMessage: string
lastMessageAt: string
status: 'open' | 'resolved' | 'pending'
assignedTo?: string
}
export function ConversationList() {
const [conversations, setConversations] = useState<Conversation[]>([])
const [selected, setSelected] = useState<string | null>(null)
useEffect(() => {
const fetchConversations = async () => {
const res = await fetch('/api/whatsapp/conversations')
const data = await res.json()
setConversations(data)
}
fetchConversations()
// Poll for new conversations every 5 seconds
const interval = setInterval(fetchConversations, 5000)
return () => clearInterval(interval)
}, [])
return (
<div className="flex h-screen">
<aside className="w-80 border-r overflow-y-auto">
{conversations.map((c) => (
<button
key={c.id}
onClick={() => setSelected(c.id)}
className="w-full p-4 text-left hover:bg-gray-50 border-b"
>
<div className="font-semibold">{c.contactName || c.phone}</div>
<div className="text-sm text-gray-500 truncate">{c.lastMessage}</div>
</button>
))}
</aside>
<main className="flex-1">
{selected ? <ChatWindow conversationId={selected} /> : (
<div className="flex items-center justify-center h-full text-gray-400">
Select a conversation
</div>
)}
</main>
</div>
)
}Next.js, Agent Reply API Route
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
export async function POST(req: NextRequest) {
const session = await getServerSession()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { phone, message, conversationId } = await req.json()
if (!phone || !message) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Send via your backend WhatsApp service
const res = await fetch(process.env.API_BASE_URL + '/whatsapp/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + process.env.INTERNAL_API_KEY,
},
body: JSON.stringify({ phone, message, conversationId, agentId: session.user?.email }),
})
if (!res.ok) return NextResponse.json({ error: 'Failed to send' }, { status: 502 })
return NextResponse.json({ success: true })
}Vue.js, Real-time Chat with WebSocket
import { ref, onUnmounted } from 'vue'
export function useWhatsAppChat(conversationId: string) {
const messages = ref<{ from: string; body: string; timestamp: string }[]>([])
const connected = ref(false)
const ws = new WebSocket(
(import.meta.env.VITE_WS_URL || 'ws://localhost:3001') + '/whatsapp/' + conversationId
)
ws.onopen = () => { connected.value = true }
ws.onclose = () => { connected.value = false }
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
messages.value.push(msg)
}
const sendReply = async (text: string) => {
await fetch('/api/whatsapp/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId, message: text }),
})
}
onUnmounted(() => ws.close())
return { messages, connected, sendReply }
}Angular
import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs'
interface Conversation { id: string; phone: string; lastMessage: string }
@Injectable({ providedIn: 'root' })
export class WhatsAppService {
constructor(private http: HttpClient) {}
getConversations(): Observable<Conversation[]> {
return this.http.get<Conversation[]>('/api/whatsapp/conversations')
}
sendReply(conversationId: string, message: string): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>('/api/whatsapp/reply', { conversationId, message })
}
}Svelte
<script lang="ts">
export let conversationId: string
let reply = ''
let sending = false
let sent = false
async function send() {
if (!reply.trim()) return
sending = true
await fetch('/api/whatsapp/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId, message: reply }),
})
reply = ''
sending = false
sent = true
setTimeout(() => (sent = false), 2000)
}
</script>
<textarea bind:value={reply} placeholder="Type a reply..." />
<button on:click={send} disabled={sending}>
{sending ? 'Sending...' : sent ? 'Sent ✓' : 'Send'}
</button>Solid.js
import { createSignal } from 'solid-js'
export function ReplyBox({ conversationId }: { conversationId: string }) {
const [reply, setReply] = createSignal('')
const [sending, setSending] = createSignal(false)
const send = async () => {
if (!reply().trim()) return
setSending(true)
await fetch('/api/whatsapp/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId, message: reply() }),
})
setReply('')
setSending(false)
}
return (
<div>
<textarea value={reply()} onInput={(e) => setReply(e.currentTarget.value)} />
<button onClick={send} disabled={sending()}>
{sending() ? 'Sending...' : 'Send'}
</button>
</div>
)
}Qwik
import { component$, useSignal, $ } from '@builder.io/qwik'
export const ReplyBox = component$<{ conversationId: string }>(({ conversationId }) => {
const reply = useSignal('')
const sending = useSignal(false)
const send = $(async () => {
if (!reply.value.trim()) return
sending.value = true
await fetch('/api/whatsapp/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId, message: reply.value }),
})
reply.value = ''
sending.value = false
})
return (
<div>
<textarea value={reply.value} onInput$={(e) => (reply.value = (e.target as HTMLTextAreaElement).value)} />
<button onClick$={send} disabled={sending.value}>
{sending.value ? 'Sending...' : 'Send'}
</button>
</div>
)
})Astro
---
const { conversationId } = Astro.props
---
<div data-conversation-id={conversationId}>
<textarea id="reply-text" placeholder="Type a reply..."></textarea>
<button id="reply-send">Send</button>
</div>
<script>
const container = document.querySelector('[data-conversation-id]') as HTMLElement
const conversationId = container?.dataset.conversationId
document.getElementById('reply-send')?.addEventListener('click', async () => {
const textarea = document.getElementById('reply-text') as HTMLTextAreaElement
const message = textarea.value.trim()
if (!message) return
await fetch('/api/whatsapp/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId, message }),
})
textarea.value = ''
})
</script>Backend Integration
The backend is where all WhatsApp logic runs. It handles webhook verification, message parsing, business logic, and outbound message sending via the Graph API. Below are production-ready implementations across four stacks.
Node.js / Express, Webhook Handler
npm install axios crypto-jsimport crypto from 'crypto'
import axios from 'axios'
import type { Request, Response } from 'express'
const BASE_URL = 'https://graph.facebook.com/v19.0'
// GET, Meta sends this once to verify your endpoint
export function verifyWebhook(req: Request, res: Response) {
const mode = req.query['hub.mode']
const token = req.query['hub.verify_token']
const challenge = req.query['hub.challenge']
if (mode === 'subscribe' && token === process.env.WEBHOOK_VERIFY_TOKEN) {
return res.status(200).send(challenge)
}
return res.status(403).send('Forbidden')
}
// POST, incoming messages and status updates
export async function handleWebhook(req: Request, res: Response) {
// Always acknowledge immediately
res.status(200).send('EVENT_RECEIVED')
const body = req.body
if (body.object !== 'whatsapp_business_account') return
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value
const phoneNumberId = value.metadata?.phone_number_id
for (const message of value.messages ?? []) {
await enqueueMessage({ message, phoneNumberId })
}
}
}
}
// Send a plain text message
export async function sendTextMessage(to: string, text: string, phoneNumberId: string) {
await axios.post(
BASE_URL + '/' + phoneNumberId + '/messages',
{
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'text',
text: { preview_url: false, body: text },
},
{ headers: { Authorization: 'Bearer ' + process.env.WHATSAPP_ACCESS_TOKEN } }
)
}
// Send a pre-approved template message
export async function sendTemplate(to: string, template: string, params: string[]) {
await axios.post(
BASE_URL + '/' + process.env.PHONE_NUMBER_ID + '/messages',
{
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name: template,
language: { code: 'en_US' },
components: [{
type: 'body',
parameters: params.map((text) => ({ type: 'text', text })),
}],
},
},
{ headers: { Authorization: 'Bearer ' + process.env.WHATSAPP_ACCESS_TOKEN } }
)
}NestJS
import { Injectable, Logger } from '@nestjs/common'
import { HttpService } from '@nestjs/axios'
import { firstValueFrom } from 'rxjs'
@Injectable()
export class WhatsAppService {
private readonly logger = new Logger(WhatsAppService.name)
private readonly baseUrl = 'https://graph.facebook.com/v19.0'
constructor(private readonly http: HttpService) {}
async sendMessage(to: string, text: string): Promise<void> {
const url = this.baseUrl + '/' + process.env.PHONE_NUMBER_ID + '/messages'
try {
await firstValueFrom(
this.http.post(
url,
{ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } },
{ headers: { Authorization: 'Bearer ' + process.env.WHATSAPP_ACCESS_TOKEN } }
)
)
} catch (error) {
this.logger.error('Failed to send WhatsApp message', { to, error })
throw error
}
}
async sendTemplate(to: string, name: string, params: string[]): Promise<void> {
const url = this.baseUrl + '/' + process.env.PHONE_NUMBER_ID + '/messages'
await firstValueFrom(
this.http.post(
url,
{
messaging_product: 'whatsapp',
to,
type: 'template',
template: {
name,
language: { code: 'en_US' },
components: [{ type: 'body', parameters: params.map((t) => ({ type: 'text', text: t })) }],
},
},
{ headers: { Authorization: 'Bearer ' + process.env.WHATSAPP_ACCESS_TOKEN } }
)
)
}
}Python FastAPI
pip install fastapi httpx python-dotenvimport os, hashlib, hmac, httpx
from fastapi import APIRouter, Request, Response, HTTPException, BackgroundTasks
router = APIRouter()
BASE_URL = "https://graph.facebook.com/v19.0"
@router.get("/webhook")
async def verify(request: Request):
p = dict(request.query_params)
if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == os.environ["WEBHOOK_VERIFY_TOKEN"]:
return Response(content=p["hub.challenge"])
raise HTTPException(status_code=403)
@router.post("/webhook")
async def handle(request: Request, background: BackgroundTasks):
body = await request.json()
# Respond immediately, process in background
if body.get("object") == "whatsapp_business_account":
for entry in body.get("entry", []):
for change in entry.get("changes", []):
for msg in change.get("value", {}).get("messages", []):
background.add_task(process_message, msg)
return {"status": "ok"}
async def send_message(to: str, text: str):
url = f"{BASE_URL}/{os.environ['PHONE_NUMBER_ID']}/messages"
async with httpx.AsyncClient() as client:
await client.post(
url,
json={"messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": text}},
headers={"Authorization": "Bearer " + os.environ["WHATSAPP_ACCESS_TOKEN"]},
)Laravel
composer require guzzlehttp/guzzle<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WhatsAppService
{
private string $baseUrl;
private string $token;
public function __construct()
{
$phoneId = config('whatsapp.phone_number_id');
$this->baseUrl = "https://graph.facebook.com/v19.0/{$phoneId}";
$this->token = config('whatsapp.access_token');
}
public function sendMessage(string $to, string $text): void
{
Http::withToken($this->token)
->post("{$this->baseUrl}/messages", [
'messaging_product' => 'whatsapp',
'to' => $to,
'type' => 'text',
'text' => ['body' => $text],
]);
}
public function sendTemplate(string $to, string $template, array $params = []): void
{
Http::withToken($this->token)
->post("{$this->baseUrl}/messages", [
'messaging_product' => 'whatsapp',
'to' => $to,
'type' => 'template',
'template' => [
'name' => $template,
'language' => ['code' => 'en_US'],
'components' => [[
'type' => 'body',
'parameters' => array_map(
fn($p) => ['type' => 'text', 'text' => $p],
$params
),
]],
],
]);
}
}Django
pip install requests djangoimport os, json, hmac, hashlib, requests
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
BASE_URL = f"https://graph.facebook.com/v19.0/{os.environ['PHONE_NUMBER_ID']}"
HEADERS = {"Authorization": f"Bearer {os.environ['WHATSAPP_ACCESS_TOKEN']}"}
@csrf_exempt
def webhook(request):
if request.method == 'GET':
p = request.GET
if p.get('hub.mode') == 'subscribe' and p.get('hub.verify_token') == os.environ['WEBHOOK_VERIFY_TOKEN']:
return HttpResponse(p['hub.challenge'])
return HttpResponse(status=403)
body = json.loads(request.body)
for entry in body.get('entry', []):
for change in entry.get('changes', []):
for msg in change.get('value', {}).get('messages', []):
process_message(msg)
return JsonResponse({'status': 'ok'})
def send_message(to: str, text: str):
requests.post(f"{BASE_URL}/messages", headers=HEADERS, json={
'messaging_product': 'whatsapp', 'to': to,
'type': 'text', 'text': {'body': text},
})
def process_message(msg: dict):
if msg.get('type') == 'text':
send_message(msg['from'], f"Thanks for your message: {msg['text']['body']}")Go
go get github.com/go-chi/chi/v5package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
var (
baseURL = fmt.Sprintf("https://graph.facebook.com/v19.0/%s", os.Getenv("PHONE_NUMBER_ID"))
token = os.Getenv("WHATSAPP_ACCESS_TOKEN")
)
func Webhook(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
q := r.URL.Query()
if q.Get("hub.mode") == "subscribe" && q.Get("hub.verify_token") == os.Getenv("WEBHOOK_VERIFY_TOKEN") {
w.Write([]byte(q.Get("hub.challenge")))
return
}
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
// Process messages asynchronously
go processWebhook(body)
w.WriteHeader(http.StatusOK)
}
func SendMessage(to, text string) error {
payload, _ := json.Marshal(map[string]any{
"messaging_product": "whatsapp", "to": to,
"type": "text", "text": map[string]string{"body": text},
})
req, _ := http.NewRequest(http.MethodPost, baseURL+"/messages", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
_, err := http.DefaultClient.Do(req)
return err
}Spring Boot
@RestController
@RequestMapping("/api/whatsapp")
public class WhatsAppController {
private final String baseUrl;
private final String token;
public WhatsAppController() {
this.baseUrl = "https://graph.facebook.com/v19.0/" + System.getenv("PHONE_NUMBER_ID");
this.token = System.getenv("WHATSAPP_ACCESS_TOKEN");
}
@GetMapping("/webhook")
public ResponseEntity<String> verify(@RequestParam Map<String, String> params) {
if ("subscribe".equals(params.get("hub.mode")) &&
System.getenv("WEBHOOK_VERIFY_TOKEN").equals(params.get("hub.verify_token"))) {
return ResponseEntity.ok(params.get("hub.challenge"));
}
return ResponseEntity.status(403).build();
}
@PostMapping("/webhook")
public ResponseEntity<Map<String, String>> handle(@RequestBody Map<String, Object> body) {
// Process async in production
return ResponseEntity.ok(Map.of("status", "ok"));
}
public void sendMessage(String to, String text) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(token);
Map<String, Object> payload = Map.of(
"messaging_product", "whatsapp", "to", to,
"type", "text", "text", Map.of("body", text)
);
new RestTemplate().exchange(baseUrl + "/messages", HttpMethod.POST, new HttpEntity<>(payload, headers), Map.class);
}
}ASP.NET Core
[ApiController]
[Route("api/whatsapp")]
public class WhatsAppController : ControllerBase
{
private readonly HttpClient _http;
private readonly string _baseUrl;
public WhatsAppController(IHttpClientFactory factory)
{
_http = factory.CreateClient();
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("WHATSAPP_ACCESS_TOKEN"));
_baseUrl = $"https://graph.facebook.com/v19.0/{Environment.GetEnvironmentVariable("PHONE_NUMBER_ID")}";
}
[HttpGet("webhook")]
public IActionResult Verify([FromQuery(Name = "hub.mode")] string mode,
[FromQuery(Name = "hub.verify_token")] string verifyToken,
[FromQuery(Name = "hub.challenge")] string challenge)
{
if (mode == "subscribe" && verifyToken == Environment.GetEnvironmentVariable("WEBHOOK_VERIFY_TOKEN"))
return Content(challenge);
return Forbid();
}
[HttpPost("webhook")]
public async Task<IActionResult> Handle([FromBody] JsonElement body)
{
// Parse and dispatch messages asynchronously
return Ok(new { status = "ok" });
}
public async Task SendMessage(string to, string text)
{
var payload = new { messaging_product = "whatsapp", to, type = "text", text = new { body = text } };
await _http.PostAsJsonAsync(_baseUrl + "/messages", payload);
}
}Rust
# Cargo.toml: axum = "0.7", reqwest = { features = ["json"] }, serde_json = "1"use axum::{extract::Query, Json};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
#[derive(Deserialize)]
pub struct VerifyQuery {
#[serde(rename = "hub.mode")]
pub mode: Option<String>,
#[serde(rename = "hub.verify_token")]
pub verify_token: Option<String>,
#[serde(rename = "hub.challenge")]
pub challenge: Option<String>,
}
pub async fn verify(Query(params): Query<VerifyQuery>) -> String {
let expected = std::env::var("WEBHOOK_VERIFY_TOKEN").unwrap();
if params.mode.as_deref() == Some("subscribe") && params.verify_token.as_deref() == Some(&expected) {
return params.challenge.unwrap_or_default();
}
"Forbidden".to_string()
}
pub async fn send_whatsapp_message(client: &Client, to: &str, text: &str) {
let phone_id = std::env::var("PHONE_NUMBER_ID").unwrap();
let token = std::env::var("WHATSAPP_ACCESS_TOKEN").unwrap();
let _ = client
.post(format!("https://graph.facebook.com/v19.0/{phone_id}/messages"))
.bearer_auth(&token)
.json(&json!({"messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": text}}))
.send().await;
}Authentication
WhatsApp Business API authentication involves three components: a Meta App with the WhatsApp product added, a System User Access Token for API calls, and a Webhook Verify Token you define for endpoint validation. None of these should ever appear in client-side code or version control.
Meta App Setup
- 1Create a Meta App at developers.facebook.com, select Business type
- 2Add the WhatsApp product to your app
- 3In WhatsApp Settings, add and verify your business phone number
- 4Generate a System User and assign it to your App with admin permissions
- 5Create a permanent System User Access Token (not the temporary test token)
- 6Set your webhook URL and Verify Token in the WhatsApp Configuration tab
- 7Subscribe to the messages webhook field
Environment Variables
# Meta / WhatsApp credentials, never commit these
WHATSAPP_ACCESS_TOKEN=EAAxxxxxxxxxxxxxx
WHATSAPP_APP_SECRET=xxxxxxxxxxxxxxxx
PHONE_NUMBER_ID=1234567890
WHATSAPP_BUSINESS_ACCOUNT_ID=0987654321
# Token you define, must match what you enter in the Meta dashboard
WEBHOOK_VERIFY_TOKEN=my-secret-verify-token-2026
# Optional: rate limiting and session management
REDIS_URL=redis://localhost:6379Webhook Signature Validation
Every POST request Meta sends to your webhook includes an X-Hub-Signature-256 header, an HMAC-SHA256 hash of the request body using your App Secret as the key. You must validate this signature on every request. Skipping validation means any attacker who discovers your webhook URL can send fabricated events.
import crypto from 'crypto'
import type { Request, Response, NextFunction } from 'express'
export function validateWhatsAppWebhook(req: Request, res: Response, next: NextFunction) {
const signature = req.headers['x-hub-signature-256'] as string
if (!signature) {
return res.status(401).json({ error: 'Missing signature header' })
}
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.WHATSAPP_APP_SECRET!)
.update(JSON.stringify(req.body))
.digest('hex')
// Use timingSafeEqual to prevent timing attacks
const sigBuffer = Buffer.from(signature)
const expBuffer = Buffer.from(expected)
if (sigBuffer.length !== expBuffer.length || !crypto.timingSafeEqual(sigBuffer, expBuffer)) {
return res.status(401).json({ error: 'Invalid signature' })
}
next()
}Access Token Rotation
System User tokens generated in Meta Business Manager do not expire, but you should rotate them after any suspected exposure, when team members with access depart, and as part of a regular annual security review. Store the token in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Vercel Environment Variables), not in .env files on production servers.
Automation Examples
The highest-value WhatsApp integrations automate repetitive, high-volume processes. Here are three complete automation patterns with working code.
Lead Qualification Flow
When a new customer sends a first message, the bot collects key information through a guided conversation, scores the lead, and routes it to the CRM, all before a sales rep is involved.
const FLOW: Record<string, { question: string; nextStage: string }> = {
START: { question: 'Welcome! What is your name?', nextStage: 'NAME' },
NAME: { question: 'Great! What is your company name?', nextStage: 'COMPANY' },
COMPANY: { question: 'What is your estimated budget?
1. Under $5k
2. $5k – $20k
3. Over $20k', nextStage: 'BUDGET' },
BUDGET: { question: 'What problem are you trying to solve?', nextStage: 'PROBLEM' },
PROBLEM: { question: '', nextStage: 'COMPLETE' },
}
export async function handleLeadFlow(phone: string, message: string) {
const session = await getSession(phone) ?? { stage: 'START', data: {} }
const { stage, data } = session
// Save the user's answer for the current stage
const updatedData = { ...data, [stage.toLowerCase()]: message.trim() }
const next = FLOW[stage]
if (!next) return
if (next.nextStage === 'COMPLETE') {
const score = scoreLead(updatedData)
await syncToHubSpot(phone, updatedData, score)
await sendTextMessage(phone,
'Thank you, ' + updatedData.name + '! Our team will reach out within 24 hours.'
)
await clearSession(phone)
} else {
await setSession(phone, { stage: next.nextStage, data: updatedData })
await sendTextMessage(phone, FLOW[next.nextStage].question)
}
}
function scoreLead(data: Record<string, string>): number {
let score = 50
if (data.budget === '3') score += 30 // Over $20k
if (data.budget === '2') score += 15 // $5k–$20k
if (data.company?.length > 3) score += 10
if (data.problem?.length > 30) score += 10
return Math.min(score, 100)
}Appointment Booking
A customer sends a message to book a call. The bot checks real-time availability, presents options, confirms the selection, and sends a pre-approved template confirmation, all without human involvement.
export async function handleBookingFlow(phone: string, message: string) {
const session = await getSession(phone)
if (!session?.bookingStage) {
const slots = await getAvailableSlots() // fetches from your calendar API
const slotList = slots.map((s, i) => (i + 1) + '. ' + s.label).join('
')
await sendTextMessage(phone, 'Available slots:
' + slotList + '
Reply with a number to book.')
await setSession(phone, { bookingStage: 'SELECT_SLOT', slots })
return
}
if (session.bookingStage === 'SELECT_SLOT') {
const index = parseInt(message.trim(), 10) - 1
const slot = session.slots?.[index]
if (!slot) {
await sendTextMessage(phone, 'Please reply with a number from the list.')
return
}
const booking = await createCalendarEvent(phone, slot)
// Send pre-approved confirmation template
await sendTemplate(phone, 'booking_confirmation', [
slot.label,
booking.confirmationCode,
booking.meetingLink,
])
await clearSession(phone)
}
}AI Customer Support with Human Escalation
The AI assistant handles the conversation using your knowledge base. When the AI cannot answer confidently, or when the customer explicitly asks for a human, the conversation is escalated to a live agent.
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function handleAISupport(phone: string, userMessage: string) {
// Check if already escalated to human
const session = await getSession(phone)
if (session?.escalated) {
await notifyAgent(phone, userMessage)
return
}
const history = await getConversationHistory(phone)
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content:
'You are a WhatsApp support agent. Answer concisely, under 200 words. ' +
'If you cannot help or the user asks for a human, reply with exactly: ESCALATE',
},
...history,
{ role: 'user', content: userMessage },
],
max_tokens: 300,
})
const reply = completion.choices[0].message.content ?? ''
if (reply.trim() === 'ESCALATE' || userMessage.toLowerCase().includes('human')) {
await setSession(phone, { escalated: true })
await sendTextMessage(phone, 'Connecting you with a team member. Average wait: 2 minutes.')
await createSupportTicket(phone, history, userMessage)
return
}
await sendTextMessage(phone, reply)
await appendToHistory(phone, userMessage, reply)
}CRM Integrations
HubSpot
Create or update HubSpot contacts from WhatsApp conversations using the HubSpot Node.js SDK. Phone number is the join key, search for an existing contact first to avoid duplicates.
import { Client } from '@hubspot/api-client'
const hubspot = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN })
export async function upsertHubSpotContact(phone: string, data: {
name?: string
company?: string
leadScore?: number
}) {
const search = await hubspot.crm.contacts.searchApi.doSearch({
filterGroups: [{ filters: [{ propertyName: 'phone', operator: 'EQ', value: phone }] }],
properties: ['phone', 'firstname', 'hs_lead_status'],
limit: 1,
after: 0,
sorts: [],
})
const properties = {
phone,
firstname: data.name ?? '',
company: data.company ?? '',
hs_lead_status: 'NEW',
whatsapp_lead: 'true',
lead_score: String(data.leadScore ?? 0),
}
if (search.results.length > 0) {
await hubspot.crm.contacts.basicApi.update(search.results[0].id, { properties })
} else {
await hubspot.crm.contacts.basicApi.create({ properties })
}
}Salesforce
Use the jsforce library to write WhatsApp leads into Salesforce as Lead records. The Phone field is the external identifier for deduplication.
import jsforce from 'jsforce'
const conn = new jsforce.Connection({
loginUrl: process.env.SF_LOGIN_URL,
})
async function getSalesforceConnection() {
await conn.login(process.env.SF_USERNAME!, process.env.SF_PASSWORD! + process.env.SF_TOKEN!)
return conn
}
export async function createSalesforceLead(phone: string, data: {
name: string
company: string
description: string
}) {
const sf = await getSalesforceConnection()
await sf.sobject('Lead').upsert(
{
Phone: phone,
FirstName: data.name.split(' ')[0] ?? data.name,
LastName: data.name.split(' ').slice(1).join(' ') || 'Unknown',
Company: data.company,
Description: data.description,
LeadSource: 'WhatsApp',
},
'Phone'
)
}Zoho CRM
Zoho's REST API uses OAuth 2.0 access tokens. Create leads via the /Leads endpoint and sync conversation notes as Activities linked to the contact record.
Custom CRM via Webhook
If you run a custom CRM or internal database, emit a structured webhook event from the WhatsApp backend whenever a conversation stage changes, lead captured, appointment booked, support resolved. The CRM subscribes to these events and handles its own data updates. This decouples the WhatsApp integration from the CRM implementation, making both easier to change independently.
AI Integrations
OpenAI
The pattern shown in the AI Support automation example above applies broadly. Use GPT-4o for complex conversational support, lead qualification interpretation, and document summarization. Use GPT-4o mini for high-volume, simple message classification and routing, it costs far less and responds faster.
Anthropic Claude
Claude can be substituted for OpenAI in any of the examples above by swapping the SDK. Claude performs particularly well on longer documents and structured extraction tasks, reading a support conversation and producing a structured ticket summary, or analyzing a customer inquiry and determining the correct routing category.
import Anthropic from '@anthropic-ai/sdk'
const claude = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
export async function summarizeConversation(messages: { from: string; body: string }[]) {
const transcript = messages.map((m) => m.from + ': ' + m.body).join('
')
const response = await claude.messages.create({
model: 'claude-opus-4-8',
max_tokens: 300,
messages: [{
role: 'user',
content:
'Summarize this WhatsApp support conversation as: issue (one sentence), ' +
'urgency (low/medium/high), category, and suggested resolution.
' + transcript,
}],
})
return response.content[0].type === 'text' ? response.content[0].text : ''
}Knowledge Base Search
Store your FAQs, product documentation, and policy content in a vector database (Pinecone, pgvector, Weaviate). When a customer asks a question, embed it, retrieve the three most relevant knowledge base articles, and pass them to the AI as context before generating the response. This is the RAG pattern, it keeps your AI grounded in your actual content and prevents hallucination.
Security Considerations
Webhook Signature Validation
Always validate the X-Hub-Signature-256 header on every incoming POST request. Use crypto.timingSafeEqual for the comparison to prevent timing-based attacks. If your raw body is parsed by a framework before the middleware runs, you may need to configure body-parser to preserve the raw buffer for signing.
Access Control
Gate your support dashboard behind authentication. Agents should only see conversations assigned to their team. Implement role-based access control: agents can read and reply, supervisors can reassign and escalate, admins can configure the bot and access analytics. Log every action an agent takes in the dashboard with a timestamp and user ID.
Token Rotation and Secrets Management
Store WHATSAPP_ACCESS_TOKEN, WHATSAPP_APP_SECRET, and WEBHOOK_VERIFY_TOKEN in a secrets manager, never in code or .env files on production servers. Rotate the access token after any team access change or suspected exposure. Use separate Meta Apps for development, staging, and production environments so a compromised staging credential cannot affect production.
Data Protection
WhatsApp conversations contain personal data, phone numbers, names, support content, potentially financial or health information. Encrypt conversation data at rest in your database. Implement a data retention policy and purge conversations older than your policy requires. If you operate under GDPR, establish a lawful basis for processing and document it. Do not send conversation transcripts to third-party AI providers without confirming your privacy policy covers this.
Audit Logging
interface WhatsAppAuditLog {
event: 'message_received' | 'message_sent' | 'template_sent' | 'escalation' | 'lead_created' | 'error'
phone: string
agentId?: string
templateName?: string
durationMs?: number
errorCode?: string
}
export function auditLog(entry: WhatsAppAuditLog) {
console.log(JSON.stringify({
...entry,
service: 'whatsapp',
timestamp: new Date().toISOString(),
env: process.env.NODE_ENV,
}))
}
// Usage
auditLog({ event: 'message_received', phone: '+1234567890' })
auditLog({ event: 'lead_created', phone: '+1234567890', durationMs: 342 })
auditLog({ event: 'escalation', phone: '+1234567890', agentId: 'agent@company.com' })Monitoring and Observability
Sentry
Wrap your webhook handler and message processor in Sentry error tracking. Capture the phone number and message type as context so errors are immediately actionable. Set up alerts for error rate spikes, a sudden increase in webhook processing errors often signals an API change or rate limit issue.
Datadog and Grafana
Emit custom metrics for: messages received per minute, messages sent per minute, AI response latency, CRM sync success rate, escalation rate, and conversation resolution time. Track these in Datadog or Grafana and set alerts when any metric deviates significantly from its 7-day average. A spike in escalation rate often signals the bot is not handling a new question type correctly.
Structured Logging
Every webhook event, outbound message, AI call, CRM write, and error should produce a structured JSON log line. Include phone (hashed for privacy in non-debug environments), event type, duration, and outcome. This makes it possible to reconstruct the full timeline of any conversation for debugging, and to build dashboards from log aggregation tools like Datadog Logs, Loki, or AWS CloudWatch Insights.
Webhook Health Monitoring
Meta will disable your webhook subscription if it consistently returns non-200 responses or times out. Monitor your webhook endpoint's response time and error rate continuously. Set an alert if P95 response time exceeds 3 seconds (your budget before Meta starts retrying). Use a separate health check endpoint that verifies your database connection, Redis connection, and queue state.
Common Mistakes
Not Responding to Webhooks Within 5 Seconds
If your webhook does not return HTTP 200 within 5 seconds, Meta retries the delivery. If it times out repeatedly, Meta disables your webhook subscription and you stop receiving messages. Always acknowledge the webhook immediately and hand the message to an async queue for processing. The handler should do nothing except validate the signature and enqueue the job.
Skipping Webhook Signature Verification
Any attacker who discovers your webhook URL can POST fabricated events and trigger your automation, creating fake leads, triggering messages, or flooding your queue. Signature validation takes five lines of code and prevents this entirely. There is no acceptable reason to skip it.
Sending Non-Approved Templates
Business-initiated messages (outside the 24-hour customer conversation window) must use Meta-approved template messages. Attempting to send free-form text to users outside the 24-hour window returns an error and can result in the business number being flagged. Submit templates for approval before you need them, approval takes 24 to 48 hours.
Poor Rate Limit Handling
WhatsApp Cloud API allows up to 80 messages per second per phone number. Exceeding this returns a 429 error. Implement a rate limiter before your send function. Add exponential backoff on retries so a spike in message volume does not cause a cascade of failed sends and retries that worsens the overload.
No Session Management for Multi-Turn Conversations
Without session state, every incoming message is treated as a new conversation. Lead qualification flows, appointment booking, and support flows all require knowing where the user is in the conversation. Use Redis with TTL-based sessions (expire after 24 hours of inactivity) to track conversation state per phone number.
Production Deployment Checklist
Before going live with a WhatsApp Business API integration, verify each item.
- ✓ WhatsApp Business Account verified and phone number approved by Meta
- ✓ Meta App created with WhatsApp product, webhook URL, and verify token configured
- ✓ Webhook signature validation implemented using X-Hub-Signature-256 and timingSafeEqual
- ✓ WHATSAPP_ACCESS_TOKEN and WHATSAPP_APP_SECRET stored in a secrets manager
- ✓ Webhook handler responds with HTTP 200 in under 500ms (all processing is async)
- ✓ Message queue (Redis / BullMQ / SQS) active and processing jobs reliably
- ✓ Retry logic with exponential backoff for failed Graph API sends
- ✓ Rate limiter in place (max 80 messages/second per phone number)
- ✓ 24-hour conversation window logic implemented, templates used for business-initiated messages
- ✓ All outbound templates submitted and approved in Meta Business Manager
- ✓ Redis session management active with TTL expiry for multi-turn flows
- ✓ Opt-in mechanism implemented before sending any outbound messages
- ✓ User opt-out (STOP keyword) handler removes contact from outbound lists
- ✓ AI escalation to human agent implemented and tested
- ✓ CRM integration tested with real phone numbers in staging
- ✓ Sentry or equivalent error tracking active and capturing webhook errors
- ✓ Structured audit logging for every inbound and outbound event
- ✓ Datadog / Grafana dashboard showing message volume, latency, and error rate
- ✓ Webhook health monitor with alert if P95 response exceeds 3 seconds
- ✓ GDPR / privacy compliance reviewed, data retention policy documented