Skip to main content

Overview

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.

Event system architecture

CoreEventBus

Location: wacore/src/types/events.rs
#[derive(Default, Clone)]
pub struct CoreEventBus {
    handlers: Arc<RwLock<Vec<Arc<dyn EventHandler>>>>,
}

impl CoreEventBus {
    pub fn dispatch(&self, event: Event) {
        let handlers = self.handlers.read().expect("...").clone();
        if handlers.is_empty() {
            return;
        }
        let event = Arc::new(event);
        for handler in &handlers {
            handler.handle_event(Arc::clone(&event));
        }
    }

    pub fn has_handlers(&self) -> bool {
        !self.handlers.read().expect("...").is_empty()
    }
}
Features:
  • Thread-safe event dispatching via Arc<Event> — each event is wrapped once and shared across all handlers, eliminating deep clones
  • Multiple handlers supported
  • Clone-cheap with Arc

EventHandler Trait

pub trait EventHandler: Send + Sync {
    fn handle_event(&self, event: Arc<Event>);

    /// Which event kinds this handler wants. Defaults to all kinds.
    /// Override to let the bus skip materializing events you don't want.
    fn interest(&self) -> EventInterest {
        EventInterest::ALL
    }
}
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));

Typed event interest (skip boxing unwanted events)

By default a handler receives every event. If you only care about a few kinds, override interest() so the event bus skips building and dispatching the kinds nobody wants — for high-throughput events (presence, receipts) this avoids the per-event Arc allocation entirely.
use wacore::types::events::{EventInterest, EventKind};

impl EventHandler for MyHandler {
    fn handle_event(&self, event: Arc<Event>) { /* … */ }

    fn interest(&self) -> EventInterest {
        // Only Message and Connected events reach this handler.
        EventInterest::of(&[EventKind::Message, EventKind::Connected])
    }
}
  • EventKind is a #[repr(u8)] discriminant — one variant per Event variant (Message, Connected, Receipt, …). The enum is #[non_exhaustive], so match blocks on EventKind must include a wildcard arm (_ => …); new kinds may be added in minor releases as the library tracks new server events.
  • EventKind::CAPACITY is a public u8 constant (currently 64) that bounds the number of kinds. It exists because each discriminant is packed as a bit in EventInterest’s u64 mask, and a future variant that would overflow it fails compilation rather than silently corrupting the mask at runtime. Treat it as a read-only ceiling — you don’t need to check it at runtime.
  • EventInterest is a 64-bit set of kinds. Build it with EventInterest::of(&[…]), EventInterest::ALL (the default), EventInterest::none(), or chain .with(kind). Query it with .wants(kind).
  • The bus exposes has_handler_for(kind) and only produces an event when at least one registered handler wants its kind.
With the Bot builder, the same narrowing is available via on_event_for:
bot.on_event_for(&[EventKind::Message], |event, client| async move {
    // only Message events
});
on_event (without kinds) keeps subscribing to everything.

Event Enum

Location: wacore/src/types/events.rs
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub enum Event {
    // Connection
    Connected(Connected),
    Disconnected(Disconnected),
    StreamReplaced(StreamReplaced),
    StreamError(StreamError),
    ConnectFailure(ConnectFailure),
    TemporaryBan(TemporaryBan),

    // Pairing
    PairingQrCode { code: String, timeout: Duration },
    PairingCode { code: String, timeout: Duration },
    PairSuccess(PairSuccess),
    PairError(PairError),
    QrScannedWithoutMultidevice(QrScannedWithoutMultidevice),
    ClientOutdated(ClientOutdated),
    LoggedOut(LoggedOut),

    // Messages
    Message(Arc<wa::Message>, Arc<MessageInfo>),
    Receipt(Receipt),
    UndecryptableMessage(UndecryptableMessage),
    Notification(Arc<OwnedNodeRef>),

    // Presence
    ChatPresence(ChatPresenceUpdate),
    Presence(PresenceUpdate),

    // User Updates
    PictureUpdate(PictureUpdate),
    UserAboutUpdate(UserAboutUpdate),
    PushNameUpdate(PushNameUpdate),
    SelfPushNameUpdated(SelfPushNameUpdated),

    // Group Updates
    GroupUpdate(GroupUpdate),

    // Contact Updates
    ContactUpdated(ContactUpdated),
    ContactNumberChanged(ContactNumberChanged),
    ContactSyncRequested(ContactSyncRequested),
    ContactUpdate(ContactUpdate),

    // Chat State
    PinUpdate(PinUpdate),
    MuteUpdate(MuteUpdate),
    ArchiveUpdate(ArchiveUpdate),
    StarUpdate(StarUpdate),
    MarkChatAsReadUpdate(MarkChatAsReadUpdate),
    DeleteChatUpdate(DeleteChatUpdate),
    ClearChatUpdate(ClearChatUpdate),
    UserStatusMuteUpdate(UserStatusMuteUpdate),
    DeleteMessageForMeUpdate(DeleteMessageForMeUpdate),
    LabelEditUpdate(LabelEditUpdate),
    LabelAssociationUpdate(LabelAssociationUpdate),

    // History Sync
    HistorySync(Box<LazyHistorySync>),
    OfflineSyncPreview(OfflineSyncPreview),
    OfflineSyncCompleted(OfflineSyncCompleted),

    // Device Updates
    DeviceListUpdate(DeviceListUpdate),
    IdentityChange(IdentityChange),
    BusinessStatusUpdate(BusinessStatusUpdate),

    // Newsletter
    NewsletterLiveUpdate(NewsletterLiveUpdate),

    // Calls
    IncomingCall(IncomingCall),

    // Notification Updates
    DisappearingModeChanged(DisappearingModeChanged),

    // Raw stanza (opt-in)
    RawNode(Arc<OwnedNodeRef>),
}
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.

Connection Events

Connected

Emitted: After successful connection and authentication
#[derive(Debug, Clone, Serialize)]
pub struct Connected;

