WhatsApp-Rust uses an event-driven architecture where the client emits events for all WhatsApp protocol interactions. Your application subscribes to these events to handle messages, connection changes, and notifications.
Handlers receive Arc<Event> — a shared reference-counted pointer to the event. Since Arc<Event> implements Deref<Target = Event>, you can pattern-match on it directly.Implementation:
struct MyHandler;impl EventHandler for MyHandler { fn handle_event(&self, event: Arc<Event>) { match &*event { Event::Message(msg, info) => { println!("Message from {}: {:?}", info.source.sender, msg); } _ => {} } }}client.register_handler(Arc::new(MyHandler));
The Event enum is #[non_exhaustive], so your match statements must include a wildcard arm (_ => {}). New variants may be added in minor releases without a breaking change.
Emitted: When another device connects with the same credentials (stream error code 409 or <conflict type="replaced">)
Event::StreamReplaced(_) => { println!("⚠️ Another instance connected - disconnecting"); // Auto-reconnect is disabled — reconnecting would displace the other client}
Behavior: Auto-reconnect is disabled. The client stops permanently.
on_connect — true if the logout happened during a connection attempt (server-initiated), false if triggered by client.logout() or a stream error while connected
reason — The reason for the logout (e.g., ConnectFailureReason::LoggedOut)
Usage:
Event::LoggedOut(logout) => { eprintln!("Logged out (reason: {:?})", logout.reason); // Session is invalid — you must re-pair the device // Auto-reconnect is disabled}
Behavior: Auto-reconnect is disabled. The application must re-pair the device to establish a new session.
Event::QrScannedWithoutMultidevice(_) => { println!("QR scanned but device does not support multi-device"); // Prompt the user to update their WhatsApp app}
use waproto::whatsapp as wa;Event::Message(msg, info) => { println!("From: {} in {}", info.source.sender, info.source.chat); // Text message if let Some(text) = &msg.conversation { println!("Text: {}", text); } // Extended text (with link preview, quoted message, etc.) if let Some(ext) = &msg.extended_text_message { println!("Text: {}", ext.text.as_deref().unwrap_or("")); if let Some(context) = &ext.context_info { if let Some(quoted) = &context.quoted_message { println!("Quoted: {:?}", quoted); } } } // Image message if let Some(img) = &msg.image_message { println!("Image: {} ({}x{})", img.caption.as_deref().unwrap_or(""), img.width.unwrap_or(0), img.height.unwrap_or(0) ); } // Video, audio, document, sticker, etc. // See waproto::whatsapp::Message for all types}
Emitted: When a message cannot be decrypted or is unavailable. This includes:
Decryption failures (no session, invalid keys, MAC errors)
Group messages that fail with NoSenderKeyState (missing sender key) — dispatched before the retry receipt is sent
Messages with an <unavailable> node (view-once already viewed, server-side unavailability) — the client automatically requests content from your primary phone via PDO
When is_unavailable is true, the message had no encrypted content in the stanza. The client sends a PDO request to your primary phone, and if the phone responds successfully, a follow-up Event::Message is dispatched with the recovered content.
Emitted: For raw notification stanzas that are not handled by a more specific event type
Event::Notification(Arc<OwnedNodeRef>)
This is a passthrough event that gives you access to the raw node for notification types that the library does not parse into dedicated event structs. The OwnedNodeRef provides zero-copy access to the decoded stanza — call .get() to obtain a NodeRef for inspecting the tag, attributes, and children.Example:
Most notifications are already parsed into specific event types (e.g., GroupUpdate, DeviceListUpdate, ContactUpdated). This event only fires for unhandled notification types.
DecryptFailMode is determined by the decrypt-fail attribute on incoming <enc> nodes. If any <enc> node has decrypt-fail="hide", the entire message uses Hide mode.
Show — Default. The application should display a “waiting for this message” placeholder. Used for regular user-visible messages.
Hide — The application should silently discard the failure. Used for infrastructure messages (reactions, poll votes, pin changes, edit messages, event responses, message history notices, secret encrypted event/poll edits, certain protocol messages, and SKDM stanzas) that don’t need user-visible placeholders.
See Decrypt-fail suppression for the full list of message types that set this attribute on outgoing stanzas.Example:
Emitted: For each action in a group notification (subject changes, participant changes, settings updates, etc.). A single notification may produce multiple GroupUpdate events.
Membership request variants include a request_method field of type MembershipRequestMethod:
pub enum MembershipRequestMethod { InviteLink, // User clicked an invite link LinkedGroupJoin, // User joined via a linked community subgroup NonAdminAdd, // A non-admin member tried to add the user}
MembershipApprovalRequest — emitted when a user requests to join a group. The requester is identified by the parent GroupUpdate::participant field.
CreatedMembershipRequests — admin-side notification: new join requests appeared. The requests field contains the requesting users (as Vec<GroupParticipantInfo>).
RevokedMembershipRequests — emitted when membership requests are rejected by an admin or cancelled by the requester. The participants field contains the affected JIDs.
Both MembershipApprovalRequest and CreatedMembershipRequests include an optional parent_group_jid field for community-linked joins.Example: handling specific group actions:
use wacore::stanza::groups::GroupNotificationAction;Event::GroupUpdate(update) => { match &update.action { GroupNotificationAction::Add { participants, .. } => { for p in participants { println!("{} was added to {}", p.jid, update.group_jid); } } GroupNotificationAction::Subject { subject, .. } => { println!("Group {} renamed to {}", update.group_jid, subject); } GroupNotificationAction::Ephemeral { expiration, .. } => { if *expiration == 0 { println!("Disappearing messages disabled in {}", update.group_jid); } else { println!("Disappearing messages set to {}s in {}", expiration, update.group_jid); } } GroupNotificationAction::MembershipApprovalRequest { request_method, parent_group_jid } => { println!("Join request in {} via {:?}", update.group_jid, request_method); if let Some(parent) = parent_group_jid { println!("From community: {}", parent); } } GroupNotificationAction::CreatedMembershipRequests { requests, request_method, .. } => { println!("{} new join requests in {} via {:?}", requests.len(), update.group_jid, request_method); } GroupNotificationAction::RevokedMembershipRequests { participants } => { println!("{} membership requests revoked in {}", participants.len(), update.group_jid); } _ => {} }}
A single group notification from the server can contain multiple actions. The library dispatches a separate GroupUpdate event for each action, so your handler may receive multiple events from one notification.
These events are emitted from <notification type="contacts"> stanzas sent by the server. They are distinct from ContactUpdate, which comes from app-state sync mutations.
Wire format:<notification type="contacts"><update jid="..."/>When you receive this event, you should invalidate any cached presence or profile picture data for the contact. WhatsApp Web resets its PresenceCollection and refreshes the profile picture thumbnail on this event.Example:
Event::ContactUpdated(update) => { println!("Contact {} profile changed at {}", update.jid, update.timestamp); // Invalidate cached presence/profile data // Re-fetch profile picture if needed}
Emitted: When a contact changes their phone number
#[derive(Debug, Clone, Serialize)]pub struct ContactNumberChanged { /// Old phone number JID. pub old_jid: Jid, /// New phone number JID. pub new_jid: Jid, /// Old LID (if provided by server). pub old_lid: Option<Jid>, /// New LID (if provided by server). pub new_lid: Option<Jid>, pub timestamp: DateTime<Utc>,}
Wire format:<notification type="contacts"><modify old="..." new="..." old_lid="..." new_lid="..."/>The library automatically creates LID-PN mappings when LID attributes are present (old_lid→old_jid and new_lid→new_jid). WhatsApp Web generates a system notification message in both the old and new chats.Example:
Event::ContactNumberChanged(change) => { println!("Contact changed number: {} -> {}", change.old_jid, change.new_jid); if let Some(new_lid) = &change.new_lid { println!("New LID: {}", new_lid); } // Update your contact records to use the new JID}
action - The contact action from app-state sync, containing fields like full_name and first_name
from_full_sync - Whether this came from a full app-state sync (initial load) or an incremental update
Example:
Event::ContactUpdate(update) => { println!("Contact {} updated at {}", update.jid, update.timestamp); if let Some(name) = &update.action.full_name { println!("Full name: {}", name); } if update.from_full_sync { println!("(from full sync)"); }}
ContactUpdate comes from app-state sync mutations and is distinct from ContactUpdated, which comes from server-side <notification type="contacts"> stanzas.
The server may also send <add/> and <remove/> child actions in contacts notifications for lightweight roster changes. These are acknowledged automatically and do not emit events.
Emitted: When a chat is deleted across linked devices
#[derive(Debug, Clone, Serialize)]pub struct DeleteChatUpdate { pub jid: Jid, /// From the index, not the proto — DeleteChatAction only has messageRange. pub delete_media: bool, pub timestamp: DateTime<Utc>, pub action: Box<wa::sync_action_value::DeleteChatAction>, pub from_full_sync: bool,}
Fields:
jid - The JID of the deleted chat
delete_media - Whether media files were also deleted
action - The underlying protobuf action containing the optional message_range
LazyHistorySync wraps the decompressed protobuf bytes of a history sync blob and only decodes the full wa::HistorySync proto on demand. Cheap metadata (sync_type, chunk_order, progress) is available without decoding, so you can filter or route events without paying the decode cost.
pub struct LazyHistorySync { raw_bytes: Bytes, sync_type: i32, chunk_order: Option<u32>, progress: Option<u32>, parsed: OnceLock<Option<Box<wa::HistorySync>>>,}impl LazyHistorySync { /// History sync type (e.g. InitialBootstrap, Recent, PushName). /// Available without decoding the proto. pub fn sync_type(&self) -> i32; /// Chunk ordering for multi-chunk transfers. pub fn chunk_order(&self) -> Option<u32>; /// Sync progress (0-100). pub fn progress(&self) -> Option<u32>; /// Full decode of the history sync proto, cached via OnceLock. /// Returns `None` if decoding fails. pub fn get(&self) -> Option<&wa::HistorySync>; /// Access the raw decompressed protobuf bytes for custom/partial decoding. pub fn raw_bytes(&self) -> &[u8];}
Key characteristics:
Metadata without decoding — sync_type(), chunk_order(), and progress() are extracted during the streaming phase and available immediately
Parse-once semantics — get() decodes the full proto on first call and caches the result via OnceLock. With Arc<Event> dispatch, all handlers share the same LazyHistorySync instance
Raw bytes access — raw_bytes() provides the decompressed protobuf bytes for custom or partial decoding without triggering the full decode
Cheap clone — Cloning shares the underlying Bytes buffer (reference-counted) but creates a fresh OnceLock, so each clone decodes independently
Serialization — Only metadata (sync_type, chunk_order, progress) is serialized, not the blob
Full decode via get() materializes the proto in memory alongside the raw bytes (~2x decompressed size). For large InitialBootstrap blobs, prefer raw_bytes() with partial decoding if you only need specific fields.
Example:
Event::HistorySync(lazy_sync) => { println!("History sync type: {}, progress: {:?}", lazy_sync.sync_type(), lazy_sync.progress()); // Full decode (cached after first call) if let Some(history) = lazy_sync.get() { for conv in &history.conversations { println!("Conversation: {:?}", conv.id); } }}
Partial decoding with raw bytes:
Event::HistorySync(lazy_sync) => { // Decode only what you need from raw protobuf bytes let history = wa::HistorySync::decode(lazy_sync.raw_bytes()).unwrap(); println!("Conversations: {}", history.conversations.len());}
The blob is only retained in memory if event handlers are registered. If no handlers are listening, the history sync pipeline extracts internal data (pushname, NCT salt, TC tokens) and discards the blob without allocating it for event dispatch.
Offline sync happens automatically when the client reconnects after being disconnected. The client tracks progress internally and emits these events to notify your application of sync status.If the server does not complete offline sync within 60 seconds, the client forces completion via a timeout fallback — OfflineSyncCompleted is still emitted with the count of items processed so far. This prevents startup from blocking indefinitely.
The user’s LID JID, if known from the notification
update_type
Whether a device was added, removed, or updated
devices
List of affected devices with their IDs and key indexes
key_index
ADV key index info for device identity verification (add operations only)
contact_hash
Server-side contact hash for update operations
This event is dispatched after the client has already patched its internal device registry cache. You can use it to track when contacts pair or unpair companion devices. The client also uses device list changes internally to manage unknown device detection, Signal session cleanup, and sender key cache invalidation — when a device is added or removed, the sender key device cache is invalidated so SKDM is redistributed on the next group message.
Emitted: When a contact reinstalls WhatsApp (their identity key changed). The client performs full session cleanup and proactive re-establishment before emitting this event, matching WhatsApp Web’s identity change handling.
#[derive(Debug, Clone, Serialize)]pub struct IdentityChange { /// The user whose identity changed pub user: Jid, /// Optional LID for the user pub lid_user: Option<Jid>,}
Fields:
user — The phone number JID of the user whose identity changed
lid_user — The user’s LID JID, if provided in the notification
This event corresponds to WhatsApp Web’s WAWebHandleIdentityChange flow. When the server sends an <identity/> notification inside a type="encrypt" stanza, the client:
Clears the device record for the user (deletes Signal sessions for all non-primary devices and all sender key device tracking)
Deletes the primary device session and identity key so a fresh session can be established (matching WhatsApp Web’s deleteRemoteInfo)
Deletes the status@broadcast sender key for forward secrecy on the next status send (matching WhatsApp Web’s markStatusSenderKeyRotate)
Invalidates the device registry cache so the next send triggers a fresh device list sync
Dispatches this event so your application can show a “security code changed” notice
Spawns a background ensure_e2e_sessions task to proactively re-establish the session (self-defers when the client is offline)
Additionally, when a message triggers an UntrustedIdentity error during decryption (indicating the sender reinstalled WhatsApp), the client:
Clears the old identity key and retries decryption with the new identity, preserving the old session for in-flight messages
Handles InvalidPreKeyId errors in the retry path by sending a retry receipt so the sender can establish a new session
Re-issues TC tokens for the sender in the background (matching WhatsApp Web’s sendTcTokenWhenDeviceIdentityChange behavior) so the contact retains a valid privacy token
The notification is processed immediately even when received during offline sync, because all cleanup operations are local-only. The background session re-establishment self-defers via wait_for_offline_delivery_end when the client is offline.
Identity change notifications from companion devices (device ID != 0) and from your own JID are ignored — only primary device identity changes for other users are processed.
Example:
Event::IdentityChange(change) => { println!("Security code changed for user {}", change.user); if let Some(lid) = &change.lid_user { println!("LID: {}", lid); } // Show a "security code changed" notice in the chat}
newsletter_jid — The newsletter channel this update is for
messages — List of messages with updated reaction counts
server_id — Server-assigned message ID
reactions — Current reaction counts (emoji code and count)
Example:
Event::NewsletterLiveUpdate(update) => { println!("Newsletter {} updated:", update.newsletter_jid); for msg in &update.messages { for r in &msg.reactions { println!(" Message {}: {} x{}", msg.server_id, r.code, r.count); } }}
You must call client.newsletter().subscribe_live_updates(&jid) to receive these events. The subscription has a limited duration (typically 300 seconds) and must be renewed periodically.
from - The contact whose disappearing messages setting changed
duration - New duration in seconds (0 = disabled, 86400 = 24 hours, 604800 = 7 days, etc.)
setting_timestamp - DateTime<Utc> indicating when the setting was changed (serialized as Unix timestamp in seconds)
You should only apply this update if setting_timestamp is newer than your previously stored value for this contact. This prevents out-of-order updates from overwriting newer settings.
Example:
Event::DisappearingModeChanged(change) => { let status = if change.duration == 0 { "disabled".to_string() } else { format!("enabled ({}s)", change.duration) }; println!("Contact {} changed disappearing messages: {}", change.from, status);}
Emitted: For every decoded stanza before router dispatch. This is an opt-in event gated by Client::set_raw_node_forwarding(true) — it is not emitted by default to avoid overhead.
Event::RawNode(Arc<OwnedNodeRef>)
This is a library extension with no WhatsApp Web equivalent. It gives you access to every raw decoded stanza before any routing or parsing occurs. The OwnedNodeRef uses yoke-based zero-copy decoding, so string and byte payloads are borrowed directly from the network buffer without allocation.Example:
client.set_raw_node_forwarding(true);// In your event handler:Event::RawNode(node) => { let node_ref = node.get(); println!("Raw stanza tag: {}, attrs: {:?}", node_ref.tag, node_ref.attrs);}
RawNode is skipped during serialization (#[serde(skip)]). Enable it only when debugging or building protocol-level tooling, as it dispatches for every incoming stanza.
ChannelEventHandler is a built-in event handler that forwards events to an async_channel for async consumption. It uses async-channel (runtime-agnostic) instead of Tokio channels, so it works with any async executor — including WASM targets.Events are buffered in an unbounded channel, so events fired before the receiver starts listening are not lost.
use wacore::types::events::{ChannelEventHandler, Event};use std::sync::Arc;let (handler, event_rx) = ChannelEventHandler::new();client.register_handler(handler);// Wait for events asynchronouslywhile let Ok(event) = event_rx.recv().await { match &*event { Event::Connected(_) => { println!("Connected!"); break; } Event::Message(msg, info) => { println!("Message from {}", info.source.sender); } _ => {} }}
This is particularly useful for:
Testing — assert on specific event sequences without closures
Custom event loops — process events in your own async task with full control over ordering
Runtime-agnostic code — no dependency on Tokio’s mpsc channels
ChannelEventHandler::new() returns (Arc<ChannelEventHandler>, async_channel::Receiver<Arc<Event>>). The handler is already wrapped in Arc for direct use with client.register_handler(). The receiver yields Arc<Event>, so use &*event or event.as_ref() to pattern-match.
Purpose: Avoid parsing large protobuf blobs unless needed. The raw decompressed bytes flow through the pipeline as reference-counted Bytes, and full protobuf decoding only happens if your code calls get().
Cloning:Bytes is reference-counted so cloning the raw data is O(1). However, parsed uses a plain OnceLock (not Arc<OnceLock>), so each clone gets its own parse cache and parses independently. This is acceptable because parsing is idempotent and the common case is a single handler.Usage:
Event::HistorySync(lazy_sync) => { // Metadata is always available without decoding let sync_type = lazy_sync.sync_type(); let progress = lazy_sync.progress(); // Full decode happens only on first call to get() if let Some(history) = lazy_sync.get() { for conv in &history.conversations { process_conversation(conv); } } // Or use raw bytes for custom partial decoding let raw = lazy_sync.raw_bytes();}
With Arc<Event> dispatch, each event is wrapped in a single Arc by the CoreEventBus and shared across all handlers. This eliminates deep clones of large event payloads like LazyHistorySync blobs and Message(Box<wa::Message>, Arc<MessageInfo>). The MessageInfo inside Event::Message and UndecryptableMessage is also wrapped in Arc, enabling zero-cost sharing across the message dispatch, retry receipt, and PDO recovery paths without cloning the full struct.Combined with LazyHistorySync’s OnceLock, all handlers sharing the same Arc<Event> get parse-once semantics for free — the first handler to call lazy_sync.get() triggers the decode, and subsequent handlers reuse the cached result.
.on_event(|event, client| async move { // Only handle events you care about match &*event { Event::Message(msg, info) if !info.source.is_from_me => { // Only handle messages from others } Event::Message(msg, info) if info.source.is_group => { // Only handle group messages } _ => {} }})