Skip to main content

Overview

The WhatsAppService provides a complete interface for the WhatsApp Business Cloud API, handling message sending, media operations, webhook verification, and payload parsing.

Class Structure

Constructor

public function __construct(
    $accessToken,
    $phoneNumberId,
    $apiVersion,
    Logger $logger
)
accessToken
string
required
WhatsApp Business API access token
phoneNumberId
string
required
WhatsApp Business phone number ID
apiVersion
string
required
Graph API version (e.g., v17.0, v18.0)
logger
Logger
required
Logger instance for tracking operations

Instantiation Example

webhook.php
if ($credentialService && $credentialService->hasWhatsAppCredentials()) {
    $waCreds = $credentialService->getWhatsAppCredentials();
    $whatsapp = new WhatsAppService(
        $waCreds['access_token'],
        $waCreds['phone_number_id'],
        Config::get('whatsapp.api_version'),
        $logger
    );
} else {
    $whatsapp = new WhatsAppService(
        Config::get('whatsapp.access_token'),
        Config::get('whatsapp.phone_number_id'),
        Config::get('whatsapp.api_version'),
        $logger
    );
}

Messaging Methods

sendMessage()

Sends a text message to a WhatsApp user.
public function sendMessage($to, $message)
to
string
required
Recipient’s phone number in international format (e.g., 573001234567)
message
string
required
Text message content (supports markdown formatting)
Returns: Message ID string or null

Implementation

try {
    $response = $this->client->post($this->phoneNumberId . '/messages', [
        'headers' => [
            'Authorization' => 'Bearer ' . $this->accessToken,
            'Content-Type' => 'application/json'
        ],
        'json' => [
            'messaging_product' => 'whatsapp',
            'to' => $to,
            'type' => 'text',
            'text' => [
                'body' => $message
            ]
        ]
    ]);

    $data = json_decode($response->getBody()->getContents(), true);
    
    $this->logger->info('WhatsApp: Message sent', [
        'to' => $to,
        'message_id' => $data['messages'][0]['id'] ?? null
    ]);

    return $data['messages'][0]['id'] ?? null;
} catch (\Exception $e) {
    $this->logger->error('WhatsApp Send Error: ' . $e->getMessage());
    throw $e;
}

Usage Example

webhook.php
$response = $rag->generateResponse($messageData['text'], $systemPrompt);

if ($response['response']) {
    $messageId = $whatsapp->sendMessage(
        $conversation['phone_number'],
        $response['response']
    );
    
    $logger->info('Response sent', ['message_id' => $messageId]);
}

Markdown Formatting

WhatsApp supports basic markdown in message text:
$message = 
    "*Bold text*\n" .
    "_Italic text_\n" .
    "~Strikethrough~\n" .
    "`Monospace`\n" .
    "```Code block```";

$whatsapp->sendMessage($phoneNumber, $message);

markAsRead()

Marks an incoming message as read.
public function markAsRead($messageId)
messageId
string
required
WhatsApp message ID to mark as read
Returns: Boolean (true on success)

Usage Example

webhook.php
if ($messageData['message_id']) {
    $whatsapp->markAsRead($messageData['message_id']);
}
Marking messages as read updates the read receipt in the WhatsApp conversation, improving user experience.

Media Methods

getMediaUrl()

Retrieves the download URL for a media file.
public function getMediaUrl($mediaId)
mediaId
string
required
WhatsApp media ID from incoming message
Returns: Media URL string or null
try {
    $response = $this->client->get($mediaId, [
        'headers' => [
            'Authorization' => 'Bearer ' . $this->accessToken
        ]
    ]);

    $mediaData = json_decode($response->getBody()->getContents(), true);
    return $mediaData['url'] ?? null;
} catch (\Exception $e) {
    $this->logger->error('WhatsApp Get Media URL Error: ' . $e->getMessage());
    throw $e;
}

downloadMedia()

Downloads media file content.
public function downloadMedia($mediaId)
mediaId
string
required
WhatsApp media ID from incoming message
Returns: Binary file content as string

Implementation

try {
    $mediaUrl = $this->getMediaUrl($mediaId);

    if (!$mediaUrl) {
        throw new \Exception('Media URL not found');
    }

    $fileResponse = $this->client->get($mediaUrl, [
        'headers' => [
            'Authorization' => 'Bearer ' . $this->accessToken
        ]
    ]);

    return $fileResponse->getBody()->getContents();
} catch (\Exception $e) {
    $this->logger->error('WhatsApp Media Download Error: ' . $e->getMessage());
    throw $e;
}

Usage Example

