WhatsApp Business Integration

Ferro integrates with the WhatsApp Business Cloud API (Meta Graph API v23.0) via the ferro-whatsapp crate. The integration covers outbound messaging (text and template messages), inbound webhook processing with HMAC verification, sender identity routing, and message deduplication.

Prerequisites

  • A Meta Developer account with a WhatsApp Business app
  • A verified WhatsApp Business phone number
  • A permanent access token (system user token)

WhatsApp Business API access requires Meta app review for production use.

Installation

Add the whatsapp feature to your ferro-rs dependency:

[dependencies]
ferro-rs = { version = "0.1", features = ["whatsapp"] }

Generate the scaffold files:

ferro make:whatsapp

This creates three files in src/whatsapp/:

  • mod.rs — initialization function
  • webhook.rs — GET challenge verification and POST webhook handlers
  • listeners.rs — event listener stubs for inbound events

Configuration

Set the following environment variables:

VariableSourceRequired
WHATSAPP_APP_SECRETMeta Developer Dashboard → App Settings → Basic → App SecretYes
WHATSAPP_ACCESS_TOKENMeta Developer Dashboard → WhatsApp → API Setup → Permanent TokenYes
WHATSAPP_PHONE_NUMBER_IDMeta Developer Dashboard → WhatsApp → API Setup → Phone Number IDYes
WHATSAPP_VERIFY_TOKENA secret string you choose for webhook verificationYes
WHATSAPP_APP_SECRET=your_app_secret
WHATSAPP_ACCESS_TOKEN=your_permanent_token
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_VERIFY_TOKEN=my_secret_verify_token

Call init() from bootstrap.rs:

#![allow(unused)]
fn main() {
crate::whatsapp::init();
}

The generated src/whatsapp/mod.rs uses WhatsAppConfig::from_env() with an is_owner closure:

#![allow(unused)]
fn main() {
use ferro::WhatsApp;

pub fn init() {
    let config = ferro::WhatsAppConfig::from_env(Box::new(|phone| {
        // phone is E.164 without '+', e.g. "393401234567"
        phone == std::env::var("OWNER_PHONE").as_deref().unwrap_or("")
    }))
    .expect("WhatsApp configuration missing.");
    WhatsApp::init(config);
}
}

Sending Messages

Text Messages

#![allow(unused)]
fn main() {
use ferro::{WhatsApp, WhatsAppRawMessage, WhatsAppSendResult};

let result: WhatsAppSendResult = WhatsApp::send(
    "393401234567",
    WhatsAppRawMessage::Text {
        body: "Hello from Ferro!".to_string(),
    },
)
.await?;

// result.wamid is the WhatsApp message ID for delivery status correlation
println!("Sent with wamid: {}", result.wamid);
}

Note: WhatsAppRawMessage is the raw ferro_whatsapp::Message enum (re-exported under this name to avoid colliding with ferro_notifications::WhatsAppMessage, the notification-system wrapper). Use WhatsAppRawMessage when calling WhatsApp::send directly; use WhatsAppMessage (with its text()/template() builders) when implementing Notification::to_whatsapp for the notification dispatcher.

Template Messages

Template messages are required for outbound messages to contacts who have not messaged you in the last 24 hours. Templates must be approved by Meta before use.

#![allow(unused)]
fn main() {
use ferro::{WhatsApp, WhatsAppRawMessage};

WhatsApp::send(
    "393401234567",
    WhatsAppRawMessage::Template {
        name: "order_confirmation".to_string(),
        language: "it".to_string(),
        parameters: vec![
            serde_json::json!({"type": "text", "text": "ORD-12345"}),
            serde_json::json!({"type": "currency", "currency": {"fallback_value": "€29,90", "code": "EUR", "amount_1000": 29900}}),
        ],
    },
)
.await?;
}

Phone numbers are E.164 format without the + prefix (e.g., "393401234567" not "+39 340 123 4567").

Webhooks

Route Registration

Register both routes in src/routes.rs:

#![allow(unused)]
fn main() {
use crate::whatsapp::webhook::{whatsapp_webhook, whatsapp_webhook_verify};

get!("/whatsapp/webhook", whatsapp_webhook_verify)
post!("/whatsapp/webhook", whatsapp_webhook)
}

Webhook URL Configuration

In Meta Developer Dashboard → Your App → WhatsApp → Configuration:

  1. Set Callback URL to https://yourdomain.com/whatsapp/webhook
  2. Set Verify Token to the value of WHATSAPP_VERIFY_TOKEN
  3. Subscribe to messages and message_status webhook fields

Webhook Processing Flow

The generated src/whatsapp/webhook.rs follows this flow:

  1. GET /whatsapp/webhook — Meta sends a challenge to verify the endpoint. The handler checks the verify token and responds with hub.challenge as plain text.

  2. POST /whatsapp/webhook — Inbound messages and status updates arrive here. The handler:

    • Reads the raw body
    • Verifies the HMAC-SHA256 signature from x-hub-signature-256
    • Acknowledges immediately with {"received": true}
    • Dispatches a ProcessWhatsAppWebhook job to the queue for async processing

Always verify HMAC before parsing JSON — Meta signs the raw bytes, and JSON re-serialization can alter whitespace or Unicode escaping.

Event Handling

ProcessWhatsAppWebhook parses the Meta webhook envelope and dispatches typed ferro-events:

WhatsAppTextReceived

Emitted for each inbound text message:

#![allow(unused)]
fn main() {
use ferro::{async_trait, EventError, Listener, WhatsAppTextReceived};

pub struct HandleInboundMessage;

#[async_trait]
impl Listener<WhatsAppTextReceived> for HandleInboundMessage {
    async fn handle(&self, event: &WhatsAppTextReceived) -> Result<(), EventError> {
        println!("From: {:?}", event.sender_identity);
        println!("Text: {}", event.text);
        println!("Wamid: {}", event.wamid);
        // event.raw contains the full Meta JSON payload
        Ok(())
    }
}
}

WhatsAppTextReceived fields:

  • wamid: String — WhatsApp message ID
  • sender_identity: SenderIdentityOwner(phone) or Customer(phone)
  • text: String — message body
  • timestamp: chrono::DateTime<Utc> — message timestamp
  • raw: serde_json::Value — full Meta JSON payload

WhatsAppStatusUpdate

Emitted for delivery status updates (sent, delivered, read, failed):

#![allow(unused)]
fn main() {
use ferro::{async_trait, DeliveryStatus, EventError, Listener, WhatsAppStatusUpdate};

pub struct HandleDeliveryStatus;

#[async_trait]
impl Listener<WhatsAppStatusUpdate> for HandleDeliveryStatus {
    async fn handle(&self, event: &WhatsAppStatusUpdate) -> Result<(), EventError> {
        match event.status {
            DeliveryStatus::Delivered => {
                println!("Message {} delivered", event.wamid);
            }
            DeliveryStatus::Read => {
                println!("Message {} read", event.wamid);
            }
            _ => {}
        }
        Ok(())
    }
}
}

WhatsAppStatusUpdate fields:

  • wamid: String — correlates with SendResult.wamid from WhatsApp::send()
  • status: DeliveryStatusSent, Delivered, Read, Failed, or Unknown
  • timestamp: chrono::DateTime<Utc> — status update timestamp

Register listeners in bootstrap.rs:

#![allow(unused)]
fn main() {
use crate::whatsapp::listeners::{HandleDeliveryStatus, HandleInboundMessage};

ferro::register_listener::<WhatsAppTextReceived, HandleInboundMessage>();
ferro::register_listener::<WhatsAppStatusUpdate, HandleDeliveryStatus>();
}

Sender Identity

The is_owner closure in WhatsAppConfig classifies each incoming phone number as either SenderIdentity::Owner or SenderIdentity::Customer. Identity is resolved before the event is dispatched, so listeners receive pre-classified events.

Phone numbers arrive from Meta in E.164 format without the + prefix:

#![allow(unused)]
fn main() {
WhatsAppConfig::from_env(Box::new(|phone| {
    // phone is "393401234567", not "+393401234567"
    phone == "393401234567"
}))
}

For DB-backed owner lookups:

#![allow(unused)]
fn main() {
WhatsAppConfig::from_env(Box::new(|phone| {
    // sync check against cached owner list
    OWNER_PHONES.contains(phone)
}))
}

SenderIdentity carries the phone number:

#![allow(unused)]
fn main() {
match event.sender_identity {
    SenderIdentity::Owner(phone) => println!("Message from owner: {phone}"),
    SenderIdentity::Customer(phone) => println!("Message from customer: {phone}"),
}
}

Deduplication

Meta may deliver the same webhook multiple times. Use InMemoryDeduplicationStore to deduplicate by wamid before dispatching the job:

#![allow(unused)]
fn main() {
use ferro::{InMemoryDeduplicationStore, DeduplicationStore, ProcessWhatsAppWebhook, queue_dispatch};

static DEDUP: std::sync::OnceLock<InMemoryDeduplicationStore> = std::sync::OnceLock::new();

fn dedup_store() -> &'static InMemoryDeduplicationStore {
    DEDUP.get_or_init(InMemoryDeduplicationStore::new)
}

#[handler]
pub async fn whatsapp_webhook(req: Request) -> Response {
    // ... HMAC verification ...
    let payload: serde_json::Value = serde_json::from_str(&body)
        .map_err(|_| HttpResponse::text("Invalid JSON").status(400))?;

    // Deduplicate by wamid before queuing
    if let Some(wamid) = payload["entry"][0]["changes"][0]["value"]["messages"][0]["id"].as_str() {
        let is_duplicate = dedup_store()
            .check_and_insert(wamid)
            .await
            .unwrap_or(false);
        if is_duplicate {
            return Ok(HttpResponse::json(serde_json::json!({"received": true})));
        }
    }

    let job = ProcessWhatsAppWebhook { payload_json: body };
    queue_dispatch(job).await
        .map_err(|e| HttpResponse::text(format!("Queue error: {e}")).status(500))?;

    Ok(HttpResponse::json(serde_json::json!({"received": true})))
}
}

InMemoryDeduplicationStore uses a DashMap with 5-minute TTL auto-expiry, which covers all reasonable Meta retry windows. For cross-restart deduplication, implement the DeduplicationStore trait with a Redis-backed store.

Deduplication is the application's responsibility — ProcessWhatsAppWebhook does not check it internally.

MCP Tools

Two MCP tools are available for WhatsApp development assistance.

whatsapp_config_status

Reports which environment variables are present or missing, and whether the scaffold directory (src/whatsapp/) exists. Use this to diagnose configuration issues before debugging webhook delivery or message sending failures.

whatsapp_webhook_events

Scans src/whatsapp/listeners.rs for Listener<T> implementations and returns the event type and listener struct name for each. Use this to audit which WhatsApp events have listeners registered.

Not Supported in v1

  • Media messages (images, documents, audio)
  • Interactive messages (buttons, list messages)
  • Multi-phone-number support (multiple WhatsApp Business accounts)

These can be added as new Message enum variants in a future phase without breaking changes.