Skip to main content

Overview

This guide covers message event handling, decryption, and receipt management in whatsapp-rust.

Event System

Subscribing to Events

Use the Bot API to handle events:
use whatsapp_rust::bot::Bot;
use wacore::types::events::Event;

let bot = Bot::builder()
    .with_backend(backend)
    .with_transport_factory(transport_factory)
    .with_http_client(http_client)
    .on_event(|event, client| async move {
        match &*event {
            Event::Message(message, info) => {
                println!("📨 Message from: {}", info.source.sender);
                println!("💬 Text: {:?}", message.text_content());
            }
            Event::Connected(_) => {
                println!("✅ Connected!");
            }
            _ => {}
        }
    })
    .build()
    .await?;
See Bot API reference for full details.

Available Events

pub enum Event {
    /// Successfully decrypted message
    Message(Box<wa::Message>, Arc<MessageInfo>),
    
    /// Message that couldn't be decrypted
    UndecryptableMessage(UndecryptableMessage),
    
    /// Client connected to WhatsApp
    Connected(Connected),
    
    /// Client disconnected
    Disconnected(Disconnected),
    
    /// Logged out (session invalidated)
    LoggedOut(LoggedOut),
    
    /// Pairing QR code for scanning
    PairingQrCode { code: String, timeout: Duration },
    
    /// Receipt (delivery, read, played)
    Receipt(Receipt),
    
    // ... other events
}

Message Structure

MessageInfo

Every message event includes metadata:
use crate::types::message::MessageInfo;

// Access message metadata
println!("Message ID: {}", info.id);
println!("Timestamp: {}", info.timestamp);
println!("Chat: {}", info.source.chat);
println!("Sender: {}", info.source.sender);
println!("From me: {}", info.source.is_from_me);

// Check if it's a group message
if info.source.chat.is_group() {
    println!("Group message from participant: {}", info.source.sender);
}

// Check ephemeral (disappearing) message duration
if let Some(expiration) = info.ephemeral_expiration {
    println!("Disappearing message timer: {}s", expiration);
}

// Check if this message was recovered via PDO
if let Some(request_id) = &info.unavailable_request_id {
    println!("Recovered via PDO request: {}", request_id);
}
The ephemeral_expiration field contains the disappearing messages timer in seconds, extracted from the message’s contextInfo.expiration. This tells you how long the message will be visible before it auto-deletes. Use this value when sending replies to the same chat via SendOptions.ephemeral_expiration. The unavailable_request_id field is set when a message was recovered via PDO rather than normal decryption. It contains the PDO request message ID, which you can use to correlate recovered messages with the original UndecryptableMessage event.

Message Content Extraction

Use the MessageExt trait to extract content:
use wacore::proto_helpers::MessageExt;

// Get text content (conversation or extended_text_message)
if let Some(text) = message.text_content() {
    println!("Text: {}", text);
}

// Get media caption
if let Some(caption) = message.get_caption() {
    println!("Caption: {}", caption);
}

// Get base message (unwrap ephemeral/view-once/edited wrappers)
let base = message.get_base_message();

// Check wrapper types
if message.is_ephemeral() {
    println!("This is a disappearing message");
}
if message.is_view_once() {
    println!("This is a view-once message");
}

// Get the ephemeral timer from the message itself
if let Some(exp) = message.get_ephemeral_expiration() {
    println!("Disappears after {}s", exp);
}

// Access message context info (thread metadata, bot info, etc.)
if let Some(ctx_info) = &message.message_context_info {
    if let Some(thread_id) = &ctx_info.thread_id {
        println!("Thread: {}", thread_id);
    }
}
See WAProto API reference for the full message type hierarchy.

Message Types

Text Messages

match event {
    Event::Message(message, info) => {
        // Simple text
        if let Some(text) = &message.conversation {
            println!("Text: {}", text);
        }
        
        // Extended text (with links, formatting)
        if let Some(ext) = &message.extended_text_message {
            if let Some(text) = &ext.text {
                println!("Extended text: {}", text);
            }
            if let Some(url) = &ext.matched_text {
                println!("Contains link: {}", url);
            }
        }
    }
    _ => {}
}

Media Messages

if let Some(img) = &message.image_message {
    println!("📷 Image message");
    if let Some(caption) = &img.caption {
        println!("Caption: {}", caption);
    }
    
    // Download the image
    let data = client.download(img.as_ref()).await?;
    std::fs::write("image.jpg", data)?;
}

if let Some(video) = &message.video_message {
    println!("🎥 Video message");
}

if let Some(audio) = &message.audio_message {
    println!("🎵 Audio message");
    if audio.ptt() {
        println!("This is a voice message");
    }
}

if let Some(doc) = &message.document_message {
    println!("📄 Document: {}", doc.file_name.as_deref().unwrap_or("unknown"));
}

if let Some(sticker) = &message.sticker_message {
    println!("🎨 Sticker");
}
See Media Handling Guide for download details.

Reactions

if let Some(reaction) = &message.reaction_message {
    if let Some(emoji) = &reaction.text {
        println!("👍 Reaction: {}", emoji);
    } else {
        println!("Reaction removed");
    }
    
    if let Some(key) = &reaction.key {
        println!("Reacted to message: {:?}", key.id);
    }
}

Quoted Messages

if let Some(ext) = &message.extended_text_message {
    if let Some(context) = &ext.context_info {
        if let Some(quoted) = &context.quoted_message {
            println!("💬 This is a reply");
            println!("Original message ID: {:?}", context.stanza_id);
            println!("Original sender: {:?}", context.participant);
            
            // Access quoted content
            if let Some(quoted_text) = quoted.text_content() {
                println!("Replying to: {}", quoted_text);
            }
        }
    }
}

Message Unwrapping

DeviceSentMessage handling

When you send a message from one device, other devices receive it as a DeviceSentMessage wrapper. The library automatically unwraps this and merges messageContextInfo from both the outer envelope and inner message:
// The library handles this automatically - you receive the inner message directly
match event {
    Event::Message(message, info) => {
        // If this was originally a DeviceSentMessage, the library has:
        // 1. Extracted the inner message content
        // 2. Merged message_context_info from outer + inner
        //    - message_secret: inner value, fallback to outer
        //    - limit_sharing_v2: always from outer
        //    - thread_id: inner if non-empty, otherwise outer
        //    - bot_metadata: inner value, fallback to outer
        
        // You can safely access the merged context
        if let Some(ctx) = &message.message_context_info {
            println!("Thread: {:?}", ctx.thread_id);
        }
    }
    _ => {}
}
Self-sent messages synced from your primary device are automatically unwrapped. The messageContextInfo is merged following WhatsApp Web’s logic, ensuring metadata like thread IDs and bot metadata are preserved correctly.

Decryption

Automatic decryption

Messages are automatically decrypted by the client:
// The Event::Message already contains decrypted content
match event {
    Event::Message(message, info) => {
        // Message is already decrypted and ready to use
        println!("Decrypted: {:?}", message.conversation);
    }
    _ => {}
}

Undecryptable Messages

When decryption fails, you receive an UndecryptableMessage event:
match event {
    Event::UndecryptableMessage(undecryptable) => {
        println!("❌ Could not decrypt message");
        println!("Message ID: {}", undecryptable.info.id);
        println!("From: {}", undecryptable.info.source.sender);
        
        // The client automatically sends retry receipts
        // No manual action needed
    }
    _ => {}
}
The client automatically handles decryption retries using the retry receipt mechanism. Failed messages trigger Event::UndecryptableMessage, and the client will request re-encryption from the sender.
See Signal Protocol and Events reference for more details.

Two-pass decryption model

Group messages arrive with two types of <enc> nodes in a single stanza:
  1. Session messages (pkmsg/msg) — carry the Sender Key Distribution Message (SKDM) via a pairwise Signal session
  2. Group messages (skmsg) — carry the actual message content, encrypted with the sender key
The client decrypts these in two passes:
  1. Pass 1: Process session <enc> nodes to extract the SKDM, which establishes the sender key for the group.
  2. Pass 2: Process group <enc> nodes using the sender key from Pass 1.
If session messages fail to decrypt, the SKDM they carried is lost. In this case, the client skips skmsg decryption entirely (since it would always fail with NoSenderKey) and dispatches an UndecryptableMessage event. The retry receipt for the session message causes the sender to resend the entire message including the SKDM. Before looking up or storing sender keys, the client normalizes the sender JID to its bare form (stripping the device component via to_non_ad()). This is necessary because WhatsApp delivers pkmsg stanzas (carrying SKDM) with a device-qualified participant JID, while skmsg stanzas use a bare participant JID. Without normalization, the sender key stored during SKDM processing would not match the key looked up during skmsg decryption. See Sender key address normalization for details. When a group skmsg decryption fails with NoSenderKeyState (the sender key is missing or was never received), the client dispatches an UndecryptableMessage event before spawning the retry receipt. This ensures your application is immediately notified that the message is pending decryption, matching the behavior of the session-based decrypt path. Exceptions where skmsg is still processed even without successful session decryption:
  • No session messages present — the sender key was already established from a prior message
  • Duplicate session messages — the SKDM was already processed in a previous delivery
This matches WhatsApp Web’s canDecryptNext pattern. It prevents unnecessary retry receipts for skmsg nodes that can never succeed without the SKDM.
Because pkmsg messages carry SKDM, silently dropping a pkmsg during processing causes all subsequent skmsg messages from that sender to fail with NoSenderKeyState. The client uses a generation-checked re-acquire loop during the offline-to-online semaphore transition to ensure pkmsg messages are never dropped. See Concurrency gating for details on how this works.

Decrypt-fail mode

Each incoming message has a decrypt_fail_mode attribute parsed from the <enc> nodes:
  • DecryptFailMode::Show — the recipient should show a “waiting for this message” placeholder in the chat
  • DecryptFailMode::Hide — the message should be silently hidden on failure (used for infrastructure messages like reactions, poll votes, pin changes, secret encrypted event/poll edits, message history notices, and certain protocol messages)
If any <enc> node in the stanza has decrypt-fail="hide", the entire message uses Hide mode. See Decrypt-fail suppression for which outgoing message types set this attribute.

Decryption retry mechanism

The library automatically:
  1. Detects decryption failures (no session, invalid keys, MAC errors)
  2. Sends retry receipts with fresh prekeys
  3. Tracks retry count (max 5 attempts)
  4. Sends a parallel PDO (Peer Data Operation) request on the first retry
  5. Falls back to immediate PDO as last resort when retries are exhausted
// Retry reasons (internal, handled automatically)
enum RetryReason {
    NoSession = 1,        // No session exists
    InvalidKey = 2,       // Invalid key
    InvalidKeyId = 3,     // PreKey ID not found
    InvalidMessage = 4,   // Invalid format or MAC
    // ... other reasons
}

Unavailable message recovery via PDO

When the server delivers a message with an <unavailable> child node instead of <enc> nodes, the message content is not present in the stanza. This happens when:
  • A view-once message has already been viewed on another device
  • The server cannot deliver the encrypted payload for other reasons
Instead of silently dropping these messages, the client requests the content from your primary phone via PDO (Peer Data Operation). The flow is:
  1. The client detects the <unavailable> node and its type (e.g., view_once or unknown)
  2. An UndecryptableMessage event is dispatched immediately with is_unavailable: true
  3. A PDO request (PlaceholderMessageResend) is sent to your own bare JID (server routes to all devices including device 0)
  4. The phone responds with the full WebMessageInfo containing the decrypted message
  5. The client validates the response came from device 0 (primary phone) and dispatches the recovered message as a normal Event::Message
The recovered MessageInfo includes unavailable_request_id — the PDO request message ID — so you can correlate recovered messages with the original UndecryptableMessage event.
Event::UndecryptableMessage(undec) => {
    if undec.is_unavailable {
        // Message content is being requested from your phone via PDO.
        // You'll receive an Event::Message when the phone responds.
        match undec.unavailable_type {
            UnavailableType::ViewOnce => {
                println!("View-once message — requesting from phone");
            }
            UnavailableType::Unknown => {
                println!("Unavailable message — requesting from phone");
            }
        }
    }
}

// When the phone responds, the recovered message includes the request ID:
Event::Message(msg, info) => {
    if let Some(request_id) = &info.unavailable_request_id {
        println!("Recovered via PDO (request: {})", request_id);
    }
}
PDO is also used alongside retry receipts for normal decryption failures. On the first retry attempt, a parallel PDO request is sent with a 500ms delay to give the retry receipt time to resolve first. If all 5 retry attempts are exhausted, an immediate PDO request is sent as a last resort.
PDO requests are deduplicated — if a request is already pending for a given message, subsequent requests are skipped. Pending requests expire after 30 seconds. The deduplication cache uses phone-number JIDs as keys (not LID JIDs) to ensure the cache key matches the JID format in the phone’s response.

Sent message retry (outbound)

When a recipient’s device cannot decrypt your message, it sends a retry receipt. The client handles this automatically using DB-backed sent message storage:
  1. Every send_message() persists the serialized message payload to the sent_messages database table
  2. On retry receipt, the client retrieves the original payload, re-encrypts it for the requesting device, and resends
  3. The payload is consumed (deleted) on retrieval to prevent double-retry
  4. Expired entries are periodically cleaned up based on sent_message_ttl_secs (default: 5 minutes)
This matches WhatsApp Web’s getMessageTable pattern of reading from persistent storage on retry receipt.
An optional in-memory L1 cache (recent_messages in CacheConfig) can be enabled for faster retry lookups. When disabled (default, capacity 0), all retry lookups go directly to the database. See Bot - Cache Configuration Reference for details.

Receipts

Automatic Delivery Receipts

The client automatically sends delivery receipts for successfully decrypted messages:
// Happens automatically after message decryption
// No manual action needed
See Receipt API reference for full details.

Sending Read Receipts

// Mark a single message as read (DM)
client.mark_as_read(
    &chat_jid,
    None, // No sender for DMs
    vec!["msg1".to_string()],
).await?;

// Mark multiple messages as read (group)
client.mark_as_read(
    &group_jid,
    Some(&sender_jid), // Must specify sender in groups
    vec!["msg1".to_string(), "msg2".to_string(), "msg3".to_string()],
).await?;

Receipt Events

Handle receipt updates from other participants:
match event {
    Event::Receipt(receipt) => {
        println!("📬 Receipt for: {:?}", receipt.message_ids);
        match receipt.r#type {
            ReceiptType::Delivered => println!("Delivered"),
            ReceiptType::Read => println!("Read"),
            ReceiptType::ReadSelf => println!("Read on another device"),
            ReceiptType::Played => println!("Played (voice/video)"),
            ReceiptType::EncRekeyRetry => println!("VoIP call re-keying retry"),
            _ => {}
        }
    }
    _ => {}
}

Advanced Usage

Custom Encryption Handlers

For custom encryption types (e.g., pkmsg, msg, skmsg):
use whatsapp_rust::types::enc_handler::EncHandler;
use async_trait::async_trait;
use std::sync::Arc;

#[derive(Clone)]
struct CustomEncHandler;

