Docs / WhatsApp

WhatsApp

Answer the same grounded assistant on a WhatsApp business number via a signed webhook. The inbound half ships and is fully testable. The outbound send is a pluggable seam, so no live Meta call leaves your store until you wire a provider up.

What ships, and what does not

The plugin owns the inbound half of WhatsApp Business on Meta's Cloud API. It handles Meta's webhook verify handshake, checks the message signature, parses the first text message, and routes it into the same agent loop that powers the web widget. The reply text is then handed to a send seam.

The plugin does not make the live Meta HTTP call. Going live needs a Meta WhatsApp Business account plus tokens, supplied by a provider that implements the fahad_ai_whatsapp_send filter. With no provider hooked, the seam is a no-op, so nothing is ever sent until a merchant wires one up.

The channel is off by default. It is opt-in because every inbound text drives a billable agent turn, and because a public webhook should never start processing until you have deliberately enabled it.

The webhook endpoint

One endpoint on the fahad-ai/v1 namespace serves both methods Meta uses, matching the webhook contract:

GET  /wp-json/fahad-ai/v1/whatsapp   subscription verify handshake
POST /wp-json/fahad-ai/v1/whatsapp   signed inbound message deliveries

The endpoint is public because Meta cannot send a WordPress nonce. The security boundary is not the chat nonce here. It is the verify token on the GET handshake and the X-Hub-Signature-256 HMAC on the POST body, both enforced inside the handlers before anything else happens.

GET: the verify handshake

When you subscribe the webhook, Meta calls it with hub.mode=subscribe, the verify token you configured in the App dashboard, and a hub.challenge nonce. The plugin echoes the challenge back only when both conditions hold:

It fails closed. An empty configured token can never match, so an unconfigured site cannot be tricked into confirming a webhook. A wrong token or any non-subscribe mode returns a 403.

POST: signed inbound messages

Order here is load-bearing, security first:

  1. Signature first. Meta signs the raw request body with HMAC-SHA256, keyed by your Meta App Secret, and sends it as sha256=<hexdigest>. The plugin recomputes that over the raw body and compares with hash_equals, a constant-time check, before any parsing or agent work. A missing or bad signature, or an unconfigured app secret, returns 403 and nothing is processed or sent.
  2. Opt-in gate. If the channel is disabled, a valid signed delivery is acknowledged with 200, so Meta does not retry, but it is not processed.
  3. Parse one text message. The plugin reads the first text message from Meta's entry[].changes[].value.messages[] shape. Status webhooks, reactions, media, and empty text are acknowledged without running the agent.
  4. Route as a guest, then reply. The text runs through the same non-streaming agent loop as the web widget, and the reply is handed to the send seam, addressed to the sender.

Once the signature passes, the endpoint always returns 200, even when nothing is sent. A non-200 makes Meta retry, and you never want a retry storm for a benign no text or no provider case.

The signature, concretely

The expected header value is the App Secret keyed HMAC of the exact raw body:

expected = 'sha256=' . hash_hmac('sha256', $raw_body, $app_secret)
verified = hash_equals(expected, $request_header['X-Hub-Signature-256'])

If the App Secret is not configured, verification returns false and the inbound is rejected. An inbound is processed only when its signature is provably valid.

Identity stays a guest

A WhatsApp sender is treated as a guest. The plugin never auto-trusts a phone number as a logged-in WooCommerce customer, so the central login gate keeps personal-data tools blocked for an unverified identity. Building a verified phone to customer mapping is out of scope for this channel.

Secrets, the verify token and the app secret, live in options. They are never sent to the browser or fed to the model, and no phone numbers or message text are logged.

The outbound send seam

The send is a documented extension point. By default, with no provider hooked, the filter returns null and nothing is sent. A provider, a companion plugin or a future add-on that holds the merchant's WhatsApp phone number id and access token, registers the filter and makes the live Meta call:

add_filter( 'fahad_ai_whatsapp_send', function ( $result, $to, $text, $ctx ) {
    // POST https://graph.facebook.com/<ver>/<phone_number_id>/messages
    //   Authorization: Bearer <access token>
    //   body: { messaging_product: 'whatsapp', to: $to,
    //           type: 'text', text: { body: $text } }
    // return [ 'sent' => true, 'id' => $wamid ] on success.
    return $result;
}, 10, 4 );

Keeping the live HTTP call out of core keeps the access token in the provider, mirrors the way the wallet provider is decoupled, and lets this scaffolding ship and be fully unit-tested without any Meta credentials.

Replies are the same grounded answers as the web widget. There is no separate WhatsApp brain. The shared agent core means a question on WhatsApp is answered from your live store data, with the same tools and the same turn-level cost controls that govern the widget.

Settings

Three options drive the channel, all set in the plugin admin: