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
- 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
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, overrideinterest() 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.
EventKindis a#[repr(u8)]discriminant — one variant perEventvariant (Messages,Connected,Receipt, …). The enum is#[non_exhaustive], somatchblocks onEventKindmust include a wildcard arm (_ => …); new kinds may be added in minor releases as the library tracks new server events.EventKind::CAPACITYis a publicu8constant (currently128) that bounds the number of kinds. It exists because each discriminant is packed as a bit inEventInterest’su128mask, 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.EventInterestis a 128-bit set of kinds. Build it withEventInterest::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.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 went first, then the notification/presence/contact/group payloads and the app-state-sync mutation payloads, then the remaining message/newsletter/device/pairing payloads and three more unit-marker events. At the time, each of those three was an empty sealed struct built as Connected::builder().build(): Connected, QrScannedWithoutMultidevice, StreamReplaced. The rollout is now complete across the whole Event surface. Two unit-marker events have since gained a field and stopped being empty: ClientOutdated gained raw (see ClientOutdated below), and Connected gained app_version_fallback in PR #1360 (see Connected below). QrScannedWithoutMultidevice and StreamReplaced are the two still empty today. 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 the session is authenticated and has asked the server to leave passive mode. On a fresh pairing (and on any reconnect before the account’s critical app-state collections have synced), the client waits for the critical app-state sync to produce an answer first — see Critical app-state sync (pairing bootstrap) — so the push name and blocklist are normally in place by the time this fires. As of PR #1291, it waits but does not withhold: a critical collection the server refused or couldn’t deliver is reported viaAppStateSyncFailed and Connected fires regardless, since a session already delivering messages shouldn’t leave a consumer believing nothing ever connected.
-
app_version_fallback— added in PR #1360; additive, so an existingConnected::builder().build()still compiles (app_version_fallbackdefaults toNone). Present when this connect could not resolve the app version from its source and settled for the version the device already held instead. Absent on every normal connect, soSomeis the whole signal. In practice this only fires on thewasm32target. The native build’s source isweb.whatsapp.com/sw.js. A client that can’t reach it treats that as a real break of WhatsApp Web itself, and failsconnect()withConnectError::Versioninstead of falling back — seeConnectError. The browser build’s source is the Facebook JS SDK bundle atconnect.facebook.net, which sits on common tracker blocklists (uBlock Origin, Brave shields, corporate DNS). A client blocked from reaching it connects anyway, on the version it already has, rather than refusing to connect over what is, for a large share of browser users, an ad blocker doing its job.
AppVersionFallback::version— the version this session actually connected with.AppVersionFallback::compiled_default—truewhen that version is the one compiled into the library. The payload carries no resolution timestamp for it, so treat the release’s age as a lower bound on its staleness, not the exact figure — the bundled revision could already have been behind current WhatsApp Web when this release was cut.falsewhen the device had already resolved a version on an earlier connect, so its staleness is only however long it’s been since that version was last successfully resolved — that’s the length of this outage only if the previous connect is what resolved it; a longer-running outage across several connects makes it older still. (Awith_versioncall on this connect never produces a fallback at all — see thewith_versionnote — but a device that carried an override from a past connect and later drops it can still hit this path, and would correctly reportfalsehere too.)AppVersionFallback::reason—SourceUnreachableis the routine case: a blocked or offline source. It often clears on a later connect once the block or outage lifts, though a durable blocklist entry can outlast that — this alone doesn’t guarantee recovery.SourceUnparsablemeans the source answered but the bundle no longer carries the field the parser looks for, which points at the source having changed shape rather than a transient condition; a later connect can still resolve it, e.g. if the change reverts, but it’s the more likely one to need attention (a version pin viawith_version, or a library update).
The server tolerates an app version some days behind current, which is what makes connecting on a fallback a real option rather than a guess. A consumer that can’t accept a stale version, or wants its own policy, can check
app_version_fallback on Connected and warn, refuse the session, or pin a version of its own with with_version.Leaving passive mode is best effort: a failed
set_passive(false) call is only logged, not retried, and the connection is announced anyway. Treat Connected as “the client believes stanzas should be flowing,” not a guarantee the server agrees.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 (seereason below to tell them apart)
reason: DisconnectReason— why the transport ended. Checkreason.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. SeeDisconnectReasonfor the variants.
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 reasonBreaking 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.The 403 variant was renamed
MainDeviceGone → AccountLocked 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 bannedcode— the ban sub-reason from thecodeattributeexpire— how long the ban lasts, as achrono::Durationmessage— the server’s free-text detail, when presenturl— the support/appeal link the official ban screen opens, when the server sent oneraw— the whole<failure>stanza
Breaking change:
TemporaryBan gained message, url and raw. It also gained a stricter emission rule: a <failure reason="402"> missing code or expire — or whose expire doesn’t fit a chrono::Duration — no longer dispatches TemporaryBan with an invented zero expiry. Such a stanza now surfaces as ConnectFailure { reason: ConnectFailureReason::TempBanned, raw: Some(node), .. } instead, carrying the same raw stanza. A consumer that matched on Event::TemporaryBan to detect an incomplete ban stanza will now see Event::ConnectFailure.StreamReplaced
Emitted: When another device connects with the same credentials (stream error code 409 or<conflict type="replaced">)
LoggedOut
Emitted: When the session is invalidated by the server (stream error code 401 or 516) or whenclient.logout() is called
on_connect—trueif the logout happened during a connection attempt (server-initiated),falseif triggered byclient.logout()or a stream error while connectedreason— The reason for the logout (e.g.,ConnectFailureReason::LoggedOut)logout_message— server-supplied header/subtext/locale, when the server sent any (in practice, only on an account lock). The official client only rendersheader/subtextwhenlocalematches the consumer’s current locale, and the locale travels with the text so a consumer can apply the same rule.raw— the stanza that caused the logout, when one was received. Two different shapes reach this field, so dispatch onraw.tagrather than assuming one:"failure"for a server-side connect refusal (on_connectis thentrue), and"stream:error"for a<conflict>, a 516 device removal, or a 401 stream error while already connected.Nonefor a locally initiatedclient.logout()— there’s no stanza to report.
Breaking change:
LoggedOut gained logout_message and raw. An account lock (reason: ConnectFailureReason::AccountLocked) carries a server-issued appeal_token plus violation_reason and vt on the <failure> stanza — WA Web itself ignores these (its appeal flow is native-client only) so they aren’t parsed into typed fields, but they now survive on logout.raw for an embedder that wants to build its own appeal UI. Read them off raw.attrs.get("appeal_token"), etc.StreamError
Emitted: For unrecognized stream error codes (codes not matching 401, 409, 429, 503, 515, or 516), and — since #1263 — also for429 (rate-limited), even though 429 is itself a recognized, explicitly-handled code. WhatsApp Web’s own handler has no arm for it either (only 500..600 is special-cased there), so reporting 429 here is an embedder-facing choice rather than a fidelity fix.
Specific stream error codes have the following event behavior:
- 401 →
LoggedOut(session invalidated) - 409 →
StreamReplaced(another client connected) - 429 →
StreamError(rate limited; also reconnects with extended backoff) - 503 → No event emitted (reconnects with normal backoff)
- 515 → No event emitted (immediate reconnect, e.g., after pairing)
- 516 →
LoggedOut(device removed)
Pairing Events
PairingQrCode
Emitted: For each QR code in rotationBreaking 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 generatedtimeout 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.Breaking change:
PairingCode moved from inline fields on Event::PairingCode { code, timeout } to a dedicated sealed struct — Event::PairingCode(PairingCode).PairingCodeRefresh
Emitted: When the in-progress phone-number pairing code should be replaced. Covers two triggers (WA WebAlt/DeviceLinkingApi.js + Link/DevicePhoneNumberCodeScreen.react.js): the server asking for it (refreshAltLinkingCode / forceManualRefresh, only 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), and a non-refused companion_finish — accepted, or its own 30s wait going unanswered — whose pair-success then went unanswered for a minute (PairCodeUtils::primary_hello_pair_success_timeout()) — a primary that could not open the key bundle just goes quiet at that point, so silence is the only signal there is. A companion_finish the server actively refuses is a different case and does not fire this event — see PairingCodeError below.
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).The silent-
pair-success timeout trigger (formerly described as the “unanswered-companion_finish” trigger) is not new by itself. What changed is narrower, but worth registering a handler for: a companion_finish the server actively refuses used to go unanswered like any other stage-2 failure, so it also fell into this same one-minute timeout and eventually fired PairingCodeRefresh. It no longer does — a refusal now exits immediately as PairingCodeError instead (see below) and this event never fires for it. If your handler only registers on_pair_code_refresh (not on_pair_code_error) and relied on it eventually firing for every stage-2 failure, including a refusal, it will now miss that case — the retry will not happen unless you also handle PairingCodeError. A handler that only cares about a server-requested refresh, or that already registers both, needs no changes.PairingCodeError
Emitted: When a phone-number pair-code flow fails, so no linking will come of it. For a stage-1 failure,Client::pair_with_code dispatches this in addition to returning Err — the event is the only surface that reports it when pairing is driven by BotBuilder::with_pair_code, since that request runs in a detached task and its Err reaches no caller. For a stage-2 companion_finish refusal there is no Err to receive either way: pair_with_code’s call already returned Ok(code) once stage 1 succeeded, long before the notification that drives stage 2 arrives — this event is the only surface for that failure, for a direct caller and a with_pair_code consumer alike.
Two failures never reach this event, because for them a code may still be on its way and the event would say the opposite:
PairCodeError::CodeAlreadyOutstanding (an earlier code is still live — the consumer already has it from the PairingCode that minted it) and PairCodeError::Cancelled (the caller withdrew this request, and a replacement may already own the slot by the time it resolves). Both are consequences of something the caller did, so neither is news to them, and a direct caller still receives the Err either way. See Pair code failure events for PairCodeRejection’s variants and the full breakdown, and BotBuilder::on_pair_code_error to register a handler.New: this event now also fires for a refused
companion_finish (stage 2), reported the moment the server answers rather than after the one-minute pair-success silence timer. No shape change — PairingCodeError’s fields are the same — so a handler already matching on this event needs no code changes, only the awareness that it can now fire earlier and for a second reason. rejection follows companion_finish’s own narrower set of refusals; see PairCodeRejection.PairingQrCodesExhausted
Emitted: When the server’s<pair-device> refs are used up — there is no QR left to render until the connection is re-established. WA Web’s rotation timer (Handle/PairDevice.js) reports UNPAIRED_IDLE here and stops; it does not close the socket unconditionally, because a phone-number (pair-code) flow may still be riding the same connection.
disconnected: true reports intent, not a completed action: the client dispatches this event before awaiting disconnect(), so the socket may still be open at the moment a handler observes it. A synchronous EventHandler runs inline ahead of the disconnect; a Bot/on_event closure runs off a channel on its own task and can race it either way. No Event::Disconnected follows, though: disconnect() sets expected_disconnect, and Disconnected is scoped to disconnects the client did not itself intend, so waiting on it here hangs forever. disconnect() also disables auto-reconnect and, as of PR #1258, is final for this Client instance — it fires the same sticky shutdown signal connect() now checks, so a later connect() on the same instance returns ConnectError::Shutdown rather than eventually succeeding. Build a fresh Client against the same persistence_manager and call connect() on that one instead.This is a new event and a new
EventKind variant, added at the end of EventKind (after ServerAck) rather than next to Event::PairingQrCodesExhausted — new kinds always go at the end, since the discriminant is what a consumer persists or transmits and inserting in the middle would renumber every kind after it.PairSuccess
Emitted: When pairing completes successfullyPairError
Emitted: When pairing failsPairPasskeyRequest
Requires the
passkey feature (opt-in, off by default as of the next release after 0.7.0). Without it, PairPasskeyRequest, PairPasskeyConfirmation, and PairPasskeyError below are never emitted — a passkey_prologue_request notification instead reaches your handler as Event::Notification. See Feature flags and Authentication — Passkey linking.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 stagePairPasskeyError
Emitted: When a passkey link attempt failsQrScannedWithoutMultidevice
Emitted: When a QR code is scanned by a device that does not support multi-deviceClientOutdated
Emitted: When the server rejects the connection because the client version is too old (connect failure code 405)raw— the<failure reason="405">stanza the server sent
Breaking change:
ClientOutdated gained raw, carrying the full <failure> stanza instead of discarding it.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.hook_committed is true when a registered inbound durability hook already committed this batch before it was dispatched. It defaults to false via the builder, so existing .build() calls compile unchanged. It’s a signal for another consumer of the same event stream — not an instruction to skip anything on its own, since a hook that persists elsewhere would leave that consumer as the only materializer. An application-level store that also materializes this same event stream can use it to avoid double-processing a hook-fed batch.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().Breaking change:
ephemeral_expiration and comment_target moved from MessageInfo onto InboundMessage. Some code samples below may still show info.ephemeral_expiration / info.comment_target from before this change.Writing these fields into the shared MessageInfo needed an Arc::make_mut copy of the whole struct on every message in a disappearing chat. Moving them onto the event, which is already built fresh per dispatch, avoids that copy and lets info stay a cheap shared Arc.Read them by destructuring the field directly off InboundMessage instead of off info: for InboundMessage { comment_target, .. } in batch.iter().MessageContext carries both fields too. MessageContext::from_inbound — what Bot::on_message uses internally — copies them from the event, so a bot handler can read ctx.ephemeral_expiration / ctx.comment_target directly.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.Resend collapse. A sender whose network is bad can retry its own outbox, resending one message re-encrypted under a new sender-key iteration — the Signal ratchet decrypts it cleanly, since it isn’t byte-identical to the first delivery, so nothing at that layer catches it. Since #1352, such a resend collapses to a single
Event::Messages dispatch, keyed by chat, message id and sender (device dropped), for as long as the dispatched_messages cache window holds (default 5-minute TTL, 1,000 entries — see cache configuration reference). stats().messages_suppressed_duplicate counts what this collapses. The gate is in-memory only and does not survive a restart of your process, so a hook or consumer performing side effects still needs its own idempotency — see Event::Messages is at-least-once too. It also does not cover a message delivered as several msmsg parts under one stanza id (e.g. a bot streaming a multi-part reply): each part still dispatches, since collapsing there could drop a part the consumer never got.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.
StanzaMessageType:
The <message type="…"> envelope attribute, parsed before any <enc> is decrypted. None when the stanza carried no type attribute at all; an unrecognized value is preserved as Unknown rather than dropped.
StanzaMessageType says nothing about the decrypted content — it is the envelope’s own claim, and nothing verifies it against the Message that comes out of the ciphertext.mediatype attribute of an <enc> node — a hint about the payload the ciphertext carries, available before the decryption that would reveal it. MessageInfo::media_type aggregates this across the stanza’s <enc> nodes: the first one that declares a value wins (direct children first, then this device’s own <enc> under <participants>); a later disagreeing value is dropped. None when no <enc> carried the attribute.
It is the sender’s claim and nothing checks it against the decrypted
Message, so it is useful for routing and telemetry, not for deciding what a message is. MessageInfo::media_type is aggregated to one value per message — a fan-out stanza’s per-device <enc> copies all repeat the same attribute in practice, so the aggregation only fills the field when the direct children carry nothing.edit attribute on message stanzas.
PollType:
Read only when the envelope’s
r#type is StanzaMessageType::Poll, mirroring the official WhatsApp Web parser, which scopes the polltype attribute to poll envelopes. Closed on purpose — a polltype value outside this list parses as None rather than being preserved as an Unknown variant, since upstream treats it the same way.
server_timestamp_us, verified_level, verified_name_serial, peer_recipient_pn, plus target_id, target_sender, thread_message_id, thread_message_sender_jid, content_type, appdata, reporting_tag, reporting_token, and reporting_token_version on MsgMetaInfo 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:
MsgMetaInfo::deprecated_lid_session was removed. It was declared but never assigned by any parser, and no wire attribute maps to it — nothing to migrate to, callers should simply drop the field access.thread_message_id and thread_message_sender_jid were already declared before this change but always empty; they are now actually populated, sourced from the poll envelope’s thread_msg_id / thread_msg_sender_jid attributes. poll_type is new, sourced from <meta polltype="…"> and scoped to StanzaMessageType::Poll envelopes only.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.Receipt
Emitted: For delivery/read/played receiptsReceiptType 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.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. ForViewOnce/Hosted/Bot, the phone never shares that content with a companion device, so the client acks the stanza directly instead of requesting it. Only theUnknowncase goes through PDO recovery
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.
The client deduplicates dispatch per
(chat, id, sender), not (chat, id) alone. A stanza id comes from the sending client. That id is only unique within a (chat, sender) pair. So two different senders can reuse the same id in one chat. The client treats those as two distinct messages and dispatches an event for each, instead of folding the second into the first.The client keys the sender on its wire-form JID. It deliberately leaves that JID unresolved to a LID/PN identity, because resolving it would let the key move as the client learns that mapping at runtime. This choice has one accepted cost: if a redelivery’s sender switches PN/LID namespace mid-flight, the client treats it as a new message and dispatches a second placeholder. The client accepts that cost because a duplicate placeholder is visible and recoverable, while a silently dropped message is not.The dedup cache holds each key for up to 5 minutes (a TTL measured from first dispatch), or until it evicts the key to stay under its 1,000-entry capacity — whichever comes first. Once a key leaves the cache, a later redelivery of that same message dispatches the event again.Notification
Emitted: For raw notification stanzas that are not handled by a more specific event typeOwnedNodeRef 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), and this event otherwise fires only for unhandled notification types. The one exception is groups_dirty: the library both acts on it and forwards it here.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.
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.Presence Events
ChatPresence
Emitted: For typing indicators and recording statesPresence
Emitted: For online/offline status and last seenUser update events
PictureUpdate
Emitted: When a user changes their profile picturejid- 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).Nonefor personal picture updates.removed- Whether the picture was removed (true) or set/updated (false)picture_id- The server-assigned picture ID.Nonefor deletions.
UserAboutUpdate
Emitted: When a user changes their status/aboutRetiredPushNameUpdate
Breaking change (PR #1310): this event is retired. Nothing constructs or dispatches it, and nothing ever will. The payload promised an
old_push_name/new_push_name comparison, but this library holds no contact store to source the previous name from. EventKind::PushNameUpdate is renamed EventKind::RetiredPushNameUpdate the same way.Both the EventKind and Event variants keep their slot instead of being deleted. EventKind’s discriminant is an EventInterest bit index a consumer may persist. Event derives Serialize into index-keyed formats (bincode, postcard) that key a variant by position. Removing either would renumber every variant after it and change how already-stored data decodes. The payload struct is emptied instead, so the slot is held without claiming anything.A handler matching Event::PushNameUpdate(..) should drop the arm — it can never fire — or rename it to Event::RetiredPushNameUpdate(..).To track a contact’s push name, read MessageInfo::push_name on the message events you already handle. It comes from the stanza’s notify attribute, and a stanza without that attribute (e.g. newsletter traffic) leaves it as an empty string. Treat an empty push_name as “not present,” not as a rename to "", and diff only non-empty values against your own contact store if you need change detection.SelfPushNameUpdated
Emitted: When your own push name is updatedGroup Events
GroupUpdate
Emitted: For each action in a group notification (subject changes, participant changes, settings updates, etc.). A single notification may produce multipleGroupUpdate events.
group_jid- The group this update applies toparticipant- The admin/user who triggered the changeparticipant_pn- Phone number JID of the participant (for LID-addressed groups)is_lid_addressing_mode- Whether the group uses LID addressing modeaction- The specific group notification action (subject change, participant add/remove/promote/demote, description change, etc.). It is boxed, like theactionfield of every other sync-action event payload (ContactUpdate,PinUpdate,MuteUpdate, …). It was the largest variant ofEvent, and every dispatched event pays for the size of the largest one.
GroupNotificationAction
Theaction 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.
Subject::subject_owner follows the group’s addressing mode, so in a LID-addressed group it is a @lid JID; the renamer’s phone-number JID arrives separately as subject_owner_pn (from s_o_pn), with subject_owner_username (from s_o_username) when present — the same split GroupUpdate::participant / participant_pn uses.
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 parentGroupUpdate::participantfield.CreatedMembershipRequests— admin-side notification: new join requests appeared. Therequestsfield contains the requesting users (asVec<GroupParticipantInfo>).RevokedMembershipRequests— emitted when membership requests are rejected by an admin or cancelled by the requester. Theparticipantsfield contains the affected JIDs.
MembershipApprovalRequest and CreatedMembershipRequests include an optional parent_group_jid field for community-linked joins.
Example: handling specific group actions:
PR #1402 changed
GroupUpdate::action to Box<GroupNotificationAction>. At 288 bytes it made GroupUpdate the largest variant of Event, and Event’s size is what every dispatched event’s Arc allocation pays for, group update or not. Field access (update.action.foo) is unchanged. A match on update.action that names a GroupNotificationAction variant now needs match &*update.action { ... }, as in the example below.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.Stale group metadata (groups_dirty)
The server can also send <notification type="w:gp2"> wrapping a <groups_dirty> child, naming one or more groups whose cached metadata is now stale (for example, after a bulk membership change made elsewhere). This is not an ordinary group notification — its from is the group server, not a group JID — so it produces no GroupUpdate event. Instead, the library evicts the cached snapshot for each named group and still dispatches the raw stanza as Event::Notification — an exception to that event’s usual “unhandled types only” rule, since the library both acts on groups_dirty (the cache eviction) and forwards it.
The next query_info (or a send) for an affected group transparently re-fetches its metadata from the server, since query_info’s default Freshness::CachePreferred behavior only serves the cache when it is populated. You do not need to call query_info_with_freshness(&jid, Freshness::Refresh) yourself in response to this notification — see Cache group information.
Contact notification events
Most events in this section are emitted from<notification type="contacts"> stanzas sent by the server. Two are not: ContactUpdate and ContactRemoved come from app-state sync mutations instead, grouped here with their notification-based siblings because they are all contact-related — each one’s own description below states its actual source.
ContactUpdated
Emitted: When a contact’s profile changes (server notification)<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<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<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)jid- The contact whose information changedtimestamp- When the change occurredaction- The contact action from app-state sync, containing fields likefull_nameandfirst_namefrom_full_sync- Whether this came from a full app-state sync (initial load) or an incremental update
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.ContactRemoved
Emitted: When a saved contact is deleted via app-state sync on a linked devicejid- The contact that was removedtimestamp- When the removal occurredfrom_full_sync- Whether this came from a full app-state sync (initial load) or an incremental update
ContactRemoved is distinct from ContactUpdate, and the event itself carries no action payload. The underlying wire mutation is different: it is a syncd Remove with an all-default ContactAction value, since WhatsApp Web builds that value before choosing the operation. WhatsApp Web ignores the value on the Remove branch and simply drops the contact from the address book — but if you call remove_app_state_action directly for an action like this one, you still need to pass a value. See Chat actions — Save and remove contacts for the outbound API (remove_contact) that emits this event on other devices.Chat state events
PinUpdate
Emitted: When a chat is pinned/unpinnedMuteUpdate
Emitted: When a chat is muted/unmutedArchiveUpdate
Emitted: When a chat is archived/unarchivedStarUpdate
Emitted: When a message is starred or unstarredchat_jid- The chat containing the starred messageparticipant_jid- The sender of the message (only for group messages from others;Nonefor self-authored or 1-on-1 messages)message_id- The ID of the starred/unstarred messagefrom_me- Whether the starred message was sent by you
MarkChatAsReadUpdate
Emitted: When a chat is marked as read or unread across linked devicesDeleteChatUpdate
Emitted: When a chat is deleted across linked devicesjid- The JID of the deleted chatdelete_media- Whether media files were also deletedaction- The underlying protobuf action containing the optionalmessage_range
ClearChatUpdate
Emitted: When a chat’s messages are cleared (but the chat is kept) on a linked devicejid- The chat that was cleareddelete_starred- Whether starred messages were also removeddelete_media- Whether downloaded media was also removedfrom_full_sync-truewhile replaying the initial app state full sync
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 devicejid- The entity whose status updates were (un)mutedmuted-truewhen status was muted,falsewhen unmutedfrom_full_sync-truewhile replaying the initial app state full sync
set_user_status_mute for the outbound API.
DeleteMessageForMeUpdate
Emitted: When a message is deleted locally (not for everyone) across linked deviceschat_jid- The chat containing the deleted messageparticipant_jid- The sender of the message (only for group messages from others;Nonefor self-authored or 1-on-1 messages)message_id- The ID of the deleted messagefrom_me- Whether the deleted message was sent by youaction- The underlying protobuf action containingdelete_mediaand optionalmessage_timestamp
LabelEditUpdate
Emitted: When a chat label is created, renamed, recolored, or deleted on a linked devicelabel_id— Stable label identifieraction.name— New display name (Nonewhen only the deleted flag changes)action.color— WhatsApp color index for the swatchaction.deleted—Some(true)when the label was removedfrom_full_sync—truewhile replaying the initial app state full sync
LabelAssociationUpdate
Emitted: When a label is added to or removed from a chat on a linked devicelabel_id— Identifier of the label being attached or detachedchat_jid— Chat whose label set changedaction.labeled—Some(true)when the label was added,Some(false)when removedfrom_full_sync—truewhile replaying the initial app state full sync
MessageLabelAssociationUpdate
Emitted: When a label is added to or removed from a single message on a linked devicelabel_id— Identifier of the label being attached or detachedchat_jid— Chat containing the labeled messagemessage_id— Message whose label set changedtimestamp— When the association change occurredaction.labeled—Some(true)when the label was added,Some(false)when removedfrom_full_sync—truewhile replaying the initial app state full sync
Distinct from
LabelAssociationUpdate above — that event is a whole-chat association, this one is scoped to a single message. See Labels — Associate a label with a message for the outbound API.QuickReplyUpdate
Emitted: When a quick reply is created, edited, or deleted on a linked deviceid— Stable quick reply identifiertimestamp— When the change occurredaction.shortcut— The/-typed trigger textaction.message— The expanded message textaction.deleted—Some(true)when the quick reply was deletedfrom_full_sync—truewhile replaying the initial app state full sync
Deletion is the same mutation with
action.deleted == Some(true), not a syncd Remove — check that flag rather than assume the event always describes a live quick reply. See Quick Replies for the outbound API that emits this event on other devices.History sync events
HistorySync
Emitted: For chat history synchronizationwa::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.
- Metadata without decoding —
sync_type(),chunk_order(),progress(), andpeer_data_request_session_id()are extracted during the streaming phase and available immediately - Parse-once semantics —
get()decodes the full proto on first call and caches the result viaOnceLock. WithArc<Event>dispatch, all handlers share the sameLazyHistorySyncinstance. The compressed payload is never consumed —get()can be called multiple times and other accessors still work afterward - Cheap clone —
Cloneis a refcount bump on the compressed buffer; no decode cache is carried over, so each cloned instance re-inflates independently on demand - Decompress on demand —
decompress()re-inflates into a fresh buffer on every call (no caching). Useget()for repeated full-proto access, orstream()for memory-bounded incremental access - Streaming —
stream()yields one conversation at a time viaHistorySyncStream, 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 correlation —
peer_data_request_session_id()is set only on syncs the server pushes in response tofetchMessageHistory/requestPlaceholderResend. Server-initiated syncs (initial bootstrap, recent, push-name) returnNone. Use it to route the blob back to the request that triggered it.
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.
OfflineSyncPreview
Emitted: Preview of pending offline sync data when reconnectingtotal 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 reconnectionOfflineSyncInterrupted
Emitted: When an offline backlog drain ends because its connection was lost, before the<ib><offline/> end marker arrived (added in PR #1380)
total is what the preceding OfflineSyncPreview announced for this drain; delivered is how many offline stanzas were processed before the connection ended. The server owns both numbers, so treat the pair as a progress report rather than an invariant — delivered is never larger than total in practice, but that isn’t guaranteed.
This is the counterpart to OfflineSyncCompleted, not a variant of it: the drain did not finish, the client is not caught up, and the rest of the backlog is still queued server-side. delivered counts stanzas processed, not stanzas guaranteed never to come back — what the next connection’s OfflineSyncPreview actually redelivers follows the pre-existing commit-batch ack contract, unchanged by this event: see Inbound Durability → Batching for exactly which batch a mid-drain disconnect does and doesn’t redeliver. A consumer that gates “caught up” UI or startup work on OfflineSyncCompleted should treat this event as “not caught up yet, wait for the next preview” rather than as completion.
Exactly one of OfflineSyncCompleted / OfflineSyncInterrupted is emitted per resume — never both, and never neither.
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 stops sending offline stanzas before the end marker arrives, but the connection itself stays up, an inactivity watchdog completes the drain — mirroring WhatsApp Web’s own stall timer — and
OfflineSyncCompleted still fires, with the count of items processed so far. The watchdog re-arms on every stanza, so it fires somewhere between 60 and 120 seconds after the last one rather than at a fixed 60 seconds.If instead the connection ends before the drain finishes, the resume is never left silent: it is reported as OfflineSyncInterrupted rather than OfflineSyncCompleted (added in PR #1380). This event only reports that the resume ended abnormally — what the next connection’s OfflineSyncPreview actually redelivers still follows the pre-existing commit-batch ack contract; see Inbound Durability → Batching.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.
dirty_type- The stale domain, mirroringwacore::iq::dirty::DirtyType:AccountSync,Groups,SyncdAppState,NewsletterMetadata, orOther(String)for a wire value the client doesn’t otherwise recognize.timestamp-Option<u64>,Noneif the<dirty>stanza omitted thetimestampattribute.
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.AppStateSyncFailed
Emitted: When a batched app-state sync (fetchingsyncd collections like critical_block, regular_high) finishes without leaving every requested collection synced.
fatal- Collections the server refused outright (the syncd IQ came back with an IQ-level error code such as400/404, not an HTTP status). Terminal for the current connection — repeating the request on it gets the same answer — but not permanent: a fresh connection gets to try again.retryable- Collections that didn’t sync but a later attempt can. The client retries these itself, backing off from 1 second and doubling (a theoretical one-hour cap that its round limit never lets it reach), for up toAPP_STATE_RETRY_MAX_ROUNDS(8) rounds — about four minutes — before giving up and reporting one moreAppStateSyncFailedfor whatever is still unsynced. A handler doesn’t need to trigger a retry itself.skipped- Collections another in-flight sync or patch send already held, so this particular sync did nothing for them. Not an error, and not itself re-queued into the retry schedule above — the equivalent work is happening elsewhere and will report its own outcome.connected- Whether the client was already connected (or went on to dispatchEvent::Connected) when this event fired, rather than an indicator of which bucket is non-empty. As of PR #1291, during the initial bootstrap it’struefor every outcome that reaches an answer — synced-with-gaps, fatal, retryable, or skipped, including a batch that failed transport-side before producing any per-collection buckets. It’sfalsewhen the client was paused, asked to disconnect, or the server rejected the session (429/503), between the sync finishing and the announcement:Connectedis withheld but this event still fires, and the leftover collections still go to the background sync either way. If the connection’s generation was retired instead — a replacement connection already took over — neither event fires for this one at all; the replacement reports for itself once its own sync finishes. Outside the bootstrap, background syncs always run on an already-connected client, soconnectedistruethere regardless of which buckets are populated.
Collections are identified by their wire name (
critical_block, critical_unblock_low, regular_high, regular_low, regular) rather than an enum, so this payload stays stable if the set of collections changes.fatal is the bucket a consumer usually has to act on. WhatsApp Web treats a fatal critical_block failure as grounds to notify the primary device and log out; this library does not end a session on its own. Instead, during the initial connection bootstrap, a fatal critical_block outcome makes the client stop waiting on it, dispatch Event::Connected anyway, and then dispatch this event with connected: true — the account is usable but missing whatever that collection carries. A fatal outcome does not retry on its own connection (WhatsApp Web’s COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY path is explicitly gated off for critical_block, and this client implements no alternative for it); the collection gets another chance only on the account’s next fresh connection. critical_block includes the setting_pushName mutation, so unless the push name was already known some other way (e.g. Bot::with_push_name, or a name learned earlier via history sync or a prior session), presence stays unavailable until that next connection syncs it. See Critical app-state sync (pairing bootstrap) for the full bootstrap flow.As of PR #1291, a retryable or skipped outcome during the bootstrap is no longer silent either: the client dispatches Connected and this event (connected: true) the same way it does for fatal, then hands the leftovers to the background sync that follows. Both buckets get one more attempt as part of that sync’s own batched request; whatever is still retryable after that enters the bounded backoff described above, and a skipped collection is handled the same way the skipped field description above does outside the bootstrap too. A handler that only reacted to fatal before should now also watch retryable/skipped if it needs to know the account is running in a degraded state, since both can now arrive alongside a Connected that announced a session missing its push name or blocklist.A background sync outside the bootstrap path (the ib dirty-resync handler, group server_sync notifications, periodic app-state resync) reports through this same event when it leaves collections unsynced, always with connected: true since the client was already connected before that sync ran. Only its retryable collections back off and retry automatically; any fatal collection it reports is subject to the same next-connection-only recovery as the bootstrap path.ClientExpirationChanged
Emitted: When the server sends (or withdraws) an<ib><client_expiration t="..."> marker — the date it expects to stop accepting the client build currently running. Fires only when the stored expires_at resolves to a different value than what was already held; a t the server resends that resolves to the same stored deadline is silent, so a handler wired to alert on this event doesn’t fire on every reconnect just because the server repeats itself. That’s not the same as “the same t is always silent” — see the note below.
expires_at- Unix seconds after which the server expects to stop accepting this build.Nonewhen the deadline was withdrawn (withdrawn: true).version- The(primary, secondary, tertiary)build the deadline was issued against — a deadline issued for one build says nothing about the next, so this is how a consumer checks the announcement still applies to the running binary.withdrawn-truewhen the server retracted a deadline it had previously set (the<client_expiration>child arrived with notattribute).
This is notice, not an instruction. The client keeps connecting until the server actually refuses it — the stanza is about the build, not the current connection — so whether to ship a newer version or alert an operator is the consumer’s call; this library never disconnects on its own because of it.The recorded deadline is never sooner than three days out, even when the server’s own answer is more abrupt (
t at or before now) — a server that says “now” still has to leave a window in which the build can be replaced. The raw t is only acted on when it’s sooner than the expires_at already stored — a t at or after that is treated as a stale retransmit or a host that hasn’t caught up, and is ignored rather than granted as an extension. That gate gets re-evaluated against “now” each time, though, so it does not mean the stored deadline can only move earlier over time: a server that keeps resending the same already-elapsed t keeps clearing the gate (it stays “sooner than stored”) and each acceptance re-floors three days out from the new “now” — pushing the stored deadline later each time even though t itself never changed. A dated deadline in the future, by contrast, settles once stored: restating it no longer clears the gate, so repeats change nothing and dispatch nothing. See ServerClientExpiration for the persisted record and the full decision rule.Device Events
DeviceListUpdate
Emitted: When a user’s device list changes (a companion device is added, removed, or updated)
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.
user— The phone number JID of the user whose identity changedlid_user— The user’s LID JID, if provided in the notificationimplicit—falsefor server-pushed<identity/>notifications (full cleanup performed);truefor locally-detected changes during decrypt (lighter cleanup, see below)
WAWebHandleIdentityChange flow. When the server sends an <identity/> notification inside a type="encrypt" stanza, the client:
- Clears the device record for the user (deletes Signal sessions for all non-primary devices). 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. - Deletes the primary device session and identity key so a fresh session can be established (matching WhatsApp Web’s
deleteRemoteInfo) - Deletes the
status@broadcastsender key for forward secrecy on the next status send (matching WhatsApp Web’smarkStatusSenderKeyRotate) - Invalidates the device registry cache so the next send triggers a fresh device list sync
- Dispatches this event so your application can show a “security code changed” notice
- Spawns a background
ensure_e2e_sessionstask to proactively re-establish the session (self-defers when the client is offline)
UntrustedIdentity error during decryption (indicating the sender reinstalled WhatsApp), the client:
- Clears the old identity key and retries decryption with the new identity, preserving the old session for in-flight messages
- Handles
InvalidPreKeyIderrors in the retry path by sending a retry receipt so the sender can establish a new session - Re-issues TC tokens for the sender in the background (matching WhatsApp Web’s
sendTcTokenWhenDeviceIdentityChangebehavior) so the contact retains a valid privacy token
wait_for_offline_delivery_end when the client is offline.
Implicit (locally-detected) identity changes
The client also firesIdentityChange 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 saveIdentity → handleNewIdentity 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
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.
BusinessStatusUpdate
Emitted: When a business account status changesNewsletter Events
NewsletterLiveUpdate
Emitted: When reaction counts change or messages are updated on a newsletter you’re subscribed to (viasubscribe_live_updates).
newsletter_jid— The newsletter channel this update is formessages— List of messages with updated reaction countsserver_id— Server-assigned message IDreactions— Current reaction counts (emoji code and 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.
group_jid: Some(...) on CallAction::Offer (below) is what distinguishes a group call offer from a 1:1 one; group on IncomingCall carries its roster snapshot. See Group calls and Call links in the VoIP guide for accepting one and attaching media.PR #1402 changed the
Event::IncomingCall variant to hold Box<IncomingCall> instead of IncomingCall by value. At 432 bytes it was one of the two variants sizing every dispatched Event, and the per-dispatch Arc<Event> allocation is now 200 bytes smaller (size_of::<Event>() dropped from 528 to 328). Field reads and &call coercions, as in the examples on this page, are unchanged. A pattern that destructures IncomingCall { .. } directly inside the variant must bind the box first or deref it with &*.PR #1355 added
video_orientation. IncomingCall only fires for a stanza the peer sent, so this field is always their rotation, never your own. As a callee, it’s the caller’s rotation, carried on the Offer. As a caller, it’s the callee’s rotation, carried on the Accept you receive back. A video-from-start party announces its camera rotation exactly once, in that stanza, and sends no mid-call <video> of its own until the camera actually turns. Before this field existed, that initial rotation was dropped: a call starting with the peer’s phone already sideways rendered upright until their next rotation change. See Video I/O in the VoIP guide for how this compares to VideoFrame::orientation.GroupCallUpdate is the same authoritative roster/relay type CallHandle::group_state() exposes once you’ve joined (via GroupCallState::snapshot() — see Reading group state):
action field is a tagged enum that mirrors the inner stanza child (<offer>, <offer_notice>, <preaccept>, <accept>, <reject>, <terminate>):
- The router automatically acks every
<call>stanza. ForOfferit additionally sends an<receipt><offer/></receipt>so the caller’s UI advances past “ringing”. OfferNoticeis 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()andaction.call_creator()to access the common identifiers without matching every variant.
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 — or when your own account’s default changes on another linked device, carried instead as a <disappearing_mode duration="…" t="…"> child of a <notification type="account_sync"> stanza. In the account_sync case from is your own account.
from- Whose disappearing messages setting changed: a contact for thedisappearing_modenotification, or your own account for theaccount_syncformduration- 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 from. This prevents out-of-order updates from overwriting newer settings.The
account_sync form of this notification has a second shape the library does not turn into this event: a <disappearing_mode action="modify"> child (no duration/t) means the server wants you to re-query the current default rather than stating it inline. Resolving that requires a disappearing_mode get request, which this library does not yet implement, so that shape is logged and no event fires.Privacy events
DisableLinkPreviewsUpdate
Emitted: When the account-wide “disable link previews” setting changes via app-state sync on a linked device (setting_disableLinkPreviews)
previews_disabled-truewhen link previews are now disabled. This event only fires when the wire carried the flag. WhatsApp Web treats a mutation with an absent flag as malformed, so no event fires for that case, rather than one carrying a defaultfalse.timestamp- When the change occurredaction- The underlying app-state actionfrom_full_sync- Whether this came from a full app-state sync (initial load) or an incremental update
DisappearingModeChanged above is a server-push notification — usually about another contact’s setting (type="disappearing_mode"), though its account_sync shape carries your own account’s setting instead. DisableLinkPreviewsUpdate is different: it is app-state sync about your own account’s setting, changed from one of your own linked devices. See Privacy — App-state settings for the outbound API (set_link_previews_disabled) that emits this event on other devices.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).
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.Decrypted payload events
DecryptedPayload
Emitted: One decrypted <enc> payload, after unpadding and before it is decoded into a wa::Message. To receive this event, hold a lease from client.acquire_decrypted_payload_forwarding() and include EventKind::DecryptedPayload in your handler’s interest(). While no lease is held, nothing is emitted and nothing is cloned.
info— Which message this came from.enc_index— Which<enc>of the stanza produced these bytes, counting from zero in the order the client enumerates them: the stanza’s direct<enc>children first, then the ones under<participants><to>addressed to this device (the fan-out shape, where one stanza carries a copy per device and only yours is yours to decrypt). This is a position in that concatenation, not a child index or a position withinenc_type’s bucket — an<enc>that produces no payload still consumes its slot, so a consumer correlating a forwarded payload back to its node has to walk the stanza the same way.enc_type— Thetypeattribute the<enc>carried:msg,pkmsg,skmsg, …state— Thestateattribute the<enc>carried, verbatim, orNonewhen it carried none. The server’s own annotation of the session this copy was encrypted under. This build does not model the possible values or act on them; they are handed over as text so a consumer can.session_type— Thesession_typeattribute the<enc>carried, verbatim, orNonewhen it carried none. Unmodelled and unacted-on, likestate.payload— The plaintext, unpadded, exactly as decoding receives it. ABytes, so forwarding it is a refcount bump, not a copy.
handle_decrypted_plaintext turns bytes into wa::Message, and when that decode fails — a field a build predates, a message type it doesn’t model — the bytes disappear. Nothing can ask for them again, because opening them already consumed state that won’t recur: the Signal ratchet advances, so the same ciphertext will never decrypt a second time. DecryptedPayload fires whether or not the decode that follows succeeds, which is the point: the failing case is the one with nothing else to look at. It also enables recording traffic for faithful replay (re-encoding a decoded Message does not reproduce the original bytes) and decoding with a newer protobuf than the running build carries.
It’s also emitted on the bot-message-secret path (msg_secret.rs), ahead of the same decode, where the secret a message_secret payload was opened with is single-use rather than ratchet-advanced — the same “cannot be asked for again” property, for a different reason.
Example:
payload is skipped during serialization (#[serde(skip)]) — like RawNode, Serialize on an event is for diagnostics, and no text format carries raw bytes without an encoding choice this type has no business making.acquire_decrypted_payload_forwarding for the lease API.
Sent frame events
SentFrame
Emitted: One marshaled stanza, exactly as it was handed to the noise frame encryption, after the transport accepts the write. To receive this event, hold a lease from client.acquire_sent_frame_forwarding() and include EventKind::SentFrame in your handler’s interest(). While no lease is held, nothing is emitted and nothing is cloned.
plaintext— The marshaled stanza, keeping the leading format byte the binary protocol writes, so decoding it iswacore_binary::marshal::unmarshal_packed_ref(&plaintext). This is the plaintext handed to noise encryption, not a transport frame — the length prefix and the AEAD tag are added after it, and onlySessionStats(see WebSocket & Noise Protocol Handling) accounts for those. ABytes, so forwarding it is a refcount bump, not a copy.
RawNode and a library extension with no WhatsApp Web equivalent. Before it existed, the send side had no observer at all: wait_for_sent_node is a filtered one-shot waiter for a single expected stanza, and it only ever sees stanzas that pass through the marshal-and-send path, so the paths that hand pre-marshaled bytes straight to the socket — acks, delivery receipts, direct-encoded IQs — were invisible even to that. SentFrame is emitted from the noise sender task, the single point every send path crosses (including the paths above and the coalesced-burst writes the ack and receipt workers use), so it covers all of them from one place. It exists for recording a session for replay, asserting the wire form in an integration test, and reading back a stanza the server rejected without rebuilding with logging on.
Handshake frames are pre-noise and are not covered, and neither are VoIP relay sockets, which — like SessionStats — receive no observers at all.
Example:
plaintext is skipped during serialization (#[serde(skip)]) — like RawNode and DecryptedPayload, Serialize on an event is for diagnostics, and no text format carries raw bytes without an encoding choice this type has no business making.acquire_sent_frame_forwarding for the lease API, and WebSocket & Noise Protocol Handling for how SendObservers wires it into the noise sender.
Enc decrypt failure events
EncDecryptFailed
Emitted: One <enc> that did not produce a usable message, and why — the failing half of what DecryptedPayload reports for the succeeding half, at the same granularity (per <enc>, not per message) and under the same numbering (enc_index comes from the same enumeration as DecryptedPayload::enc_index, so the two events index one stanza and not two). To receive this event, hold a lease from client.acquire_enc_decrypt_failed_forwarding() and include EventKind::EncDecryptFailed in your handler’s interest(). While no lease is held, nothing is emitted and nothing is built.
info— Which message this<enc>belongs to.enc_index— Which<enc>of the stanza this was, counting from zero in the order the client enumerates them — the stanza’s direct<enc>children first, then the ones under<participants><to>addressed to this device. Not a child index.enc_type— Thetypeattribute the<enc>carried:msg,pkmsg,skmsg, …Noneonly when the node carried notypeattribute at all — the one thingMalformedNodecan mean here that a present type does not.reason— Where the client stopped. SeeEncDecryptFailureReasonbelow.
<enc> per device), driving a retry or resync policy off the specific reason, and measuring session health per peer rather than per message.
EncDecryptFailureReason
Why one<enc> produced no plaintext. This is the client’s own classification of where it stopped, not something the server sends and not a statement about the sender’s copy. It is #[non_exhaustive] — a match needs a _ arm, since new branches append new variants as the receive path is refined.
EncDecryptFailureReason::decryption_was_attempted(self) -> bool reports whether the client entered its decryption path at all — false for MalformedNode, UnsupportedEncType, and NotAttempted; true for every other variant, spanning everything from an envelope that wouldn’t parse to a MAC that wouldn’t verify.
What it does not say
- Not a display signal. Whether to show the user a placeholder is
UndecryptableMessage, which is per message, deduplicated by(chat, id, sender), and carries the server’sdecrypt-failhint.EncDecryptFailedis per<enc>, is not deduplicated, and answers a different question. - Not a loss report. Most reasons are recoverable — the client may already have asked the sender to resend — and this event says nothing about whether a retry went out or succeeded later.
- Repeats. A redelivered stanza that fails again emits it again, once per
<enc>per delivery. Correlate on(info.source.chat, info.source.sender, info.id, enc_index)if you want at-most-once per<enc>—info.idalone is not globally unique, only within a(chat, sender)pair (see Idempotency Requirement). - A duplicate is not a failure. An
<enc>the server redelivered that this device already processed emits neither this event norDecryptedPayload— its plaintext was reported the first time round. A stanza whose only duplicate<enc>sits beside one that genuinely fails still reports that other<enc>asNotAttempted, on every delivery, since no delivery ever produced its plaintext. - Order is
enc_index, not arrival. The client decrypts a stanza’s<enc>nodes in per-kind passes (session, then group, then bot), so neither these events norDecryptedPayloads arrive in stanza order, and a failure for a later<enc>can precede a success for an earlier one.
acquire_enc_decrypt_failed_forwarding for the lease API.
Event handler patterns
Bot builder pattern
Delivery order and backpressure
Closures registered viaon_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 orderedmessages.upsertcontract of WA Web (preserveOrder: true), whatsmeow, and Baileys. When the mailbox is full the event is dropped — counted inStatsSnapshot::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.
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.
- 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
mpscchannels
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 implementEventHandler directly:
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.
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
WithArc<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
Related Sections
Architecture
Understand the event bus system
Authentication
Learn about pairing events
Sending messages
Sending and receiving messages
Client API
Complete client API reference