Event::Connected(Connected)
Usage:
Event::Connected(_) => {
    println!("✅ Connected to WhatsApp");
    // Safe to send messages now
}

Disconnected

Emitted: When connection is lost
#[derive(Debug, Clone, Serialize)]
pub struct Disconnected;

Event::Disconnected(Disconnected)
Behavior: Client automatically attempts reconnection

ConnectFailure

Emitted: When connection fails with a specific reason
#[derive(Debug, Clone, Serialize)]
pub struct ConnectFailure {
    pub reason: ConnectFailureReason,
    pub message: String,
    pub raw: Option<Node>,
}

#[derive(Debug, Clone, PartialEq, Eq, Copy, Serialize)]
pub enum ConnectFailureReason {
    Generic,                // 400
    LoggedOut,              // 401
    TempBanned,             // 402
    AccountLocked,          // 403 — WA Web REASON_LOCKED (account/device locked)
    UnknownLogout,          // 406
    ClientOutdated,         // 405
    BadUserAgent,           // 409
    CatExpired,             // 413
    CatInvalid,             // 414
    NotFound,               // 415
    ClientUnknown,          // 418
    InternalServerError,    // 500
    Experimental,           // 501
    ServiceUnavailable,     // 503
    Unknown(i32),
}
Helper methods:
if reason.is_logged_out() {
    // Clear session and re-pair
}

if reason.should_reconnect() {
    // Retry connection
}
The 403 variant was renamed MainDeviceGoneAccountLocked in v0.6 to match WA Web’s REASON_LOCKED semantics (the account/device is locked server-side; a manual unlink arrives as a different reason). It still maps from wire code 403 and reports is_logged_out() == true with no auto-reconnect. Update any match arms referencing the old name.

TemporaryBan

Emitted: When account is temporarily banned
#[derive(Debug, Clone, Serialize)]
pub struct TemporaryBan {
    pub code: TempBanReason,
    pub expire: chrono::Duration,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum TempBanReason {
    SentToTooManyPeople,        // 101
    BlockedByUsers,             // 102
    CreatedTooManyGroups,       // 103
    SentTooManySameMessage,     // 104
    BroadcastList,              // 106
    Unknown(i32),
}
Usage:
Event::TemporaryBan(ban) => {
    eprintln!("Banned: {} (expires in {:?})", ban.code, ban.expire);
}

StreamReplaced

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.

LoggedOut

Emitted: When the session is invalidated by the server (stream error code 401 or 516) or when client.logout() is called
#[derive(Debug, Clone, Serialize)]
pub struct LoggedOut {
    pub on_connect: bool,
    pub reason: ConnectFailureReason,
}
Fields:
  • on_connecttrue 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.

StreamError

Emitted: For unrecognized stream error codes (codes not matching 401, 409, 429, 503, 515, or 516)
#[derive(Debug, Clone, Serialize)]
pub struct StreamError {
    pub code: String,
    pub raw: Option<Node>,
}
Usage:
Event::StreamError(err) => {
    eprintln!("Unknown stream error: {} (raw: {:?})", err.code, err.raw);
}
Recognized stream error codes emit specific events instead of StreamError:
  • 401LoggedOut (session invalidated)
  • 409StreamReplaced (another client connected)
  • 429 → No event emitted (reconnects with extended backoff)
  • 503 → No event emitted (reconnects with normal backoff)
  • 515 → No event emitted (immediate reconnect, e.g., after pairing)
  • 516LoggedOut (device removed)

Pairing Events

PairingQrCode

Emitted: For each QR code in rotation
Event::PairingQrCode {
    code: String,           // ASCII art QR or data string
    timeout: Duration,      // 60s first, 20s subsequent
}
Example:
Event::PairingQrCode { code, timeout } => {
    println!("Scan this QR (valid {}s):", timeout.as_secs());
    println!("{}", code);
}

PairingCode

Emitted: When pair code is generated
Event::PairingCode {
    code: String,           // 8-character code
    timeout: Duration,      // ~180 seconds
}
Example:
Event::PairingCode { code, .. } => {
    println!("Enter {} on your phone", code);
}

PairSuccess

Emitted: When pairing completes successfully
#[derive(Debug, Clone, Serialize)]
pub struct PairSuccess {
    pub id: Jid,
    pub lid: Jid,
    pub business_name: String,
    pub platform: String,
}
Example:
Event::PairSuccess(info) => {
    println!("✅ Paired as {}", info.id);
    println!("LID: {}", info.lid);
    println!("Name: {}", info.business_name);
}

PairError

Emitted: When pairing fails
#[derive(Debug, Clone, Serialize)]
pub struct PairError {
    pub id: Jid,
    pub lid: Jid,
    pub business_name: String,
    pub platform: String,
    pub error: String,
}

QrScannedWithoutMultidevice

Emitted: When a QR code is scanned by a device that does not support multi-device
#[derive(Debug, Clone, Serialize)]
pub struct QrScannedWithoutMultidevice;
Usage:
Event::QrScannedWithoutMultidevice(_) => {
    println!("QR scanned but device does not support multi-device");
    // Prompt the user to update their WhatsApp app
}

ClientOutdated

Emitted: When the server rejects the connection because the client version is too old (connect failure code 405)
#[derive(Debug, Clone, Serialize)]
pub struct ClientOutdated;
Usage:
Event::ClientOutdated(_) => {
    eprintln!("Client version is outdated — update whatsapp-rust");
    // Auto-reconnect is disabled
}
Behavior: Auto-reconnect is disabled. You must update to a newer version of the library.

Message Events

Message

Emitted: For all incoming messages (text, media, etc.)
Event::Message(Arc<wa::Message>, Arc<MessageInfo>)
Both the message body and MessageInfo are Arc-wrapped. The bus dispatches the same Arc<wa::Message> to every handler — no deep clone on fan-out — and Event::as_message() returns Option<(&Arc<wa::Message>, &MessageInfo)> so you can cheaply share the payload with spawned tasks or downstream channels. Before v0.6 the body was Box<wa::Message>; the public guarantee changed from “owned, freely mutable” to “shared, immutable read access” — call Arc::make_mut (or clone the inner wa::Message) only if you genuinely need to mutate.
MessageInfo structure:
#[derive(Debug, Clone, Default, Serialize)]
pub struct MessageInfo {
    pub source: MessageSource,
    pub id: MessageId,
    pub server_id: MessageServerId,
    pub r#type: String,
    pub push_name: String,
    pub timestamp: DateTime<Utc>,
    pub server_timestamp_us: Option<i64>,
    pub category: MessageCategory,
    pub multicast: bool,
    pub media_type: String,
    pub edit: EditAttribute,
    pub bot_info: Option<MsgBotInfo>,
    pub meta_info: MsgMetaInfo,
    pub verified_name: Option<wa::VerifiedNameCertificate>,
    pub verified_level: Option<String>,
    pub verified_name_serial: Option<String>,
    pub device_sent_meta: Option<DeviceSentMeta>,
    pub ephemeral_expiration: Option<u32>,
    pub is_offline: bool,
    pub peer_recipient_pn: Option<Jid>,
    pub unavailable_request_id: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct MessageSource {
    pub chat: Jid,             // Where it was sent (group or DM)
    pub sender: Jid,           // Who sent the message
    pub is_from_me: bool,
    pub is_group: bool,
    pub addressing_mode: Option<AddressingMode>,
    pub sender_alt: Option<Jid>,
    pub recipient_alt: Option<Jid>,
    pub broadcast_list_owner: Option<Jid>,
    pub recipient: Option<Jid>,
}
sender_alt carries the LID/PN counterpart of sender whenever the stanza exposes one — including status@broadcast messages, which always include participant_lid (or participant_pn for LID-addressed status). The library reads it unconditionally so the LID-PN cache can re-warm from the message itself, matching WA Web’s WAWebMsgParser. MessageCategory:
pub enum MessageCategory {
    Empty,        // Default (empty string on the wire)
    Peer,         // Self-synced message from primary phone
    Other(String), // Unknown category (forward compatibility)
}
EditAttribute: Indicates the type of edit or revocation applied to a message. Values correspond to the wire-format edit attribute on message stanzas.
pub enum EditAttribute {
    Empty,            // "" — no edit (default)
    MessageEdit,      // "1" — message text was edited
    PinInChat,        // "2" — message was pinned/unpinned
    AdminEdit,        // "3" — admin edited the message
    SenderRevoke,     // "7" — sender deleted for everyone
    AdminRevoke,      // "8" — admin deleted for everyone
    Unknown(String),  // Forward-compatible fallback
}
MsgBotInfo: Present when the message originates from a WhatsApp bot (AI-generated responses). Contains streaming edit metadata.
pub struct MsgBotInfo {
    pub edit_type: Option<BotEditType>,
    pub edit_target_id: Option<MessageId>,
    pub edit_sender_timestamp_ms: Option<DateTime<Utc>>,
}

pub enum BotEditType {
    First,  // Initial bot response
    Inner,  // Streaming update (intermediate chunk)
    Last,   // Final streaming update
}
MsgMetaInfo: Additional metadata for message threading, targeting, and abuse reporting.
pub struct MsgMetaInfo {
    pub target_id: Option<MessageId>,
    pub target_sender: Option<Jid>,
    pub deprecated_lid_session: Option<bool>,
    pub thread_message_id: Option<MessageId>,
    pub thread_message_sender_jid: Option<Jid>,
    pub content_type: Option<String>,
    pub appdata: Option<String>,
    pub reporting_tag: Option<String>,
    pub reporting_token: Option<Bytes>,
    pub reporting_token_version: Option<i32>,
}
FieldDescription
target_idID of the message being targeted (for edits/revokes)
target_senderSender of the targeted message
deprecated_lid_sessionLegacy LID session flag
thread_message_idID of the thread parent message
thread_message_sender_jidSender of the thread parent message
content_typeHigh-level content classification carried by the server (e.g. "image", "document") so consumers can filter without decoding the message body
appdataOpaque per-message app data used by the abuse-report flow
reporting_tagTag the server attaches so the recipient can submit an abuse report tied to this message
reporting_tokenRaw bytes of the abuse-report token paired with reporting_tag
reporting_token_versionVersion of the reporting-token scheme used by the server
server_timestamp_us, verified_level, verified_name_serial, peer_recipient_pn, plus all five MsgMetaInfo additions above were added in v0.6 as part of aligning inbound parsing with WA Web. They are populated only when the server includes the corresponding stanza attribute, so existing consumers that ignore them keep working.
DeviceSentMeta: Present on device-synced messages (messages you sent from another device).
pub struct DeviceSentMeta {
    pub destination_jid: String,
    pub phash: String,
}
Example:
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
}

Receipt

Emitted: For delivery/read/played receipts
#[derive(Debug, Clone, Serialize)]
pub struct Receipt {
    pub source: MessageSource,
    pub message_ids: Vec<MessageId>,
    pub timestamp: DateTime<Utc>,
    pub r#type: ReceiptType,
    /// `true` when the receipt was drained from the server's offline queue on
    /// reconnect (carried the `offline` attribute) rather than delivered live.
    pub offline: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum ReceiptType {
    Delivered,
    Sender,
    Retry,
    EncRekeyRetry,
    Read,
    ReadSelf,
    Played,
    PlayedSelf,
    ServerError,
    Inactive,
    PeerMsg,
    HistorySync,
    Other(String),
}
ReceiptType is #[non_exhaustive]. Server-driven sets like this grow over time (recent additions include EncRekeyRetry, ReadSelf, PlayedSelf, PeerMsg, and HistorySync), so your match arms must always include a wildcard (_ => …). New variants can be added in minor releases without a breaking change.
Example:
Event::Receipt(receipt) => {
    match receipt.r#type {
        ReceiptType::Read => {
            println!("✓✓ Read by {}", receipt.source.sender);
        }
        ReceiptType::Delivered => {
            println!("✓ Delivered to {}", receipt.source.sender);
        }
        _ => {}
    }
}

UndecryptableMessage

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.
#[derive(Debug, Clone, Serialize)]
pub struct UndecryptableMessage {
    pub info: Arc<MessageInfo>,
    pub is_unavailable: bool,
    pub unavailable_type: UnavailableType,
    pub decrypt_fail_mode: DecryptFailMode,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum UnavailableType {
    Unknown,
    ViewOnce,       // View-once media already viewed
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DecryptFailMode {
    Show,           // Show placeholder in chat
    Hide,           // Hide from chat
}

Notification

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:
Event::Notification(node) => {
    let node_ref = node.get();
    println!("Raw notification tag: {}", node_ref.tag);
}
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:
Event::UndecryptableMessage(undec) => {
    match undec.decrypt_fail_mode {
        DecryptFailMode::Hide => {
            // Silently ignore — infrastructure message (reaction, poll vote, etc.)
        }
        DecryptFailMode::Show => {
            if matches!(undec.unavailable_type, UnavailableType::ViewOnce) {
                println!("View-once message already consumed");
            } else {
                eprintln!("Failed to decrypt message from {}", undec.info.source.sender);
            }
        }
    }
}

Presence Events

ChatPresence

Emitted: For typing indicators and recording states
#[derive(Debug, Clone, Serialize)]
pub struct ChatPresenceUpdate {
    pub source: MessageSource,
    pub state: ChatPresence,
    pub media: ChatPresenceMedia,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ChatPresence {
    Composing,
    Paused,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ChatPresenceMedia {
    Text,
    Audio,
}
Example:
Event::ChatPresence(update) => {
    match (update.state, update.media) {
        (ChatPresence::Composing, ChatPresenceMedia::Text) => {
            println!("{} is typing...", update.source.sender);
        }
        (ChatPresence::Composing, ChatPresenceMedia::Audio) => {
            println!("{} is recording audio...", update.source.sender);
        }
        (ChatPresence::Paused, _) => {
            println!("{} stopped typing", update.source.sender);
        }
    }
}

Presence

Emitted: For online/offline status and last seen
#[derive(Debug, Clone, Serialize)]
pub struct PresenceUpdate {
    pub from: Jid,
    pub unavailable: bool,
    pub last_seen: Option<DateTime<Utc>>,
}
Example:
Event::Presence(update) => {
    if update.unavailable {
        println!("{} is offline", update.from);
        if let Some(last_seen) = update.last_seen {
            println!("Last seen: {}", last_seen);
        }
    } else {
        println!("{} is online", update.from);
    }
}

User update events

PictureUpdate

Emitted: When a user changes their profile picture
#[derive(Debug, Clone, Serialize)]
pub struct PictureUpdate {
    pub jid: Jid,
    pub author: Option<Jid>,
    pub timestamp: DateTime<Utc>,
    pub removed: bool,
    pub picture_id: Option<String>,
}
Fields:
  • jid - The JID whose picture changed (user or group)
  • author - The user who made the change. Present for group picture changes (the admin who changed it). None for personal picture updates.
  • removed - Whether the picture was removed (true) or set/updated (false)
  • picture_id - The server-assigned picture ID. None for deletions.

UserAboutUpdate

Emitted: When a user changes their status/about
#[derive(Debug, Clone, Serialize)]
pub struct UserAboutUpdate {
    pub jid: Jid,
    pub status: String,
    pub timestamp: DateTime<Utc>,
}

PushNameUpdate

Emitted: When a contact changes their display name
#[derive(Debug, Clone, Serialize)]
pub struct PushNameUpdate {
    pub jid: Jid,
    pub message: Box<MessageInfo>,
    pub old_push_name: String,
    pub new_push_name: String,
}

SelfPushNameUpdated

Emitted: When your own push name is updated
#[derive(Debug, Clone, Serialize)]
pub struct SelfPushNameUpdated {
    pub from_server: bool,
    pub old_name: String,
    pub new_name: String,
}

Group Events

GroupUpdate

Emitted: For each action in a group notification (subject changes, participant changes, settings updates, etc.). A single notification may produce multiple GroupUpdate events.
#[derive(Debug, Clone, Serialize)]
pub struct GroupUpdate {
    pub group_jid: Jid,
    pub participant: Option<Jid>,
    pub participant_pn: Option<Jid>,
    pub timestamp: DateTime<Utc>,
    pub is_lid_addressing_mode: bool,
    pub action: GroupNotificationAction,
}
Fields:
  • group_jid - The group this update applies to
  • participant - The admin/user who triggered the change
  • participant_pn - Phone number JID of the participant (for LID-addressed groups)
  • is_lid_addressing_mode - Whether the group uses LID addressing mode
  • action - The specific group notification action (subject change, participant add/remove/promote/demote, description change, etc.)
Example:
Event::GroupUpdate(update) => {
    println!("Group {} updated by {:?}: {:?}", 
        update.group_jid, update.participant, update.action);
}

GroupNotificationAction

The action field on GroupUpdate is a GroupNotificationAction enum with the following variants:
VariantWire tagDescription
Add { participants, reason }<add>Members added to group
Remove { participants, reason }<remove>Members removed from group
Promote { participants }<promote>Members promoted to admin
Demote { participants }<demote>Members demoted from admin
Modify { participants }<modify>Member changed phone number
ChangeNumber { new_owner, sub_group_suggestions }<change_number>A participant changed their phone number. new_owner is read from the jid attribute of <change_number> and sub_group_suggestions is collected from any <sub_group_suggestion> children. The previous JID is carried by GroupUpdate::participant.
Subject { subject, subject_owner, subject_time }<subject>Group name changed
Description { id, description }<description>Group description changed or deleted
Locked { threshold }<locked>Only admins can edit group info
Unlocked<unlocked>All members can edit group info
Announce<announcement>Only admins can send messages
NotAnnounce<not_announcement>All members can send messages
Ephemeral { expiration, trigger }<ephemeral>Disappearing messages setting changed
MembershipApprovalMode { enabled }<membership_approval_mode>Join approval toggled
MembershipApprovalRequest { request_method, parent_group_jid }<membership_approval_request>A user requested to join the group
CreatedMembershipRequests { request_method, parent_group_jid, requests }<created_membership_requests>Admin-side: new join requests appeared
RevokedMembershipRequests { participants }<revoked_membership_requests>Membership requests rejected or cancelled
MemberAddMode { mode }<member_add_mode>Who can add members (admin_add or all_member_add)
NoFrequentlyForwarded<no_frequently_forwarded>Forwarding restricted
FrequentlyForwardedOk<frequently_forwarded_ok>Forwarding allowed
Invite { code }<invite>Member joined via invite link
RevokeInvite<revoke>Invite link revoked
GrowthLocked { expiration, lock_type }<growth_locked>Invite links unavailable
GrowthUnlocked<growth_unlocked>Invite links available again
Create { raw }<create>Group created
Delete { reason }<delete>Group deleted
Link { link_type, raw }<link>Subgroup linked (community)
Unlink { unlink_type, unlink_reason, raw }<unlink>Subgroup unlinked (community)
Unknown { tag }variesUnknown notification tag (forward compatibility)
Participant-related variants include a participants field of type Vec<GroupParticipantInfo>:
pub struct GroupParticipantInfo {
    pub jid: Jid,
    pub phone_number: Option<Jid>,
    pub display_name: Option<String>,
}
display_name carries the server-provided label for a participant — for non-contacts this is typically the masked phone number ("+55•••••••••79"). It is populated only when the participant appears as a <participant> child of a group notification; entries that arrive via <requested_user> (membership requests) leave it None. 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.

Contact notification events

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.

ContactUpdated

Emitted: When a contact’s profile changes (server notification)
#[derive(Debug, Clone, Serialize)]
pub struct ContactUpdated {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
}
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
}

ContactNumberChanged

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
}

ContactSyncRequested

Emitted: When the server requests a full contact re-sync
#[derive(Debug, Clone, Serialize)]
pub struct ContactSyncRequested {
    /// If present, only sync contacts modified after this timestamp.
    pub after: Option<DateTime<Utc>>,
    pub timestamp: DateTime<Utc>,
}
Wire format: <notification type="contacts"><sync after="..."/> Example:
Event::ContactSyncRequested(sync) => {
    if let Some(after) = sync.after {
        println!("Server requests contact sync for changes after {}", after);
    } else {
        println!("Server requests full contact sync");
    }
}

ContactUpdate

Emitted: When a contact’s information changes via app-state sync (e.g., first name, last name set in your address book)
#[derive(Debug, Clone, Serialize)]
pub struct ContactUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::ContactAction>,
    pub from_full_sync: bool,
}
Fields:
  • jid - The contact whose information changed
  • timestamp - When the change occurred
  • 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.

Chat state events

PinUpdate

Emitted: When a chat is pinned/unpinned
#[derive(Debug, Clone, Serialize)]
pub struct PinUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::PinAction>,
    pub from_full_sync: bool,
}

MuteUpdate

Emitted: When a chat is muted/unmuted
#[derive(Debug, Clone, Serialize)]
pub struct MuteUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::MuteAction>,
    pub from_full_sync: bool,
}

ArchiveUpdate

Emitted: When a chat is archived/unarchived
#[derive(Debug, Clone, Serialize)]
pub struct ArchiveUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::ArchiveChatAction>,
    pub from_full_sync: bool,
}

StarUpdate

Emitted: When a message is starred or unstarred
#[derive(Debug, Clone, Serialize)]
pub struct StarUpdate {
    pub chat_jid: Jid,
    pub participant_jid: Option<Jid>,
    pub message_id: String,
    pub from_me: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::StarAction>,
    pub from_full_sync: bool,
}
Fields:
  • chat_jid - The chat containing the starred message
  • participant_jid - The sender of the message (only for group messages from others; None for self-authored or 1-on-1 messages)
  • message_id - The ID of the starred/unstarred message
  • from_me - Whether the starred message was sent by you
Example:
Event::StarUpdate(update) => {
    println!("Message {} in {} was {}starred",
        update.message_id, update.chat_jid,
        if update.action.starred.unwrap_or(false) { "" } else { "un" }
    );
}

MarkChatAsReadUpdate

Emitted: When a chat is marked as read or unread across linked devices
#[derive(Debug, Clone, Serialize)]
pub struct MarkChatAsReadUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::MarkChatAsReadAction>,
    pub from_full_sync: bool,
}
Example:
Event::MarkChatAsReadUpdate(update) => {
    let read = update.action.read.unwrap_or(false);
    println!("Chat {} marked as {}", update.jid, if read { "read" } else { "unread" });
}

DeleteChatUpdate

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
Example:
Event::DeleteChatUpdate(update) => {
    println!("Chat {} deleted (media deleted: {})", update.jid, update.delete_media);
}

ClearChatUpdate

Emitted: When a chat’s messages are cleared (but the chat is kept) on a linked device
#[derive(Debug, Clone, Serialize)]
pub struct ClearChatUpdate {
    pub jid: Jid,
    /// From the index, not the proto — ClearChatAction only has messageRange.
    pub delete_starred: bool,
    /// From the index, not the proto.
    pub delete_media: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::ClearChatAction>,
    pub from_full_sync: bool,
}
Fields:
  • jid - The chat that was cleared
  • delete_starred - Whether starred messages were also removed
  • delete_media - Whether downloaded media was also removed
  • from_full_sync - true while replaying the initial app state full sync
Example:
Event::ClearChatUpdate(update) => {
    println!("Chat {} cleared (starred: {}, media: {})",
        update.jid, update.delete_starred, update.delete_media);
}
See clear_chat for the outbound API that emits this on other devices.

UserStatusMuteUpdate

Emitted: When a contact/group/channel’s status updates are muted or unmuted on a linked device
#[derive(Debug, Clone, Serialize)]
pub struct UserStatusMuteUpdate {
    pub jid: Jid,
    /// `true` = status muted, `false` = unmuted.
    pub muted: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::UserStatusMuteAction>,
    pub from_full_sync: bool,
}
Fields:
  • jid - The entity whose status updates were (un)muted
  • muted - true when status was muted, false when unmuted
  • from_full_sync - true while replaying the initial app state full sync
Example:
Event::UserStatusMuteUpdate(update) => {
    println!("Status of {} {}", update.jid, if update.muted { "muted" } else { "unmuted" });
}
See set_user_status_mute for the outbound API.

DeleteMessageForMeUpdate

Emitted: When a message is deleted locally (not for everyone) across linked devices
#[derive(Debug, Clone, Serialize)]
pub struct DeleteMessageForMeUpdate {
    pub chat_jid: Jid,
    pub participant_jid: Option<Jid>,
    pub message_id: String,
    pub from_me: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::DeleteMessageForMeAction>,
    pub from_full_sync: bool,
}
Fields:
  • chat_jid - The chat containing the deleted message
  • participant_jid - The sender of the message (only for group messages from others; None for self-authored or 1-on-1 messages)
  • message_id - The ID of the deleted message
  • from_me - Whether the deleted message was sent by you
  • action - The underlying protobuf action containing delete_media and optional message_timestamp
Example:
Event::DeleteMessageForMeUpdate(update) => {
    println!("Message {} in {} deleted for me", update.message_id, update.chat_jid);
}

LabelEditUpdate

Emitted: When a chat label is created, renamed, recolored, or deleted on a linked device
#[derive(Debug, Clone, Serialize)]
pub struct LabelEditUpdate {
    pub label_id: String,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::LabelEditAction>,
    pub from_full_sync: bool,
}
Fields:
  • label_id — Stable label identifier
  • action.name — New display name (None when only the deleted flag changes)
  • action.color — WhatsApp color index for the swatch
  • action.deletedSome(true) when the label was removed
  • from_full_synctrue while replaying the initial app state full sync
Example:
Event::LabelEditUpdate(update) => {
    if update.action.deleted == Some(true) {
        println!("Label {} deleted", update.label_id);
    } else {
        println!("Label {} renamed to {:?}", update.label_id, update.action.name);
    }
}

LabelAssociationUpdate

Emitted: When a label is added to or removed from a chat on a linked device
#[derive(Debug, Clone, Serialize)]
pub struct LabelAssociationUpdate {
    pub label_id: String,
    pub chat_jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::LabelAssociationAction>,
    pub from_full_sync: bool,
}
Fields:
  • label_id — Identifier of the label being attached or detached
  • chat_jid — Chat whose label set changed
  • action.labeledSome(true) when the label was added, Some(false) when removed
  • from_full_synctrue while replaying the initial app state full sync
Example:
Event::LabelAssociationUpdate(update) => {
    let attached = update.action.labeled == Some(true);
    println!(
        "Label {} {} chat {}",
        update.label_id,
        if attached { "added to" } else { "removed from" },
        update.chat_jid,
    );
}
See Labels for the outbound API that emits these events on other devices.

History sync events

HistorySync

Emitted: For chat history synchronization
Event::HistorySync(Box<LazyHistorySync>)
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>,
    peer_data_request_session_id: Option<String>,
    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>;

    /// Session ID set only on ON_DEMAND syncs. Use it to correlate the
    /// blob with the original `fetchMessageHistory` /
    /// `requestPlaceholderResend` request. Server-pushed syncs return
    /// `None`.
    pub fn peer_data_request_session_id(&self) -> Option<&str>;

    /// 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 decodingsync_type(), chunk_order(), progress(), and peer_data_request_session_id() are extracted during the streaming phase and available immediately
  • Parse-once semanticsget() 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 accessraw_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, peer_data_request_session_id) is serialized, not the blob
  • On-demand correlationpeer_data_request_session_id() is set only on syncs the server pushes in response to fetchMessageHistory / requestPlaceholderResend. Server-initiated syncs (initial bootstrap, recent, push-name) return None. Use it to route the blob back to the request that triggered it.
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.

OfflineSyncPreview

Emitted: Preview of pending offline sync data when reconnecting
#[derive(Debug, Clone, Serialize)]
pub struct OfflineSyncPreview {
    pub total: i32,
    pub app_data_changes: i32,
    pub messages: i32,
    pub notifications: i32,
    pub receipts: i32,
}
Example:
Event::OfflineSyncPreview(preview) => {
    println!("Syncing {} items ({} messages, {} notifications)", 
        preview.total, preview.messages, preview.notifications);
}

OfflineSyncCompleted

Emitted: When offline sync completes after reconnection
#[derive(Debug, Clone, Serialize)]
pub struct OfflineSyncCompleted {
    pub count: i32,
}
Example:
Event::OfflineSyncCompleted(sync) => {
    println!("Offline sync completed: {} items processed", sync.count);
}
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.

Device Events

DeviceListUpdate

Emitted: When a user’s device list changes (a companion device is added, removed, or updated)
#[derive(Debug, Clone, Serialize)]
pub struct DeviceListUpdate {
    pub user: Jid,
    pub lid_user: Option<Jid>,
    pub update_type: DeviceListUpdateType,
    pub devices: Vec<DeviceNotificationInfo>,
    pub key_index: Option<KeyIndexInfo>,
    pub contact_hash: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DeviceListUpdateType {
    Add,
    Remove,
    Update,
}

#[derive(Debug, Clone, Serialize)]
pub struct DeviceNotificationInfo {
    pub device_id: u32,
    pub key_index: Option<u32>,
}
Fields:
FieldDescription
userThe user whose device list changed (PN JID)
lid_userThe user’s LID JID, if known from the notification
update_typeWhether a device was added, removed, or updated
devicesList of affected devices with their IDs and key indexes
key_indexADV key index info for device identity verification (add operations only)
contact_hashServer-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.

IdentityChange

Emitted: When a contact reinstalls WhatsApp (their identity key changed). The event fires from two paths: an explicit server <identity/> notification, or a locally-detected change discovered while decrypting an incoming message. The implicit field distinguishes them.
#[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>,
    /// `true` when detected locally during decrypt (mirrors WA Web
    /// `saveIdentity` -> `handleNewIdentity`), `false` when triggered by the
    /// server's `<identity/>` notification.
    pub implicit: bool,
}
Fields:
  • user — The phone number JID of the user whose identity changed
  • lid_user — The user’s LID JID, if provided in the notification
  • implicitfalse for server-pushed <identity/> notifications (full cleanup performed); true for locally-detected changes during decrypt (lighter cleanup, see below)
This event corresponds to WhatsApp Web’s WAWebHandleIdentityChange flow. When the server sends an <identity/> notification inside a type="encrypt" stanza, the client:
  1. Clears the device record for the user (deletes Signal sessions for all non-primary devices). Per-device sender key tracking is not wiped here — matching WhatsApp Web’s WAWebUpdateLocalSignalSession, SKDM redistribution is driven per-group/per-device by retry receipts (markForgetSenderKey), so a global wipe would empty the tracker too aggressively.
  2. Deletes the primary device session and identity key so a fresh session can be established (matching WhatsApp Web’s deleteRemoteInfo)
  3. Deletes the status@broadcast sender key for forward secrecy on the next status send (matching WhatsApp Web’s markStatusSenderKeyRotate)
  4. Invalidates the device registry cache so the next send triggers a fresh device list sync
  5. Dispatches this event so your application can show a “security code changed” notice
  6. 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:
  1. Clears the old identity key and retries decryption with the new identity, preserving the old session for in-flight messages
  2. Handles InvalidPreKeyId errors in the retry path by sending a retry receipt so the sender can establish a new session
  3. 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.

Implicit (locally-detected) identity changes

The client also fires IdentityChange with implicit: true when decrypting a peer’s message replaces an existing identity key with a different one — for example, when a contact’s reinstall reaches you through an incoming message before the server <identity/> push arrives. This mirrors WhatsApp Web’s saveIdentityhandleNewIdentity flow. The implicit path is deliberately lighter than the server push:
  • Clears the device record (non-primary sessions + per-device sender key tracking)
  • Invalidates the device registry cache so the next send re-runs usync
  • Re-issues an active TC token if one exists
It does not delete the primary session, rotate the status@broadcast sender key, or proactively re-establish sessions — the in-flight message is already establishing a new session, and the heavier reset is handled when the server <identity/> push reliably follows.
Identity change notifications from companion devices (device ID != 0) and from your own JID are ignored on both paths — only primary device identity changes for other users are processed.
Example:
Event::IdentityChange(change) => {
    if change.implicit {
        // Detected while decrypting an incoming message — the server
        // <identity/> push will usually follow with the full reset.
        println!("Local identity change for {}", change.user);
    } else {
        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
}

BusinessStatusUpdate

Emitted: When a business account status changes
#[derive(Debug, Clone, Serialize)]
pub struct BusinessStatusUpdate {
    pub jid: Jid,
    pub update_type: BusinessUpdateType,
    pub timestamp: DateTime<Utc>,
    pub target_jid: Option<Jid>,
    pub hash: Option<String>,
    pub verified_name: Option<String>,
    pub product_ids: Vec<String>,
    pub collection_ids: Vec<String>,
    pub subscriptions: Vec<BusinessSubscription>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum BusinessUpdateType {
    RemovedAsBusiness,
    VerifiedNameChanged,
    ProfileUpdated,
    ProductsUpdated,
    CollectionsUpdated,
    SubscriptionsUpdated,
    Unknown,
}

Newsletter Events

NewsletterLiveUpdate

Emitted: When reaction counts change or messages are updated on a newsletter you’re subscribed to (via subscribe_live_updates).
#[derive(Debug, Clone, Serialize)]
pub struct NewsletterLiveUpdate {
    pub newsletter_jid: Jid,
    pub messages: Vec<NewsletterLiveUpdateMessage>,
}

#[derive(Debug, Clone, Serialize)]
pub struct NewsletterLiveUpdateMessage {
    pub server_id: u64,
    pub reactions: Vec<NewsletterLiveUpdateReaction>,
}

#[derive(Debug, Clone, Serialize)]
pub struct NewsletterLiveUpdateReaction {
    pub code: String,
    pub count: u64,
}
Fields:
  • 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.

Call Events

IncomingCall

Emitted: When the server delivers a <call> stanza — voice or video, 1-on-1 or group. Mirrors WhatsApp Web’s inbound call signaling.
#[derive(Debug, Clone, Serialize)]
pub struct IncomingCall {
    pub from: Jid,
    /// Stanza id; distinct from `CallAction::call_id`.
    pub stanza_id: String,
    pub notify: Option<String>,
    pub platform: Option<String>,
    pub version: Option<String>,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub timestamp: DateTime<Utc>,
    pub offline: bool,
    pub action: CallAction,
}
The action field is a tagged enum that mirrors the inner stanza child (<offer>, <offer_notice>, <preaccept>, <accept>, <reject>, <terminate>):
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CallAction {
    Offer {
        call_id: String,
        call_creator: Jid,
        caller_pn: Option<Jid>,
        caller_country_code: Option<String>,
        device_class: Option<String>,
        joinable: bool,
        is_video: bool,
        audio: Vec<CallAudioCodec>,
        /// Set on group calls. Primary group signal per `WAWebVoipGatingUtils`.
        group_jid: Option<Jid>,
    },
    /// Group-call notification fan-out to members. The router acks it via the
    /// generic call ack — no offer-receipt is expected.
    OfferNotice {
        call_id: String,
        call_creator: Jid,
        is_video: bool,
        is_group: bool,
    },
    PreAccept { call_id: String, call_creator: Jid },
    Accept    { call_id: String, call_creator: Jid },
    Reject    { call_id: String, call_creator: Jid },
    Terminate {
        call_id: String,
        call_creator: Jid,
        duration: Option<u32>,
        audio_duration: Option<u32>,
    },
}
Behavior:
  • The router automatically acks every <call> stanza. For Offer it additionally sends an <receipt><offer/></receipt> so the caller’s UI advances past “ringing”.
  • OfferNotice is the server’s fan-out to other group members when a group call starts. No offer-receipt is sent — only the generic ack.
  • Use action.call_id() and action.call_creator() to access the common identifiers without matching every variant.
Detecting group calls:
Event::IncomingCall(call) => match &call.action {
    CallAction::Offer { is_video, group_jid: Some(group), .. } => {
        // 1-to-many group call offer addressed directly to you.
        let kind = if *is_video { "video" } else { "voice" };
        println!("Incoming group {kind} call in {group} from {}", call.from);
    }
    CallAction::Offer { is_video, group_jid: None, .. } => {
        let kind = if *is_video { "video" } else { "voice" };
        println!("Incoming 1-on-1 {kind} call from {}", call.from);
    }
    CallAction::OfferNotice { is_group: true, is_video, call_creator, .. } => {
        // Group-call fan-out: another member started a call in a group you belong to.
        let kind = if *is_video { "video" } else { "voice" };
        println!("Group {kind} call started by {call_creator}");
    }
    CallAction::Terminate { call_id, duration, .. } => {
        println!("Call {call_id} ended after {:?}s", duration);
    }
    _ => {}
},
group_jid on CallAction::Offer is the primary signal for distinguishing a group call from a 1-on-1 call (matches WhatsApp Web’s WAWebVoipGatingUtils). OfferNotice is the secondary signal for members who were not directly offered the call — for example, when you are a passive group member receiving the announcement that a call started.

Notification Events

DisappearingModeChanged

Emitted: When a contact changes their default disappearing messages setting. Sent by the server as a <notification type="disappearing_mode"> stanza.
#[derive(Debug, Clone, Serialize)]
pub struct DisappearingModeChanged {
    pub from: Jid,
    pub duration: u32,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub setting_timestamp: DateTime<Utc>,
}
Fields:
  • 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);
}

