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 functionwebhook.rs— GET challenge verification and POST webhook handlerslisteners.rs— event listener stubs for inbound events
Configuration
Set the following environment variables:
| Variable | Source | Required |
|---|---|---|
WHATSAPP_APP_SECRET | Meta Developer Dashboard → App Settings → Basic → App Secret | Yes |
WHATSAPP_ACCESS_TOKEN | Meta Developer Dashboard → WhatsApp → API Setup → Permanent Token | Yes |
WHATSAPP_PHONE_NUMBER_ID | Meta Developer Dashboard → WhatsApp → API Setup → Phone Number ID | Yes |
WHATSAPP_VERIFY_TOKEN | A secret string you choose for webhook verification | Yes |
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:
WhatsAppRawMessageis the rawferro_whatsapp::Messageenum (re-exported under this name to avoid colliding withferro_notifications::WhatsAppMessage, the notification-system wrapper). UseWhatsAppRawMessagewhen callingWhatsApp::senddirectly; useWhatsAppMessage(with itstext()/template()builders) when implementingNotification::to_whatsappfor 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:
- Set Callback URL to
https://yourdomain.com/whatsapp/webhook - Set Verify Token to the value of
WHATSAPP_VERIFY_TOKEN - Subscribe to
messagesandmessage_statuswebhook fields
Webhook Processing Flow
The generated src/whatsapp/webhook.rs follows this flow:
-
GET
/whatsapp/webhook— Meta sends a challenge to verify the endpoint. The handler checks the verify token and responds withhub.challengeas plain text. -
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
ProcessWhatsAppWebhookjob 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 IDsender_identity: SenderIdentity—Owner(phone)orCustomer(phone)text: String— message bodytimestamp: chrono::DateTime<Utc>— message timestampraw: 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 withSendResult.wamidfromWhatsApp::send()status: DeliveryStatus—Sent,Delivered,Read,Failed, orUnknowntimestamp: 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.