This guide covers receiving and verifying webhook events from the WhatsApp Business API.
Overview
Meta delivers webhook events (incoming messages, delivery receipts, status updates) as HTTP POST requests signed with HMAC-SHA256. The SDK provides two levels of integration:
WhatsApp.Webhook— Low-level signature verification functionsWhatsApp.WebhookPlug— Drop-in Phoenix Plug with event dispatch
Quick Start with WebhookPlug
The fastest way to handle webhooks in a Phoenix app:
1. Create a handler module
defmodule MyApp.WhatsAppHandler do
@behaviour WhatsApp.WebhookPlug.Handler
require Logger
@impl true
def handle_event(%{"messages" => messages} = _event) do
Enum.each(messages, fn message ->
Logger.info("Received message: #{inspect(message)}")
end)
:ok
end
def handle_event(%{"statuses" => statuses} = _event) do
Enum.each(statuses, fn status ->
Logger.info("Status update: #{status["status"]} for #{status["id"]}")
end)
:ok
end
def handle_event(_event), do: :ok
end2. Add the route in your router
# In your Phoenix router
forward "/webhook/whatsapp", WhatsApp.WebhookPlug,
app_secret: Application.compile_env!(:my_app, :whatsapp_app_secret),
verify_token: Application.compile_env!(:my_app, :whatsapp_verify_token),
handler: MyApp.WhatsAppHandler3. Configure the raw body reader
Phoenix consumes the request body during JSON parsing, but signature verification needs the raw body. Add a cache body reader to your endpoint:
# lib/my_app_web/endpoint.ex
plug Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
body_reader: {MyApp.CacheBodyReader, :read_body, []},
json_decoder: JSON# lib/my_app_web/cache_body_reader.ex
defmodule MyApp.CacheBodyReader do
def read_body(conn, opts) do
case Plug.Conn.read_body(conn, opts) do
{:ok, body, conn} ->
{:ok, body, Plug.Conn.assign(conn, :raw_body, body)}
other ->
other
end
end
endThe plug checks conn.assigns[:raw_body] first, then falls back to Plug.Conn.read_body/1.
Low-Level Verification
For custom setups or non-Phoenix apps, use WhatsApp.Webhook directly.
Subscription Verification
When you register a webhook URL in the Meta dashboard, Meta sends a GET request to verify your endpoint:
case WhatsApp.Webhook.verify_subscription(params, "my_verify_token") do
{:ok, challenge} ->
# Respond with 200 and the challenge string
send_resp(conn, 200, challenge)
{:error, _error} ->
send_resp(conn, 403, "Forbidden")
endThe function handles both dot notation (hub.mode) and underscore notation (hub_mode) parameter formats.
Signature Validation
Validate that a POST payload was signed by Meta:
signature = get_req_header(conn, "x-hub-signature-256") |> List.first("")
if WhatsApp.Webhook.valid?(raw_body, signature, app_secret) do
# Payload is authentic — process events
{:ok, payload} = JSON.decode(raw_body)
process_events(payload)
else
send_resp(conn, 403, "Invalid signature")
endComputing Signatures
For testing or debugging, compute a signature manually:
sig = WhatsApp.Webhook.compute_signature(payload, app_secret)
# => "0329a06b62cd16b33eb6792be8c60b158d89a2ee3a876fce9a881ebb488c0914"Webhook Payload Structure
Meta delivers webhook payloads with this structure:
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "WABA_ID",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "phone_number_id": "12345" },
"messages": [...]
}
}
]
}
]
}The WebhookPlug extracts each "value" map from the "changes" list and passes it to your handler's handle_event/1 callback.
Security Notes
- Always validate signatures in production. Never skip verification.
- The SDK uses constant-time comparison (
crypto.hash_equals) to prevent timing attacks. - Store your
app_secretsecurely (environment variables, not source code). - The
verify_tokenis a shared secret you define — use a strong random value.