The WhatsApp Business API delivers messages via webhooks. Getting the webhook right — HMAC verification, 20-second response requirement, deduplication — is the foundation everything else depends on. This article covers the complete webhook architecture in PHP.
Payload Structure
Every WhatsApp webhook arrives as a JSON POST with this structure:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"value": {
"messaging_product": "whatsapp",
"metadata": {"display_phone_number": "...", "phone_number_id": "..."},
"contacts": [{"profile": {"name": "Rajesh"}, "wa_id": "919876543210"}],
"messages": [{
"id": "wamid.xxx",
"from": "919876543210",
"timestamp": "1716400000",
"type": "text",
"text": {"body": "What is the status of my case?"}
}]
},
"field": "messages"
}]
}]
}
HMAC-SHA256 Signature Verification
function verify_meta_signature(string $payload, string $signatureHeader, string $appSecret): bool
{
if (!str_starts_with($signatureHeader, 'sha256=')) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $payload, $appSecret);
return hash_equals($expected, $signatureHeader);
}
// In your webhook handler
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (!verify_meta_signature($rawBody, $signature, META_APP_SECRET)) {
http_response_code(403);
exit;
}
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
http_response_code(400);
exit;
}
Responding in Under 20 Seconds
Meta retries webhooks that don't return 200 within 20 seconds. Since LLM processing takes 15-30 seconds, the webhook must return immediately and hand off to a background worker.
// Deduplicate first
$msgId = $payload['entry'][0]['changes'][0]['value']['messages'][0]['id'] ?? null;
if ($msgId && is_duplicate($msgId, $pdo)) {
http_response_code(200);
echo json_encode(['status' => 'duplicate']);
exit;
}
// Mark as seen to prevent re-processing on retry
mark_as_seen($msgId, $pdo);
// Enqueue for async processing
enqueue_message($payload, $pdo);
// Return 200 IMMEDIATELY — before any AI processing
http_response_code(200);
echo json_encode(['status' => 'accepted']);
Deduplication with wa_msg_id
CREATE TABLE wa_processed_messages (
wa_msg_id VARCHAR(255) NOT NULL PRIMARY KEY,
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_processed_at (processed_at) -- for TTL cleanup
);
function is_duplicate(string $msgId, PDO $pdo): bool
{
$stmt = $pdo->prepare('SELECT 1 FROM wa_processed_messages WHERE wa_msg_id = ?');
$stmt->execute([$msgId]);
return (bool) $stmt->fetchColumn();
}
function mark_as_seen(string $msgId, PDO $pdo): void
{
$pdo->prepare('INSERT IGNORE INTO wa_processed_messages (wa_msg_id) VALUES (?)')
->execute([$msgId]);
}
What to Watch For
- Status updates vs messages — Meta also sends delivery status webhooks (sent/delivered/read/failed). Check for
statusesin the payload and handle them separately — do not try to process them as messages. - Verification challenge — Meta sends a GET request to verify your endpoint with a
hub.challengetoken. Handle it or your webhook will never be activated. - TTL on dedup table — Clean up
wa_processed_messagesentries older than 24 hours. Meta doesn't retry that far back.