#[async_trait]
impl EncHandler for CustomEncHandler {
    async fn handle(
        &self,
        client: Arc<Client>,
        node: &Node,
        info: &Arc<MessageInfo>,
    ) -> Result<(), anyhow::Error> {
        // Custom decryption logic
        println!("Custom encryption type: {:?}", node.attrs().optional_string("type"));
        Ok(())
    }
}

// Register the handler via BotBuilder
let bot = Bot::builder()
    .with_enc_handler("custom_type", CustomEncHandler)
    // ... other configuration
    .build()
    .await?;
See Client API reference for handler registration details.

Filtering Messages

Use the type-safe JID methods (is_group(), is_broadcast_list(), is_status_broadcast()) to classify messages by chat type:
use wacore_binary::jid::JidExt;

.on_event(|event, _client| async move {
    match &*event {
        Event::Message(message, info) => {
            // Ignore own messages
            if info.source.is_from_me {
                return;
            }
            
            // Only handle group messages
            if !info.source.chat.is_group() {
                return;
            }
            
            // Only handle text messages
            if let Some(text) = message.text_content() {
                println!("Group text: {}", text);
            }
        }
        _ => {}
    }
})

Session and Key Management

The library automatically manages Signal Protocol sessions:
// Sessions are established automatically when:
// - Receiving PreKeySignalMessage (pkmsg)
// - Receiving SignalMessage (msg) for existing sessions
// - Receiving SenderKeyDistributionMessage for groups

// No manual session management needed!
For advanced cases (identity changes, session cleanup):
// The library handles identity changes automatically:
// - Detects UntrustedIdentity errors during decryption
// - Clears the old identity key (but preserves the session for in-flight messages)
// - Retries decryption with the new identity
// - On InvalidPreKeyId, attempts PN→LID session migration before requesting a retry
// - Re-issues TC tokens so the contact retains a valid privacy token
When a contact reinstalls WhatsApp, you’ll receive an IdentityChange event after the client has completed all session cleanup. The client also re-issues TC tokens in the background to maintain privacy token continuity. See Signal Protocol for more on session management.

Error Handling

.on_event(|event, client| async move {
    match &*event {
        Event::Message(message, info) => {
            // Process message
            if let Err(e) = process_message(message, info, client).await {
                eprintln!("Error processing message {}: {:?}", info.id, e);
            }
        }
        Event::UndecryptableMessage(undecryptable) => {
            eprintln!("⚠️  Undecryptable message from {}", 
                undecryptable.info.source.sender);
            // Client automatically handles retries
        }
        _ => {}
    }
})

async fn process_message(
    message: &wa::Message,
    info: &Arc<MessageInfo>,
    client: Arc<Client>,
) -> Result<()> {
    // Your message processing logic
    Ok(())
}

Best Practices

1
Use the Bot API for Event Handling
2
The Bot API provides a clean interface for message handling:
3
let bot = Bot::builder()
    .on_event(|event, client| async move {
        // Handle events here
    })
    .build()
    .await?;
4
Extract Content with Helper Methods
5
use wacore::proto_helpers::MessageExt;

// Use helper methods instead of manual field access
let text = message.text_content();
let caption = message.get_caption();
let base = message.get_base_message();
6
Handle All Event Types
7
Always handle critical events:
8
match &*event {
    Event::Message(message, info) => { /* ... */ }
    Event::Connected(_) => { /* Initialize */ }
    Event::Disconnected(_) => { /* Cleanup */ }
    Event::LoggedOut(_) => { /* Re-authenticate */ }
    _ => {}
}
9
Don’t Block Event Handlers
10
Spawn tasks for long-running operations:
11
.on_event(|event, client| async move {
    if let Event::Message(message, info) = &*event {
        // Spawn task for heavy processing — Arc clone is O(1)
        let client = client.clone();
        let event = event.clone();
        tokio::spawn(async move {
            if let Event::Message(message, info) = &*event {
                process_heavy_task(message, info, client).await;
            }
        });
    }
})

Next Steps