Series 6 — Building a WhatsApp AI Agent • Part 1 of 10

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 statuses in 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.challenge token. Handle it or your webhook will never be activated.
  • TTL on dedup table — Clean up wa_processed_messages entries older than 24 hours. Meta doesn't retry that far back.