Raw stanza events

RawNode

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.

Event handler patterns

Bot builder pattern

use whatsapp_rust::bot::Bot;
use wacore::types::events::Event;

let mut bot = Bot::builder()
    .with_backend(backend)
    .on_event(|event, client| async move {
        match &*event {
            Event::Message(msg, info) => {
                // Handle message
            }
            Event::Connected(_) => {
                // Handle connection
            }
            _ => {}
        }
    })
    .build()
    .await?;

Multiple Handlers

struct MessageHandler;
impl EventHandler for MessageHandler {
    fn handle_event(&self, event: Arc<Event>) {
        if let Event::Message(msg, info) = &*event {
            // Handle messages
        }
    }
}

struct ConnectionHandler;
impl EventHandler for ConnectionHandler {
    fn handle_event(&self, event: Arc<Event>) {
        match &*event {
            Event::Connected(_) => { /* ... */ }
            Event::Disconnected(_) => { /* ... */ }
            _ => {}
        }
    }
}

client.register_handler(Arc::new(MessageHandler));
client.register_handler(Arc::new(ConnectionHandler));

ChannelEventHandler

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 asynchronously
while 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.

Custom async event handlers

For custom channel-based patterns, you can implement EventHandler directly:
use tokio::sync::mpsc;
use std::sync::Arc;

let (tx, mut rx) = mpsc::unbounded_channel();

struct AsyncHandler {
    tx: mpsc::UnboundedSender<Arc<Event>>,
}

impl EventHandler for AsyncHandler {
    fn handle_event(&self, event: Arc<Event>) {
        let _ = self.tx.send(event);
    }
}

client.register_handler(Arc::new(AsyncHandler { tx }));

// Process events asynchronously
tokio::spawn(async move {
    while let Some(event) = rx.recv().await {
        // event is Arc<Event> — no cloning needed
    }
});
Since events are dispatched as Arc<Event>, you can forward them to channels without any cloning overhead.

Performance Optimization

LazyHistorySync

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().
pub struct LazyHistorySync {
    raw_bytes: Bytes,                                  // Reference-counted decompressed bytes
    sync_type: i32,                                    // Cheap metadata
    chunk_order: Option<u32>,
    progress: Option<u32>,
    peer_data_request_session_id: Option<String>,      // Set only on ON_DEMAND syncs
    parsed: OnceLock<Option<Box<wa::HistorySync>>>,    // Decoded on first access
}
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();
}

Arc<Event> dispatch

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(Arc<wa::Message>, Arc<MessageInfo>). Both the wa::Message body and the MessageInfo are Arc-wrapped, 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.

Best Practices

Event Filtering

.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
        }
        _ => {}
    }
})

Error Handling

.on_event(|event, client| async move {
    if let Err(e) = handle_event(&event, client).await {
        eprintln!("Event handler error: {}", e);
    }
})

async fn handle_event(event: &Event, client: Arc<Client>) -> Result<()> {
    match event {
        Event::Message(msg, info) => {
            process_message(msg, info, client).await?
        }
        _ => {}
    }
    Ok(())
}

Spawning Tasks

.on_event(|event, client| async move {
    if let Event::Message(msg, info) = &*event {
        let client = client.clone();
        let event = event.clone(); // Arc clone — O(1)

        tokio::spawn(async move {
            if let Event::Message(msg, info) = &*event {
                process_message(msg, info, &client).await;
            }
        });
    }
})

Architecture

Understand the event bus system

Authentication

Learn about pairing events

Sending messages

Sending and receiving messages

Client API

Complete client API reference