webhook.php
if ($messageData['type'] === 'audio' && isset($messageData['audio_id'])) {
    // Download audio file
    $audioContent = $whatsapp->downloadMedia($messageData['audio_id']);
    
    // Save to disk
    $audioFileName = uniqid('audio_') . '_' . time() . '.ogg';
    $audioPath = $audioDir . '/' . $audioFileName;
    file_put_contents($audioPath, $audioContent);
    
    // Transcribe with OpenAI Whisper
    $transcription = $openai->transcribeAudio($audioContent, 'audio.ogg');
    
    $messageData['text'] = '[Audio] ' . $transcription;
}

Webhook Methods

verifyWebhook()

Verifies webhook subscription requests from WhatsApp.
public function verifyWebhook($mode, $token, $challenge, $verifyToken)
mode
string
required
Hub mode from $_GET['hub_mode']
token
string
required
Hub verify token from $_GET['hub_verify_token']
challenge
string
required
Hub challenge from $_GET['hub_challenge']
verifyToken
string
required
Your configured verify token
Returns: Challenge string if valid, false otherwise

Usage Example

webhook.php
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    $mode = $_GET['hub_mode'] ?? '';
    $token = $_GET['hub_verify_token'] ?? '';
    $challenge = $_GET['hub_challenge'] ?? '';
    
    if ($credentialService && $credentialService->hasWhatsAppCredentials()) {
        $waCreds = $credentialService->getWhatsAppCredentials();
        $verifyToken = $waCreds['verify_token'];
    } else {
        $verifyToken = Config::get('whatsapp.verify_token');
    }
    
    if ($mode === 'subscribe' && $token === $verifyToken) {
        echo $challenge;
        http_response_code(200);
        exit;
    }
    
    http_response_code(403);
    exit;
}

parseWebhookPayload()

Parses incoming webhook payloads into a structured format.
public function parseWebhookPayload($payload)
payload
array
required
Decoded JSON webhook payload
Returns: Structured message data array or null

Supported Message Types

[
    'from' => '573001234567',
    'text' => 'Hello, I need help',
    'message_id' => 'wamid.ABCxyz...',
    'timestamp' => 1678901234,
    'contact_name' => 'John Doe',
    'type' => 'text'
]

Implementation

public function parseWebhookPayload($payload)
{
    if (!isset($payload['entry'][0]['changes'][0]['value'])) {
        return null;
    }

    $value = $payload['entry'][0]['changes'][0]['value'];
    
    if (!isset($value['messages'][0])) {
        return null;
    }

    $message = $value['messages'][0];
    $messageType = $message['type'] ?? 'text';
    
    $data = [
        'from' => $message['from'] ?? null,
        'text' => '',
        'message_id' => $message['id'] ?? null,
        'timestamp' => $message['timestamp'] ?? time(),
        'contact_name' => $value['contacts'][0]['profile']['name'] ?? 'Unknown',
        'type' => $messageType
    ];

    if ($messageType === 'text') {
        $data['text'] = $message['text']['body'] ?? '';
    } elseif ($messageType === 'audio') {
        $data['audio_id'] = $message['audio']['id'] ?? null;
        $data['mime_type'] = $message['audio']['mime_type'] ?? 'audio/ogg';
    }

    return $data;
}

Usage Example

webhook.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $rawBody = file_get_contents('php://input');
    $payload = json_decode($rawBody, true);
    
    $messageData = $whatsapp->parseWebhookPayload($payload);
    
    if (!$messageData) {
        http_response_code(200);
        echo json_encode(['status' => 'ignored']);
        exit;
    }
    
    $logger->info('Webhook received', [
        'type' => $messageData['type'],
        'from_hash' => substr(hash('sha256', $messageData['from'] ?? ''), 0, 12),
        'timestamp' => $messageData['timestamp'] ?? time()
    ]);
    
    // Process message...
}

Webhook Security

Signature Verification

Validate webhook authenticity using HMAC-SHA256:
webhook.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $rawBody = file_get_contents('php://input');
    
    if ($credentialService && $credentialService->hasWhatsAppCredentials()) {
        $waCreds = $credentialService->getWhatsAppCredentials();
        $appSecret = $waCreds['app_secret'];
    } else {
        $appSecret = Config::get('whatsapp.app_secret');
    }
    
    if ($appSecret) {
        $signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
        $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $appSecret);
        
        if (!hash_equals($expected, $signature)) {
            http_response_code(401);
            exit('Unauthorized');
        }
    } else {
        $logger->warning('Webhook signature validation SKIPPED');
    }
    
    $payload = json_decode($rawBody, true);
    // Process payload...
}
Always validate webhook signatures in production to prevent spoofed requests:
if (!$appSecret) {
    throw new \Exception('App secret must be configured for webhook security');
}

