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
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

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:

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.
  • EventKind is a #[repr(u8)] discriminant — one variant per Event variant (Messages, 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 128) that bounds the number of kinds. It exists because each discriminant is packed as a bit in EventInterest’s u128 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 128-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.
EventInterest was widened from a u64 to a u128 mask (and EventKind::CAPACITY from 64 to 128) as part of the pre-1.0 event-payload API freeze, since the kind count had reached 58/64. The public surface (EventInterest::of, .with(kind), .wants(kind), EventInterest::ALL) is unchanged — only the internal bit width doubled, giving headroom for future event kinds.
With the Bot builder, the same narrowing is available via on_event_for:
on_event (without kinds) keeps subscribing to everything.

Event Enum

Location: wacore/src/types/events.rs
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.
Payload stability: every event payload struct is sealed with #[non_exhaustive] plus a bon builder for construction (Type::builder()…build()), so a payload can gain fields later without breaking consumers. The freeze rolled out in stages — ServerAck first, then the notification/presence/contact/group payloads and the app-state-sync mutation payloads, then the remaining message/newsletter/device/pairing payloads and the four unit-marker events (Connected, ClientOutdated, QrScannedWithoutMultidevice, StreamReplaced, each an empty sealed struct built as Connected::builder().build()) — and is now complete across the whole Event surface. Read the fields you need (e.g. ack.class) or keep a .. rest when destructuring (required for a #[non_exhaustive] struct pattern from outside the defining crate — e.g. InboundMessage { message, info, .. }), rather than binding every field. A maybe-absent field is always modeled as Option<T> (with a maybe_* builder setter), never an empty-string or zero sentinel. The library itself constructs every payload via its builder — a struct literal from outside wacore/whatsapp-rust no longer compiles (E0639).

Connection Events

Connected

Emitted: After successful connection and authentication
Usage:

Disconnected

Emitted: When the connection ends without the client itself intentionally closing or reconnecting it — covers both a routine server-initiated stream recycle and a genuine transport failure (see reason below to tell them apart)
Fields:
  • reason: DisconnectReason — why the transport ended. Check reason.is_clean_shutdown() to tell a routine server-initiated stream recycle (WhatsApp’s normal reconnect path) apart from a genuine transport failure, without parsing logs. See DisconnectReason for the variants.
Behavior: Client automatically attempts reconnection
Breaking change: Disconnected gained the reason field (previously a unit struct). Disconnected is now #[non_exhaustive] too, so a destructuring pattern needs a .. rest: Event::Disconnected(Disconnected { reason, .. }), or just Event::Disconnected(_).

ConnectFailure

Emitted: When connection fails with a specific reason
Breaking change: ConnectFailure.message changed from String (empty-string sentinel when the server omitted the message attribute) to Option<String>, matching the “maybe-absent field is always Option” convention. unwrap_or_default() at a call site becomes .unwrap_or_default() on the Option (same fallback) or, better, match/if let Some(msg) = &failure.message.
Helper methods:
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
Usage:

StreamReplaced

Emitted: When another device connects with the same credentials (stream error code 409 or <conflict type="replaced">)
Usage:
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
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:
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)
Usage:
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
Example:
Breaking change: PairingQrCode moved from inline fields directly on the Event::PairingQrCode { code, timeout } variant to a dedicated sealed struct — Event::PairingQrCode(PairingQrCode). Update destructuring patterns to match through the newtype, with a .. rest since the inner struct is #[non_exhaustive].

PairingCode

Emitted: When pair code is generated
timeout is the remaining validity window, not always the full ~180 seconds: the clock starts before the stage-1 companion_hello round-trip, so timeout is already reduced by however long that request took.
Example:
Breaking change: PairingCode moved from inline fields on Event::PairingCode { code, timeout } to a dedicated sealed struct — Event::PairingCode(PairingCode).

PairingCodeRefresh

Emitted: When the server asks the companion to refresh an in-progress pair code (WA Web refreshAltLinkingCode / forceManualRefresh). Only fired while a pair-code flow is outstanding and the server’s ref matches it — a refresh_code notification for a stale or unrelated flow is silently ignored.
Example:
Breaking change: PairingCodeRefresh moved from an inline Event::PairingCodeRefresh { force_manual } field to a dedicated sealed struct — Event::PairingCodeRefresh(PairingCodeRefresh). A matches! check on the field becomes matches!(event, Event::PairingCodeRefresh(r) if r.force_manual).

PairSuccess

Emitted: When pairing completes successfully
Example:

PairError

Emitted: When pairing fails

PairPasskeyRequest

Emitted: During passkey (SHORTCAKE_PASSKEY) linking, when the server asks for a WebAuthn assertion to gate the link
If a PasskeyAuthenticator is registered via Client::set_passkey_authenticator, the client obtains and sends the assertion automatically; this event is for hosts that drive the WebAuthn ceremony manually. Example:

PairPasskeyConfirmation

Emitted: When the passkey link reaches the verification stage
Example:

PairPasskeyError

Emitted: When a passkey link attempt fails

QrScannedWithoutMultidevice

Emitted: When a QR code is scanned by a device that does not support multi-device
Usage:

ClientOutdated

Emitted: When the server rejects the connection because the client version is too old (connect failure code 405)
Usage:
Behavior: Auto-reconnect is disabled. You must update to a newer version of the library.

Message Events

Messages

Emitted: For all incoming messages (text, media, etc.), one event per durable commit.
Live traffic dispatches a batch of one, so per-message latency is unchanged from the previous single-message event. During the offline drain the client accumulates decrypted messages and dispatches one Event::Messages per durable commit (size/byte/timeout triggers, matching WhatsApp Web’s MessageProcessorCache — see Inbound Durability). MessageBatch behaves as a collection: batch.iter(), batch.len(), batch.is_empty(), batch.first(), and for msg in &batch all work directly. Event::as_messages() returns Option<&MessageBatch>, and Event::messages() returns an iterator over the batch’s InboundMessages (empty for any other event kind) — use it to scan a mixed event stream without matching on Event::Messages first.
Breaking change: InboundMessage and MessageBatch are now #[non_exhaustive], sealed with a bon builder. A for InboundMessage { message, info } in batch.iter() destructuring pattern needs a .. rest: for InboundMessage { message, info, .. } in batch.iter().
Both the message body and MessageInfo are Arc-wrapped inside InboundMessage. The same Arc slice handed to a registered durability hook is what this event carries — no deep clone, and a consumer never sees a message the hook did not commit (newsletter messages and PDO placeholder recoveries are the two exceptions: they dispatch event-only, bypassing the hook). 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:
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:
EditAttribute: Indicates the type of edit or revocation applied to a message. Values correspond to the wire-format edit attribute on message stanzas.
MsgBotInfo: Present when the message originates from a WhatsApp bot (AI-generated responses). Contains streaming edit metadata.
MsgMetaInfo: Additional metadata for message threading, targeting, and abuse reporting.
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.
Breaking change: verified_name changed from the raw, never-populated wa::VerifiedNameCertificate to Option<Box<VerifiedName>> — the decoded business display name (see VerifiedName for its name/serial/issuer/certificate fields). Business senders attach this cert to the <message> stanza’s <verified_name> child; it’s now decoded the same way the usync and business-notification parsers already did, so a WABA sender’s display name (e.g. “HDFC Bank Ltd”) reaches this field instead of being silently dropped. It’s boxed since most messages carry none. Undecodable cert bytes don’t fail message parsing — the field is just None in that case.verified_name_serial is unrelated to and unchanged by this: it’s parsed from the envelope’s own verified_name attribute (an integer), while verified_name.serial is decoded from the child cert’s Details.serial field (a string). Both name the same certificate serial in a well-formed stanza, but they’re read from two different places on the wire and can be populated independently — a stanza could in principle carry one without the other.
Every Option<T> field on MessageInfo, MessageSource, MsgBotInfo, and MsgMetaInfo is annotated #[serde(skip_serializing_if = "Option::is_none")]. A JSON serialization of these structs (e.g. via serde_json::to_value, for structured logging or observability dumps) omits an absent field entirely instead of emitting it as null. This applies uniformly across all optional fields as of the allocation/serialization cleanup in whatsapp-rust#1059 — earlier releases only omitted the four v0.6-era fields above, and serialized the remaining optional fields as explicit null when absent. Present field values are unchanged; only the JSON output shape for absent fields differs.
DeviceSentMeta: Present on device-synced messages (messages you sent from another device).
Example:

Receipt

Emitted: For delivery/read/played receipts
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:

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, hosted content, bot fanouts, or other server-side unavailability. For ViewOnce/Hosted/Bot, the phone never shares that content with a companion device, so the client acks the stanza directly instead of requesting it. Only the Unknown case goes through PDO recovery
When is_unavailable is true, the message had no encrypted content in the stanza. For UnavailableType::Unknown, the client sends a PDO request to your primary phone, and if the phone responds successfully, a follow-up Event::Messages is dispatched with the recovered content (event-only — a PDO recovery bypasses the durability hook and the offline-drain batcher, dispatching immediately with BatchOrigin::Live). For ViewOnce, Hosted, and Bot, no PDO request is sent — that content is unrecoverable by design, so no follow-up Event::Messages should be expected.

Notification

Emitted: For raw notification stanzas that are not handled by a more specific event type
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:

ServerAck

Emitted: Observe-only, for every server <ack> stanza that carries an id — dispatched independently of the internal send-waiter resolution, so registering a handler never interacts with the send/phash flow.
Server acks cover every outgoing stanza class, not just messages — filter on class rather than correlating ids blind. Dispatch is gated on a registered handler existing for this event kind, so the hot ack path allocates nothing when no consumer subscribes.
ServerAck was the first payload sealed under the stability policy above: it’s #[non_exhaustive] and constructed via a generated bon builder (ServerAck::builder().id(...).maybe_class(...)…build()). This only affects code that constructs a ServerAck (the client itself) or uses exhaustive struct-pattern destructuring (which is already disallowed by the .. guidance above); accessing fields by name with dot notation (ack.id, ack.class), as in the example below, is unaffected.
Example:

Presence Events

ChatPresence

Emitted: For typing indicators and recording states
Example:

Presence

Emitted: For online/offline status and last seen
Example:

User update events

PictureUpdate

Emitted: When a user changes their profile picture
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

PushNameUpdate

Emitted: When a contact changes their display name

SelfPushNameUpdated

Emitted: When your own push name is updated

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.
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:

GroupNotificationAction

The action field on GroupUpdate is a GroupNotificationAction enum with the following variants: Participant-related variants include a participants field of type Vec<GroupParticipantInfo>:
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:
  • 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:
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)
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:

ContactNumberChanged

Emitted: When a contact changes their phone number
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:

ContactSyncRequested

Emitted: When the server requests a full contact re-sync
Wire format: <notification type="contacts"><sync after="..."/> Example:

ContactUpdate

Emitted: When a contact’s information changes via app-state sync (e.g., first name, last name set in your address book)
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:
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

MuteUpdate

Emitted: When a chat is muted/unmuted

ArchiveUpdate

Emitted: When a chat is archived/unarchived

StarUpdate

Emitted: When a message is starred or unstarred
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:

MarkChatAsReadUpdate

Emitted: When a chat is marked as read or unread across linked devices
Example:

DeleteChatUpdate

Emitted: When a chat is deleted across linked devices
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:

ClearChatUpdate

Emitted: When a chat’s messages are cleared (but the chat is kept) on a linked device
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:
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
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:
See set_user_status_mute for the outbound API.

DeleteMessageForMeUpdate

Emitted: When a message is deleted locally (not for everyone) across linked devices
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:

LabelEditUpdate

Emitted: When a chat label is created, renamed, recolored, or deleted on a linked device
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:

LabelAssociationUpdate

Emitted: When a label is added to or removed from a chat on a linked device
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:
See Labels for the outbound API that emits these events on other devices.

History sync events

HistorySync

Emitted: For chat history synchronization
LazyHistorySync holds the original compressed zlib payload and only decodes the full wa::HistorySync proto on demand. Cheap metadata (sync_type, chunk_order, progress) is available without decoding. Queued events are ~10× smaller than the decompressed form — a typical InitialBootstrap chunk is 5–20 MB inflated, ~1–2 MB compressed.
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. The compressed payload is never consumed — get() can be called multiple times and other accessors still work afterward
  • Cheap cloneClone is a refcount bump on the compressed buffer; no decode cache is carried over, so each cloned instance re-inflates independently on demand
  • Decompress on demanddecompress() re-inflates into a fresh buffer on every call (no caching). Use get() for repeated full-proto access, or stream() for memory-bounded incremental access
  • Streamingstream() yields one conversation at a time via HistorySyncStream, keeping peak memory near the largest single conversation rather than the full decompressed size
  • 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.
For large InitialBootstrap blobs, prefer stream() for incremental processing or decompress() for one-shot custom decoding. When decompressed_size() is large (e.g. > 256 KB), wrap the call in tokio::task::spawn_blocking (cloning compressed_bytes() into the closure) to avoid blocking the async runtime.
Example — full decode:
Example — streaming (memory-bounded):
Example — decompress for custom parsing:
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.

HistorySyncStream

wacore::history_sync::HistorySyncStream iterates a compressed blob with bounded memory. At any point, only the current inflate window plus the largest single serialized conversation is resident — the full decompressed blob is never materialized.
Error variants relevant to streaming:

OfflineSyncPreview

Emitted: Preview of pending offline sync data when reconnecting
total is authoritative — the <ib><offline_preview> stanza’s own count attribute — and the per-kind counts are not guaranteed to sum to it. calls and statuses count the server’s call and status backlog attributes; a server that predates these fields leaves both at 0. Example:

OfflineSyncCompleted

Emitted: When offline sync completes after reconnection
Example:
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.

DirtyState

Emitted: When the server sends an <ib><dirty type="..." timestamp="..."> marker, telling the client one of its cached protocol domains is stale server-side.
Fields:
  • dirty_type - The stale domain, mirroring wacore::iq::dirty::DirtyType: AccountSync, Groups, SyncdAppState, NewsletterMetadata, or Other(String) for a wire value the client doesn’t otherwise recognize.
  • timestamp - Option<u64>, None if the <dirty> stanza omitted the timestamp attribute.
This is a pure observability hook — it does not replace or gate the client’s built-in handling. The client always sends the matching <clean> IQ (throttled behind offline-sync completion for Groups/NewsletterMetadata, per WAWebHandleDirtyBits) and, for SyncdAppState, re-syncs all app-state collections, exactly as it did before this event existed. DirtyState fires first, right before that built-in work starts, so a handler can refresh its own domain-specific derived state (e.g. invalidate a local groups cache) without parsing raw <ib> stanzas via RawNode or racing the client’s own resync.
Example:

Device Events

DeviceListUpdate

Emitted: When a user’s device list changes (a companion device is added, removed, or updated)
Fields: 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.
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:

BusinessStatusUpdate

Emitted: When a business account status changes

Newsletter Events

NewsletterLiveUpdate

Emitted: When reaction counts change or messages are updated on a newsletter you’re subscribed to (via subscribe_live_updates).
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:
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.
The action field is a tagged enum that mirrors the inner stanza child (<offer>, <offer_notice>, <preaccept>, <accept>, <reject>, <terminate>):
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:
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.
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:

Raw stanza events

RawNode

Emitted: The raw stanza received from the server. To receive this event, call client.set_raw_node_forwarding(true) to enable dispatch and include EventKind::RawNode in your handler’s interest(). When forwarding is disabled or no handler wants RawNode, the bus skips this dispatch entirely (zero overhead on the hot path).
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:
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

Delivery order and backpressure

Closures registered via on_event, on_event_for, on_message, and the other typed registrars are bridged onto the CoreEventBus by an internal adapter with a configurable delivery strategy, set via BotBuilder::with_event_delivery:
  • EventDelivery::Concurrent (default) — each event is fanned out to every interested callback on its own spawned task. A slow callback never stalls the bus or its siblings, but ordering across events is not guaranteed, and a persistently slow consumer can accumulate unbounded in-flight tasks.
  • EventDelivery::Ordered { capacity } — events are handed to a single drainer task through a bounded mailbox and delivered to callbacks strictly in arrival order (within an event, interested callbacks run in registration order). This mirrors the ordered messages.upsert contract of WA Web (preserveOrder: true), whatsmeow, and Baileys. When the mailbox is full the event is dropped — counted in StatsSnapshot::events_dropped — instead of backpressuring the receive pipeline or growing without bound. A panicking callback is caught and logged; the drainer keeps running and later events still get delivered.
Struct-based handlers registered with with_event_handler (or client.register_handler) always run handle_event inline on the dispatch path — EventDelivery only governs the closure-based registrars.
Ordered trades throughput and drop-under-load for a stronger ordering guarantee. If dropped events are unacceptable, pair it with an inbound durability hook for at-least-once redelivery — the hook’s buffering is independent of the delivery mailbox.

Multiple Handlers

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.
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:
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 compressed payload is stored as reference-counted Bytes, and full protobuf decoding only happens if your code calls get(). See the HistorySync event reference for the complete API.
Cloning: Clone is a refcount bump on the compressed buffer. No decode cache is carried over — each clone re-inflates independently on demand. Usage:

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 Messages(MessageBatch). Both the wa::Message body and the MessageInfo inside each InboundMessage are Arc-wrapped, enabling zero-cost sharing across the message dispatch, durability hook, retry receipt, and PDO recovery paths without cloning the full struct — the batch handed to a registered durability hook is the very same Arc<[InboundMessage]> this event carries. 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

Error Handling

Spawning Tasks

Architecture

Understand the event bus system

Authentication

Learn about pairing events

Sending messages

Sending and receiving messages

Client API

Complete client API reference