Message Type Handling

Unsupported Types

webhook.php
$unsupportedTypes = ['image', 'document', 'location', 'video', 'sticker', 'contacts'];

if (in_array($messageData['type'], $unsupportedTypes)) {
    $unsupportedMsg = 
        "Lo siento, por el momento solo puedo procesar mensajes de *texto*. " .
        "Por favor, envíame tu consulta en un mensaje de texto.";
    
    $whatsapp->sendMessage($messageData['from'], $unsupportedMsg);
    
    $conversationService->addMessage(
        $conversation['id'],
        'bot',
        $unsupportedMsg,
        null,
        null,
        1.0
    );
    
    http_response_code(200);
    echo json_encode(['status' => 'unsupported_media_type']);
    exit;
}

Audio Handling

See Media Methods above for audio processing example.

HTTP Client Configuration

The service uses GuzzleHTTP configured for Facebook Graph API:
$this->client = new Client([
    'base_uri' => 'https://graph.facebook.com/' . $this->apiVersion . '/',
    'timeout' => 30,
    'verify' => false
]);
SSL verification is disabled. Enable it in production:
'verify' => true

Configuration

Configure WhatsApp settings in config/config.php:
config/config.php
return [
    'whatsapp' => [
        'access_token' => env('WHATSAPP_ACCESS_TOKEN'),
        'phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'),
        'verify_token' => env('WHATSAPP_VERIFY_TOKEN'),
        'app_secret' => env('WHATSAPP_APP_SECRET'),
        'api_version' => env('WHATSAPP_API_VERSION', 'v17.0')
    ]
];

Error Handling

Rate Limiting

WhatsApp enforces rate limits. Handle 429 errors gracefully:
try {
    $whatsapp->sendMessage($to, $message);
} catch (\GuzzleHttp\Exception\ClientException $e) {
    if ($e->getResponse()->getStatusCode() === 429) {
        $logger->warning('WhatsApp rate limit exceeded', ['to' => $to]);
        
        // Queue message for retry
        $db->insert('message_queue', [
            'phone_number' => $to,
            'message' => $message,
            'retry_after' => time() + 60
        ]);
    } else {
        throw $e;
    }
}

Failed Message Logging

try {
    $messageId = $whatsapp->sendMessage($to, $message);
    if (!$messageId) {
        $logger->error('Message sent but no ID returned', ['to' => $to]);
    }
} catch (\Exception $e) {
    $logger->error('WhatsApp Send Error', [
        'to' => $to,
        'error' => $e->getMessage()
    ]);
    
    // Store failed message for manual review
    $db->insert('failed_messages', [
        'phone_number' => $to,
        'message' => $message,
        'error' => $e->getMessage(),
        'timestamp' => date('Y-m-d H:i:s')
    ]);
}

Best Practices

Configure app_secret and verify all incoming requests:
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Unauthorized');
}
WhatsApp may send duplicate webhooks. Check message IDs:
if ($messageData['message_id']) {
    $existing = $db->fetchOne(
        'SELECT id FROM messages WHERE message_id = :message_id',
        [':message_id' => $messageData['message_id']]
    );
    
    if ($existing) {
        http_response_code(200);
        echo json_encode(['status' => 'already_processed']);
        exit;
    }
}
Improves user experience:
if ($messageData['message_id']) {
    $whatsapp->markAsRead($messageData['message_id']);
}
Webhooks must respond within 20 seconds. Process heavy tasks asynchronously:
// Respond immediately
http_response_code(200);
echo json_encode(['status' => 'received']);

// Process in background
$db->insert('message_queue', [
    'phone_number' => $messageData['from'],
    'message' => $messageData['text'],
    'status' => 'queued'
]);

Testing

Test Webhook Verification

curl -X GET "https://your-domain.com/webhook.php?hub.mode=subscribe&hub.verify_token=YOUR_VERIFY_TOKEN&hub.challenge=CHALLENGE_STRING"
# Should return: CHALLENGE_STRING

Test Message Sending

$whatsapp = new WhatsAppService(
    $accessToken,
    $phoneNumberId,
    'v17.0',
    $logger
);

$messageId = $whatsapp->sendMessage(
    '573001234567',
    'Test message from bot'
);

echo "Message sent: {$messageId}\n";

OpenAI Service

Transcribes audio messages with Whisper

RAG Service

Generates responses sent via WhatsApp

Next Steps

Webhook Setup

Configure WhatsApp webhook in Meta dashboard

Audio Messages

Enable audio message transcription

Build docs developers (and LLMs) love