> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Events

> Event system, event handlers, and Event enum types in whatsapp-rust

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

```rust theme={null}
#[derive(Default, Clone)]
pub struct CoreEventBus {
    handlers: Arc<RwLock<Vec<Arc<dyn EventHandler>>>>,
}

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

    pub fn has_handlers(&self) -> bool {
        !self.handlers.read().expect("...").is_empty()
    }
}
```

**Features:**

* Thread-safe event dispatching via `Arc<Event>` — each event is wrapped once and shared across all handlers, eliminating deep clones
* Multiple handlers supported
* Clone-cheap with `Arc`

### EventHandler Trait

```rust theme={null}
pub trait EventHandler: Send + Sync {
    fn handle_event(&self, event: Arc<Event>);

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

Handlers receive `Arc<Event>` — a shared reference-counted pointer to the event. Since `Arc<Event>` implements `Deref<Target = Event>`, you can pattern-match on it directly.

**Implementation:**

```rust theme={null}
use wacore::types::events::{Event, InboundMessage};
use std::sync::Arc;

struct MyHandler;

impl EventHandler for MyHandler {
    fn handle_event(&self, event: Arc<Event>) {
        match &*event {
            Event::Messages(batch) => {
                for InboundMessage { message: msg, info, .. } in batch.iter() {
                    println!("Message from {}: {:?}", info.source.sender, msg);
                }
            }
            _ => {}
        }
    }
}

client.register_handler(Arc::new(MyHandler));
```

### Typed event interest (skip boxing unwanted events)

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

```rust theme={null}
use wacore::types::events::{EventInterest, EventKind};

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

    fn interest(&self) -> EventInterest {
        // Only Messages and Connected events reach this handler.
        EventInterest::of(&[EventKind::Messages, EventKind::Connected])
    }
}
```

* **`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.

<Note>
  `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.
</Note>

With the [`Bot`](/api/bot) builder, the same narrowing is available via `on_event_for`:

```rust theme={null}
bot.on_event_for(&[EventKind::Messages], |event, client| async move {
    // only Messages events
});
```

`on_event` (without kinds) keeps subscribing to everything.

## Event Enum

**Location:** `wacore/src/types/events.rs`

```rust theme={null}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub enum Event {
    // Connection
    Connected(Connected),
    Disconnected(Disconnected),
    StreamReplaced(StreamReplaced),
    StreamError(StreamError),
    ConnectFailure(ConnectFailure),
    TemporaryBan(TemporaryBan),

    // Pairing
    PairingQrCode(PairingQrCode),
    PairingCode(PairingCode),
    PairingCodeRefresh(PairingCodeRefresh),
    PairSuccess(PairSuccess),
    PairError(PairError),
    QrScannedWithoutMultidevice(QrScannedWithoutMultidevice),
    ClientOutdated(ClientOutdated),
    LoggedOut(LoggedOut),

    // Messages
    Messages(MessageBatch),
    Receipt(Receipt),
    UndecryptableMessage(UndecryptableMessage),
    Notification(Arc<OwnedNodeRef>),
    ServerAck(ServerAck),

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

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

    // Group Updates
    GroupUpdate(GroupUpdate),

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

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

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

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

    // Newsletter
    NewsletterLiveUpdate(NewsletterLiveUpdate),

    // Calls
    IncomingCall(IncomingCall),

    // Notification Updates
    DisappearingModeChanged(DisappearingModeChanged),

    // Raw stanza (opt-in)
    RawNode(Arc<OwnedNodeRef>),

    // Passkey linking (SHORTCAKE_PASSKEY)
    PairPasskeyRequest(PairPasskeyRequest),
    PairPasskeyConfirmation(PairPasskeyConfirmation),
    PairPasskeyError(PairPasskeyError),
}
```

<Note>
  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.
</Note>

<Note>
  **Payload stability:** every event payload struct is sealed with `#[non_exhaustive]` plus a [`bon`](https://docs.rs/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`).
</Note>

## Connection Events

### Connected

**Emitted:** After successful connection and authentication

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct Connected {}

Event::Connected(Connected::builder().build())
```

**Usage:**

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

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct Disconnected {
    pub reason: DisconnectReason,
}

Event::Disconnected(Disconnected::builder().reason(reason).build())
```

**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`](/api/transport#disconnected) for the variants.

**Behavior:** Client automatically attempts reconnection

<Note>
  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(_)`.
</Note>

### ConnectFailure

**Emitted:** When connection fails with a specific reason

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ConnectFailure {
    pub reason: ConnectFailureReason,
    /// The server's `message` attribute on the `<failure>` stanza, when present.
    pub message: Option<String>,
    pub raw: Option<Node>,
}

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

<Note>
  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`.
</Note>

**Helper methods:**

```rust theme={null}
if reason.is_logged_out() {
    // Clear session and re-pair
}

if reason.should_reconnect() {
    // Retry connection
}
```

<Note>
  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.
</Note>

### TemporaryBan

**Emitted:** When account is temporarily banned

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct TemporaryBan {
    pub code: TempBanReason,
    pub expire: chrono::Duration,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum TempBanReason {
    SentToTooManyPeople,        // 101
    BlockedByUsers,             // 102
    CreatedTooManyGroups,       // 103
    SentTooManySameMessage,     // 104
    BroadcastList,              // 106
    Unknown(i32),
}
```

**Usage:**

```rust theme={null}
Event::TemporaryBan(ban) => {
    eprintln!("Banned: {} (expires in {:?})", ban.code, ban.expire);
}
```

### StreamReplaced

**Emitted:** When another device connects with the same credentials (stream error code 409 or `<conflict type="replaced">`)

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct StreamReplaced {}
```

**Usage:**

```rust theme={null}
Event::StreamReplaced(_) => {
    println!("⚠️ Another instance connected - disconnecting");
    // Auto-reconnect is disabled — reconnecting would displace the other client
}
```

**Behavior:** Auto-reconnect is disabled. The client stops permanently.

### LoggedOut

**Emitted:** When the session is invalidated by the server (stream error code 401 or 516) or when `client.logout()` is called

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct LoggedOut {
    pub on_connect: bool,
    pub reason: ConnectFailureReason,
}
```

**Fields:**

* `on_connect` — `true` if the logout happened during a connection attempt (server-initiated), `false` if triggered by `client.logout()` or a stream error while connected
* `reason` — The reason for the logout (e.g., `ConnectFailureReason::LoggedOut`)

**Usage:**

```rust theme={null}
Event::LoggedOut(logout) => {
    eprintln!("Logged out (reason: {:?})", logout.reason);
    // Session is invalid — you must re-pair the device
    // Auto-reconnect is disabled
}
```

**Behavior:** Auto-reconnect is disabled. The application must re-pair the device to establish a new session.

### StreamError

**Emitted:** For unrecognized stream error codes (codes not matching 401, 409, 429, 503, 515, or 516)

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct StreamError {
    pub code: String,
    pub raw: Option<Node>,
}
```

**Usage:**

```rust theme={null}
Event::StreamError(err) => {
    eprintln!("Unknown stream error: {} (raw: {:?})", err.code, err.raw);
}
```

<Note>
  Recognized stream error codes emit specific events instead of `StreamError`:

  * **401** → `LoggedOut` (session invalidated)
  * **409** → `StreamReplaced` (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)
  * **516** → `LoggedOut` (device removed)
</Note>

## Pairing Events

### PairingQrCode

**Emitted:** For each QR code in rotation

```rust theme={null}
/// A QR code the consumer renders during multi-device pairing.
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairingQrCode {
    /// The QR payload to render — ASCII art QR or data string.
    pub code: String,
    /// How long this code stays valid before the next one rotates in (60s first, 20s subsequent).
    pub timeout: std::time::Duration,
}
```

**Example:**

```rust theme={null}
Event::PairingQrCode(PairingQrCode { code, timeout, .. }) => {
    println!("Scan this QR (valid {}s):", timeout.as_secs());
    println!("{}", code);
}
```

<Note>
  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]`.
</Note>

### PairingCode

**Emitted:** When pair code is generated

```rust theme={null}
/// Generated pair code for phone number linking.
/// User should enter this code on their phone in WhatsApp > Linked Devices.
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairingCode {
    /// The 8-character pairing code to display.
    pub code: String,
    /// Approximate validity duration (~180 seconds).
    pub timeout: std::time::Duration,
}
```

<Note>
  `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.
</Note>

**Example:**

```rust theme={null}
Event::PairingCode(PairingCode { code, .. }) => {
    println!("Enter {} on your phone", code);
}
```

<Note>
  Breaking change: `PairingCode` moved from inline fields on `Event::PairingCode { code, timeout }` to a dedicated sealed struct — `Event::PairingCode(PairingCode)`.
</Note>

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

```rust theme={null}
/// The server asked the companion to refresh an in-progress phone-number
/// pairing code. The consumer should request a fresh code via
/// `pair_with_code`; the previous code is no longer guaranteed valid.
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairingCodeRefresh {
    /// `true` when the server set `force_manual_refresh` — the code must be
    /// re-requested explicitly rather than auto-rotated.
    pub force_manual: bool,
}
```

**Example:**

```rust theme={null}
Event::PairingCodeRefresh(PairingCodeRefresh { force_manual, .. }) => {
    // The previous code is no longer guaranteed valid — request a new one,
    // e.g. by calling `client.pair_with_code(options)` again.
    println!("Server requested a pair-code refresh (force_manual={force_manual})");
}
```

<Note>
  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)`.
</Note>

### PairSuccess

**Emitted:** When pairing completes successfully

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairSuccess {
    pub id: Jid,
    pub lid: Jid,
    pub business_name: String,
    pub platform: String,
}
```

**Example:**

```rust theme={null}
Event::PairSuccess(info) => {
    println!("✅ Paired as {}", info.id);
    println!("LID: {}", info.lid);
    println!("Name: {}", info.business_name);
}
```

### PairError

**Emitted:** When pairing fails

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairError {
    pub id: Jid,
    pub lid: Jid,
    pub business_name: String,
    pub platform: String,
    pub error: String,
}
```

### PairPasskeyRequest

**Emitted:** During [passkey (SHORTCAKE\_PASSKEY) linking](/concepts/authentication#passkey-linking-shortcake_passkey), when the server asks for a WebAuthn assertion to gate the link

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairPasskeyRequest {
    pub request_options_json: String, // verbatim PublicKeyCredentialRequestOptions JSON
}
```

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

```rust theme={null}
Event::PairPasskeyRequest(req) => {
    let assertion = my_get_assertion_from_json(&req.request_options_json).await?;
    client.send_passkey_response(assertion).await?;
}
```

### PairPasskeyConfirmation

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairPasskeyConfirmation {
    pub code: String,          // 8-char "XXXX-XXXX" verification code
    pub skip_handoff_ux: bool, // true on a proven re-link: continuity means the code need not be shown
}
```

**Example:**

```rust theme={null}
Event::PairPasskeyConfirmation(conf) => {
    if conf.skip_handoff_ux {
        client.send_passkey_confirmation().await?;
    } else {
        println!("Confirm {} matches on your phone", conf.code);
    }
}
```

### PairPasskeyError

**Emitted:** When a passkey link attempt fails

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PairPasskeyError {
    pub error: String,
    pub continuation: bool, // false = failed during the initial request, true = during continuation/verification
}
```

### QrScannedWithoutMultidevice

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct QrScannedWithoutMultidevice {}
```

**Usage:**

```rust theme={null}
Event::QrScannedWithoutMultidevice(_) => {
    println!("QR scanned but device does not support multi-device");
    // Prompt the user to update their WhatsApp app
}
```

### ClientOutdated

**Emitted:** When the server rejects the connection because the client version is too old (connect failure code 405)

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ClientOutdated {}
```

**Usage:**

```rust theme={null}
Event::ClientOutdated(_) => {
    eprintln!("Client version is outdated — update whatsapp-rust");
    // Auto-reconnect is disabled
}
```

**Behavior:** Auto-reconnect is disabled. You must update to a newer version of the library.

## Message Events

### Messages

**Emitted:** For all incoming messages (text, media, etc.), one event per durable commit.

```rust theme={null}
Event::Messages(MessageBatch)

#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct InboundMessage {
    pub message: Arc<wa::Message>,
    pub info: Arc<MessageInfo>,
}

pub enum BatchOrigin {
    Live,          // delivered immediately, batch of one
    OfflineDrain,  // accumulated batch from the offline drain
}

#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct MessageBatch {
    pub messages: Arc<[InboundMessage]>,
    pub origin: BatchOrigin,
}
```

<Note>
  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](/advanced/inbound-durability#batching)). `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 `InboundMessage`s (empty for any other event kind) — use it to scan a mixed event stream without matching on `Event::Messages` first.
</Note>

<Note>
  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()`.
</Note>

<Note>
  Both the message body and `MessageInfo` are `Arc`-wrapped inside `InboundMessage`. The same `Arc` slice handed to a registered [durability hook](/advanced/inbound-durability) 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.
</Note>

**MessageInfo structure:**

```rust theme={null}
#[derive(Debug, Clone, Default, Serialize)]
pub struct MessageInfo {
    pub source: MessageSource,
    pub id: MessageId,
    pub server_id: MessageServerId,
    pub r#type: String,
    pub push_name: String,
    pub timestamp: DateTime<Utc>,
    pub server_timestamp_us: Option<i64>,
    pub category: MessageCategory,
    pub multicast: bool,
    pub media_type: String,
    pub edit: EditAttribute,
    pub bot_info: Option<MsgBotInfo>,
    pub meta_info: MsgMetaInfo,
    pub verified_name: Option<Box<VerifiedName>>,
    pub verified_level: Option<String>,
    pub verified_name_serial: Option<String>,
    pub device_sent_meta: Option<DeviceSentMeta>,
    pub ephemeral_expiration: Option<u32>,
    pub is_offline: bool,
    pub peer_recipient_pn: Option<Jid>,
    pub unavailable_request_id: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct MessageSource {
    pub chat: Jid,             // Where it was sent (group or DM)
    pub sender: Jid,           // Who sent the message
    pub is_from_me: bool,
    pub is_group: bool,
    pub addressing_mode: Option<AddressingMode>,
    pub sender_alt: Option<Jid>,
    pub recipient_alt: Option<Jid>,
    pub broadcast_list_owner: Option<Jid>,
    pub recipient: Option<Jid>,
}
```

`sender_alt` carries the LID/PN counterpart of `sender` whenever the stanza exposes one — including `status@broadcast` messages, which always include `participant_lid` (or `participant_pn` for LID-addressed status). The library reads it unconditionally so the [LID-PN cache](/concepts/storage#lidpncache) can re-warm from the message itself, matching WA Web's `WAWebMsgParser`.

**MessageCategory:**

```rust theme={null}
pub enum MessageCategory {
    Empty,        // Default (empty string on the wire)
    Peer,         // Self-synced message from primary phone
    Other(String), // Unknown category (forward compatibility)
}
```

**EditAttribute:**

Indicates the type of edit or revocation applied to a message. Values correspond to the wire-format `edit` attribute on message stanzas.

```rust theme={null}
pub enum EditAttribute {
    Empty,            // "" — no edit (default)
    MessageEdit,      // "1" — message text was edited
    PinInChat,        // "2" — message was pinned/unpinned
    AdminEdit,        // "3" — admin edited the message
    SenderRevoke,     // "7" — sender deleted for everyone
    AdminRevoke,      // "8" — admin deleted for everyone
    Unknown(String),  // Forward-compatible fallback
}
```

**MsgBotInfo:**

Present when the message originates from a WhatsApp bot (AI-generated responses). Contains streaming edit metadata.

```rust theme={null}
pub struct MsgBotInfo {
    pub edit_type: Option<BotEditType>,
    pub edit_target_id: Option<MessageId>,
    pub edit_sender_timestamp_ms: Option<DateTime<Utc>>,
}

pub enum BotEditType {
    First,  // Initial bot response
    Inner,  // Streaming update (intermediate chunk)
    Last,   // Final streaming update
}
```

**MsgMetaInfo:**

Additional metadata for message threading, targeting, and abuse reporting.

```rust theme={null}
pub struct MsgMetaInfo {
    pub target_id: Option<MessageId>,
    pub target_sender: Option<Jid>,
    pub deprecated_lid_session: Option<bool>,
    pub thread_message_id: Option<MessageId>,
    pub thread_message_sender_jid: Option<Jid>,
    pub content_type: Option<String>,
    pub appdata: Option<String>,
    pub reporting_tag: Option<String>,
    pub reporting_token: Option<Bytes>,
    pub reporting_token_version: Option<i32>,
}
```

| Field                       | Description                                                                                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `target_id`                 | ID of the message being targeted (for edits/revokes)                                                                                             |
| `target_sender`             | Sender of the targeted message                                                                                                                   |
| `deprecated_lid_session`    | Legacy LID session flag                                                                                                                          |
| `thread_message_id`         | ID of the thread parent message                                                                                                                  |
| `thread_message_sender_jid` | Sender of the thread parent message                                                                                                              |
| `content_type`              | High-level content classification carried by the server (e.g. `"image"`, `"document"`) so consumers can filter without decoding the message body |
| `appdata`                   | Opaque per-message app data used by the abuse-report flow                                                                                        |
| `reporting_tag`             | Tag the server attaches so the recipient can submit an abuse report tied to this message                                                         |
| `reporting_token`           | Raw bytes of the abuse-report token paired with `reporting_tag`                                                                                  |
| `reporting_token_version`   | Version of the reporting-token scheme used by the server                                                                                         |

<Note>
  `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.
</Note>

<Note>
  Breaking change: `verified_name` changed from the raw, never-populated `wa::VerifiedNameCertificate` to `Option<Box<VerifiedName>>` — the decoded business display name (see [`VerifiedName`](/api/contacts#is_on_whatsapp) 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.
</Note>

<Note>
  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](https://github.com/oxidezap/whatsapp-rust/pull/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.
</Note>

**DeviceSentMeta:**

Present on device-synced messages (messages you sent from another device).

```rust theme={null}
pub struct DeviceSentMeta {
    pub destination_jid: String,
    pub phash: String,
}
```

**Example:**

```rust theme={null}
use waproto::whatsapp as wa;

Event::Messages(batch) => {
    for InboundMessage { message: msg, info, .. } in batch.iter() {
        println!("From: {} in {}", info.source.sender, info.source.chat);

        // Text message
        if let Some(text) = &msg.conversation {
            println!("Text: {}", text);
        }

        // Extended text (with link preview, quoted message, etc.)
        if let Some(ext) = &msg.extended_text_message {
            println!("Text: {}", ext.text.as_deref().unwrap_or(""));

            if let Some(context) = &ext.context_info {
                if let Some(quoted) = &context.quoted_message {
                    println!("Quoted: {:?}", quoted);
                }
            }
        }

        // Image message
        if let Some(img) = &msg.image_message {
            println!("Image: {} ({}x{})", 
                img.caption.as_deref().unwrap_or(""),
                img.width.unwrap_or(0),
                img.height.unwrap_or(0)
            );
        }

        // Video, audio, document, sticker, etc.
        // See waproto::whatsapp::Message for all types
    }
}
```

### Receipt

**Emitted:** For delivery/read/played receipts

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

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum ReceiptType {
    Delivered,
    Sender,
    Retry,
    EncRekeyRetry,
    Read,
    ReadSelf,
    Played,
    PlayedSelf,
    ServerError,
    Inactive,
    PeerMsg,
    HistorySync,
    Other(String),
}
```

<Note>
  `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.
</Note>

**Example:**

```rust theme={null}
Event::Receipt(receipt) => {
    match receipt.r#type {
        ReceiptType::Read => {
            println!("✓✓ Read by {}", receipt.source.sender);
        }
        ReceiptType::Delivered => {
            println!("✓ Delivered to {}", receipt.source.sender);
        }
        _ => {}
    }
}
```

### UndecryptableMessage

**Emitted:** When a message cannot be decrypted or is unavailable. This includes:

* Decryption failures (no session, invalid keys, MAC errors)
* Group messages that fail with `NoSenderKeyState` (missing sender key) — dispatched before the retry receipt is sent
* Messages with an `<unavailable>` node — view-once already viewed, 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](/guides/receiving-messages#unavailable-message-recovery-via-pdo)

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.

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct UndecryptableMessage {
    pub info: Arc<MessageInfo>,
    pub is_unavailable: bool,
    pub unavailable_type: UnavailableType,
    pub decrypt_fail_mode: DecryptFailMode,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum UnavailableType {
    Unknown,
    ViewOnce,       // View-once media already viewed
    Hosted,         // Hosted content the phone won't fan out to companions
    Bot,            // AI bot message fanout
}

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

### Notification

**Emitted:** For raw notification stanzas that are not handled by a more specific event type

```rust theme={null}
Event::Notification(Arc<OwnedNodeRef>)
```

This is a passthrough event that gives you access to the raw node for notification types that the library does not parse into dedicated event structs. The `OwnedNodeRef` provides zero-copy access to the decoded stanza — call `.get()` to obtain a `NodeRef` for inspecting the tag, attributes, and children.

**Example:**

```rust theme={null}
Event::Notification(node) => {
    let node_ref = node.get();
    println!("Raw notification tag: {}", node_ref.tag);
}
```

<Note>
  Most notifications are already parsed into specific event types (e.g., `GroupUpdate`, `DeviceListUpdate`, `ContactUpdated`). This event only fires for unhandled notification types.
</Note>

**`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](/api/send#decrypt-fail-suppression) for the full list of message types that set this attribute on outgoing stanzas.

**Example:**

```rust theme={null}
Event::UndecryptableMessage(undec) => {
    match undec.decrypt_fail_mode {
        DecryptFailMode::Hide => {
            // Silently ignore — infrastructure message (reaction, poll vote, etc.)
        }
        DecryptFailMode::Show => {
            match undec.unavailable_type {
                UnavailableType::ViewOnce => println!("View-once message already consumed"),
                UnavailableType::Hosted => println!("Hosted content unavailable"),
                UnavailableType::Bot => println!("Bot message unavailable"),
                UnavailableType::Unknown => {
                    eprintln!("Failed to decrypt message from {}", undec.info.source.sender);
                }
            }
        }
    }
}
```

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ServerAck {
    pub id: String,
    pub class: Option<String>,
    pub from: Option<Jid>,
    pub timestamp: Option<DateTime<Utc>>,
    pub error: Option<String>,
}
```

| Field       | Description                                                                                                                                                                                                                                              |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`        | Id of the acked stanza (for a sent message, its message id).                                                                                                                                                                                             |
| `class`     | Stanza class the ack refers to (`"message"`, `"receipt"`, `"notification"`, `"call"`, …). `None` when the server omits it.                                                                                                                               |
| `from`      | Chat/entity the ack refers to, when present and parseable.                                                                                                                                                                                               |
| `timestamp` | Server timestamp from the ack's `t` attribute, when present. For a message ack this is the authoritative send timestamp — the same attribute the [whatsmeow](https://github.com/tulir/whatsmeow) Go library reads into its own `SendResponse.Timestamp`. |
| `error`     | Nack code (e.g. `"479"`), when the ack is actually a nack; `None` for a plain ack.                                                                                                                                                                       |

<Note>
  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.
</Note>

<Note>
  `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.
</Note>

**Example:**

```rust theme={null}
Event::ServerAck(ack) => {
    if ack.class.as_deref() == Some("message") {
        if let Some(err) = &ack.error {
            eprintln!("Message {} nacked: {err}", ack.id);
        } else {
            println!("Message {} accepted by the server", ack.id);
        }
    }
}
```

## Presence Events

### ChatPresence

**Emitted:** For typing indicators and recording states

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ChatPresenceUpdate {
    pub source: MessageSource,
    pub state: ChatPresence,
    pub media: ChatPresenceMedia,
}

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ChatPresenceMedia {
    Text,
    Audio,
}
```

**Example:**

```rust theme={null}
Event::ChatPresence(update) => {
    match (update.state, update.media) {
        (ChatPresence::Composing, ChatPresenceMedia::Text) => {
            println!("{} is typing...", update.source.sender);
        }
        (ChatPresence::Composing, ChatPresenceMedia::Audio) => {
            println!("{} is recording audio...", update.source.sender);
        }
        (ChatPresence::Paused, _) => {
            println!("{} stopped typing", update.source.sender);
        }
    }
}
```

### Presence

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PresenceUpdate {
    pub from: Jid,
    pub unavailable: bool,
    pub last_seen: Option<DateTime<Utc>>,
}
```

**Example:**

```rust theme={null}
Event::Presence(update) => {
    if update.unavailable {
        println!("{} is offline", update.from);
        if let Some(last_seen) = update.last_seen {
            println!("Last seen: {}", last_seen);
        }
    } else {
        println!("{} is online", update.from);
    }
}
```

## User update events

### PictureUpdate

**Emitted:** When a user changes their profile picture

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PictureUpdate {
    pub jid: Jid,
    pub author: Option<Jid>,
    pub timestamp: DateTime<Utc>,
    pub removed: bool,
    pub picture_id: Option<String>,
}
```

**Fields:**

* `jid` - The JID whose picture changed (user or group)
* `author` - The user who made the change. Present for group picture changes (the admin who changed it). `None` for personal picture updates.
* `removed` - Whether the picture was removed (`true`) or set/updated (`false`)
* `picture_id` - The server-assigned picture ID. `None` for deletions.

### UserAboutUpdate

**Emitted:** When a user changes their status/about

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct UserAboutUpdate {
    pub jid: Jid,
    pub status: String,
    pub timestamp: DateTime<Utc>,
}
```

### PushNameUpdate

**Emitted:** When a contact changes their display name

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PushNameUpdate {
    pub jid: Jid,
    pub message: Box<MessageInfo>,
    pub old_push_name: String,
    pub new_push_name: String,
}
```

### SelfPushNameUpdated

**Emitted:** When your own push name is updated

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct SelfPushNameUpdated {
    pub from_server: bool,
    pub old_name: String,
    pub new_name: String,
}
```

## Group Events

### GroupUpdate

**Emitted:** For each action in a group notification (subject changes, participant changes, settings updates, etc.). A single notification may produce multiple `GroupUpdate` events.

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct GroupUpdate {
    pub group_jid: Jid,
    pub participant: Option<Jid>,
    pub participant_pn: Option<Jid>,
    pub timestamp: DateTime<Utc>,
    pub is_lid_addressing_mode: bool,
    pub action: GroupNotificationAction,
}
```

**Fields:**

* `group_jid` - The group this update applies to
* `participant` - The admin/user who triggered the change
* `participant_pn` - Phone number JID of the participant (for LID-addressed groups)
* `is_lid_addressing_mode` - Whether the group uses LID addressing mode
* `action` - The specific group notification action (subject change, participant add/remove/promote/demote, description change, etc.)

**Example:**

```rust theme={null}
Event::GroupUpdate(update) => {
    println!("Group {} updated by {:?}: {:?}", 
        update.group_jid, update.participant, update.action);
}
```

### GroupNotificationAction

The `action` field on `GroupUpdate` is a `GroupNotificationAction` enum with the following variants:

| Variant                                                                    | Wire tag                        | Description                                                                                                                                                                                                                                                 |
| -------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Add { participants, reason }`                                             | `<add>`                         | Members added to group                                                                                                                                                                                                                                      |
| `Remove { participants, reason }`                                          | `<remove>`                      | Members removed from group                                                                                                                                                                                                                                  |
| `Promote { participants }`                                                 | `<promote>`                     | Members promoted to admin                                                                                                                                                                                                                                   |
| `Demote { participants }`                                                  | `<demote>`                      | Members demoted from admin                                                                                                                                                                                                                                  |
| `Modify { participants }`                                                  | `<modify>`                      | Member changed phone number                                                                                                                                                                                                                                 |
| `ChangeNumber { new_owner, sub_group_suggestions }`                        | `<change_number>`               | A participant changed their phone number. `new_owner` is read from the `jid` attribute of `<change_number>` and `sub_group_suggestions` is collected from any `<sub_group_suggestion>` children. The previous JID is carried by `GroupUpdate::participant`. |
| `Subject { subject, subject_owner, subject_time }`                         | `<subject>`                     | Group name changed                                                                                                                                                                                                                                          |
| `Description { id, description }`                                          | `<description>`                 | Group description changed or deleted                                                                                                                                                                                                                        |
| `Locked { threshold }`                                                     | `<locked>`                      | Only admins can edit group info                                                                                                                                                                                                                             |
| `Unlocked`                                                                 | `<unlocked>`                    | All members can edit group info                                                                                                                                                                                                                             |
| `Announce`                                                                 | `<announcement>`                | Only admins can send messages                                                                                                                                                                                                                               |
| `NotAnnounce`                                                              | `<not_announcement>`            | All members can send messages                                                                                                                                                                                                                               |
| `Ephemeral { expiration, trigger }`                                        | `<ephemeral>`                   | Disappearing messages setting changed                                                                                                                                                                                                                       |
| `MembershipApprovalMode { enabled }`                                       | `<membership_approval_mode>`    | Join approval toggled                                                                                                                                                                                                                                       |
| `MembershipApprovalRequest { request_method, parent_group_jid }`           | `<membership_approval_request>` | A user requested to join the group                                                                                                                                                                                                                          |
| `CreatedMembershipRequests { request_method, parent_group_jid, requests }` | `<created_membership_requests>` | Admin-side: new join requests appeared                                                                                                                                                                                                                      |
| `RevokedMembershipRequests { participants }`                               | `<revoked_membership_requests>` | Membership requests rejected or cancelled                                                                                                                                                                                                                   |
| `MemberAddMode { mode }`                                                   | `<member_add_mode>`             | Who can add members (`admin_add` or `all_member_add`)                                                                                                                                                                                                       |
| `NoFrequentlyForwarded`                                                    | `<no_frequently_forwarded>`     | Forwarding restricted                                                                                                                                                                                                                                       |
| `FrequentlyForwardedOk`                                                    | `<frequently_forwarded_ok>`     | Forwarding allowed                                                                                                                                                                                                                                          |
| `Invite { code }`                                                          | `<invite>`                      | Member joined via invite link                                                                                                                                                                                                                               |
| `RevokeInvite`                                                             | `<revoke>`                      | Invite link revoked                                                                                                                                                                                                                                         |
| `GrowthLocked { expiration, lock_type }`                                   | `<growth_locked>`               | Invite links unavailable                                                                                                                                                                                                                                    |
| `GrowthUnlocked`                                                           | `<growth_unlocked>`             | Invite links available again                                                                                                                                                                                                                                |
| `Create { raw }`                                                           | `<create>`                      | Group created                                                                                                                                                                                                                                               |
| `Delete { reason }`                                                        | `<delete>`                      | Group deleted                                                                                                                                                                                                                                               |
| `Link { link_type, raw }`                                                  | `<link>`                        | Subgroup linked (community)                                                                                                                                                                                                                                 |
| `Unlink { unlink_type, unlink_reason, raw }`                               | `<unlink>`                      | Subgroup unlinked (community)                                                                                                                                                                                                                               |
| `Unknown { tag }`                                                          | varies                          | Unknown notification tag (forward compatibility)                                                                                                                                                                                                            |

Participant-related variants include a `participants` field of type `Vec<GroupParticipantInfo>`:

```rust theme={null}
pub struct GroupParticipantInfo {
    pub jid: Jid,
    pub phone_number: Option<Jid>,
    pub display_name: Option<String>,
}
```

`display_name` carries the server-provided label for a participant — for non-contacts this is typically the masked phone number (`"+55•••••••••79"`). It is populated only when the participant appears as a `<participant>` child of a group notification; entries that arrive via `<requested_user>` (membership requests) leave it `None`.

Membership request variants include a `request_method` field of type `MembershipRequestMethod`:

```rust theme={null}
pub enum MembershipRequestMethod {
    InviteLink,        // User clicked an invite link
    LinkedGroupJoin,   // User joined via a linked community subgroup
    NonAdminAdd,       // A non-admin member tried to add the user
}
```

* `MembershipApprovalRequest` — emitted when a user requests to join a group. The requester is identified by the parent `GroupUpdate::participant` field.
* `CreatedMembershipRequests` — admin-side notification: new join requests appeared. The `requests` field contains the requesting users (as `Vec<GroupParticipantInfo>`).
* `RevokedMembershipRequests` — emitted when membership requests are rejected by an admin or cancelled by the requester. The `participants` field contains the affected JIDs.

Both `MembershipApprovalRequest` and `CreatedMembershipRequests` include an optional `parent_group_jid` field for community-linked joins.

**Example: handling specific group actions:**

```rust theme={null}
use wacore::stanza::groups::GroupNotificationAction;

Event::GroupUpdate(update) => {
    match &update.action {
        GroupNotificationAction::Add { participants, .. } => {
            for p in participants {
                println!("{} was added to {}", p.jid, update.group_jid);
            }
        }
        GroupNotificationAction::Subject { subject, .. } => {
            println!("Group {} renamed to {}", update.group_jid, subject);
        }
        GroupNotificationAction::Ephemeral { expiration, .. } => {
            if *expiration == 0 {
                println!("Disappearing messages disabled in {}", update.group_jid);
            } else {
                println!("Disappearing messages set to {}s in {}", expiration, update.group_jid);
            }
        }
        GroupNotificationAction::MembershipApprovalRequest { request_method, parent_group_jid } => {
            println!("Join request in {} via {:?}", update.group_jid, request_method);
            if let Some(parent) = parent_group_jid {
                println!("From community: {}", parent);
            }
        }
        GroupNotificationAction::CreatedMembershipRequests { requests, request_method, .. } => {
            println!("{} new join requests in {} via {:?}",
                requests.len(), update.group_jid, request_method);
        }
        GroupNotificationAction::RevokedMembershipRequests { participants } => {
            println!("{} membership requests revoked in {}",
                participants.len(), update.group_jid);
        }
        _ => {}
    }
}
```

<Note>
  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.
</Note>

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ContactUpdated {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
}
```

**Wire format:** `<notification type="contacts"><update jid="..."/>`

When you receive this event, you should invalidate any cached presence or profile picture data for the contact. WhatsApp Web resets its `PresenceCollection` and refreshes the profile picture thumbnail on this event.

**Example:**

```rust theme={null}
Event::ContactUpdated(update) => {
    println!("Contact {} profile changed at {}", update.jid, update.timestamp);
    // Invalidate cached presence/profile data
    // Re-fetch profile picture if needed
}
```

### ContactNumberChanged

**Emitted:** When a contact changes their phone number

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ContactNumberChanged {
    /// Old phone number JID.
    pub old_jid: Jid,
    /// New phone number JID.
    pub new_jid: Jid,
    /// Old LID (if provided by server).
    pub old_lid: Option<Jid>,
    /// New LID (if provided by server).
    pub new_lid: Option<Jid>,
    pub timestamp: DateTime<Utc>,
}
```

**Wire format:** `<notification type="contacts"><modify old="..." new="..." old_lid="..." new_lid="..."/>`

The library automatically creates LID-PN mappings when LID attributes are present (`old_lid→old_jid` and `new_lid→new_jid`). WhatsApp Web generates a system notification message in both the old and new chats.

**Example:**

```rust theme={null}
Event::ContactNumberChanged(change) => {
    println!("Contact changed number: {} -> {}", change.old_jid, change.new_jid);
    if let Some(new_lid) = &change.new_lid {
        println!("New LID: {}", new_lid);
    }
    // Update your contact records to use the new JID
}
```

### ContactSyncRequested

**Emitted:** When the server requests a full contact re-sync

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ContactSyncRequested {
    /// If present, only sync contacts modified after this timestamp.
    pub after: Option<DateTime<Utc>>,
    pub timestamp: DateTime<Utc>,
}
```

**Wire format:** `<notification type="contacts"><sync after="..."/>`

**Example:**

```rust theme={null}
Event::ContactSyncRequested(sync) => {
    if let Some(after) = sync.after {
        println!("Server requests contact sync for changes after {}", after);
    } else {
        println!("Server requests full contact sync");
    }
}
```

### ContactUpdate

**Emitted:** When a contact's information changes via app-state sync (e.g., first name, last name set in your address book)

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ContactUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::ContactAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `jid` - The contact whose information changed
* `timestamp` - When the change occurred
* `action` - The contact action from app-state sync, containing fields like `full_name` and `first_name`
* `from_full_sync` - Whether this came from a full app-state sync (initial load) or an incremental update

**Example:**

```rust theme={null}
Event::ContactUpdate(update) => {
    println!("Contact {} updated at {}", update.jid, update.timestamp);
    if let Some(name) = &update.action.full_name {
        println!("Full name: {}", name);
    }
    if update.from_full_sync {
        println!("(from full sync)");
    }
}
```

<Note>
  `ContactUpdate` comes from app-state sync mutations and is distinct from `ContactUpdated`, which comes from server-side `<notification type="contacts">` stanzas.
</Note>

<Note>
  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.
</Note>

## Chat state events

### PinUpdate

**Emitted:** When a chat is pinned/unpinned

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct PinUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::PinAction>,
    pub from_full_sync: bool,
}
```

### MuteUpdate

**Emitted:** When a chat is muted/unmuted

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct MuteUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::MuteAction>,
    pub from_full_sync: bool,
}
```

### ArchiveUpdate

**Emitted:** When a chat is archived/unarchived

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ArchiveUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::ArchiveChatAction>,
    pub from_full_sync: bool,
}
```

### StarUpdate

**Emitted:** When a message is starred or unstarred

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct StarUpdate {
    pub chat_jid: Jid,
    pub participant_jid: Option<Jid>,
    pub message_id: String,
    pub from_me: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::StarAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `chat_jid` - The chat containing the starred message
* `participant_jid` - The sender of the message (only for group messages from others; `None` for self-authored or 1-on-1 messages)
* `message_id` - The ID of the starred/unstarred message
* `from_me` - Whether the starred message was sent by you

**Example:**

```rust theme={null}
Event::StarUpdate(update) => {
    println!("Message {} in {} was {}starred",
        update.message_id, update.chat_jid,
        if update.action.starred.unwrap_or(false) { "" } else { "un" }
    );
}
```

### MarkChatAsReadUpdate

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct MarkChatAsReadUpdate {
    pub jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::MarkChatAsReadAction>,
    pub from_full_sync: bool,
}
```

**Example:**

```rust theme={null}
Event::MarkChatAsReadUpdate(update) => {
    let read = update.action.read.unwrap_or(false);
    println!("Chat {} marked as {}", update.jid, if read { "read" } else { "unread" });
}
```

### DeleteChatUpdate

**Emitted:** When a chat is deleted across linked devices

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct DeleteChatUpdate {
    pub jid: Jid,
    /// From the index, not the proto — DeleteChatAction only has messageRange.
    pub delete_media: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::DeleteChatAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `jid` - The JID of the deleted chat
* `delete_media` - Whether media files were also deleted
* `action` - The underlying protobuf action containing the optional `message_range`

**Example:**

```rust theme={null}
Event::DeleteChatUpdate(update) => {
    println!("Chat {} deleted (media deleted: {})", update.jid, update.delete_media);
}
```

### ClearChatUpdate

**Emitted:** When a chat's messages are cleared (but the chat is kept) on a linked device

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct ClearChatUpdate {
    pub jid: Jid,
    /// From the index, not the proto — ClearChatAction only has messageRange.
    pub delete_starred: bool,
    /// From the index, not the proto.
    pub delete_media: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::ClearChatAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `jid` - The chat that was cleared
* `delete_starred` - Whether starred messages were also removed
* `delete_media` - Whether downloaded media was also removed
* `from_full_sync` - `true` while replaying the initial app state full sync

**Example:**

```rust theme={null}
Event::ClearChatUpdate(update) => {
    println!("Chat {} cleared (starred: {}, media: {})",
        update.jid, update.delete_starred, update.delete_media);
}
```

See [`clear_chat`](/api/chat-actions#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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct UserStatusMuteUpdate {
    pub jid: Jid,
    /// `true` = status muted, `false` = unmuted.
    pub muted: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::UserStatusMuteAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `jid` - The entity whose status updates were (un)muted
* `muted` - `true` when status was muted, `false` when unmuted
* `from_full_sync` - `true` while replaying the initial app state full sync

**Example:**

```rust theme={null}
Event::UserStatusMuteUpdate(update) => {
    println!("Status of {} {}", update.jid, if update.muted { "muted" } else { "unmuted" });
}
```

See [`set_user_status_mute`](/api/chat-actions#set_user_status_mute) for the outbound API.

### DeleteMessageForMeUpdate

**Emitted:** When a message is deleted locally (not for everyone) across linked devices

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct DeleteMessageForMeUpdate {
    pub chat_jid: Jid,
    pub participant_jid: Option<Jid>,
    pub message_id: String,
    pub from_me: bool,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::DeleteMessageForMeAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `chat_jid` - The chat containing the deleted message
* `participant_jid` - The sender of the message (only for group messages from others; `None` for self-authored or 1-on-1 messages)
* `message_id` - The ID of the deleted message
* `from_me` - Whether the deleted message was sent by you
* `action` - The underlying protobuf action containing `delete_media` and optional `message_timestamp`

**Example:**

```rust theme={null}
Event::DeleteMessageForMeUpdate(update) => {
    println!("Message {} in {} deleted for me", update.message_id, update.chat_jid);
}
```

### LabelEditUpdate

**Emitted:** When a chat label is created, renamed, recolored, or deleted on a linked device

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct LabelEditUpdate {
    pub label_id: String,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::LabelEditAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `label_id` — Stable label identifier
* `action.name` — New display name (`None` when only the deleted flag changes)
* `action.color` — WhatsApp color index for the swatch
* `action.deleted` — `Some(true)` when the label was removed
* `from_full_sync` — `true` while replaying the initial app state full sync

**Example:**

```rust theme={null}
Event::LabelEditUpdate(update) => {
    if update.action.deleted == Some(true) {
        println!("Label {} deleted", update.label_id);
    } else {
        println!("Label {} renamed to {:?}", update.label_id, update.action.name);
    }
}
```

### LabelAssociationUpdate

**Emitted:** When a label is added to or removed from a chat on a linked device

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct LabelAssociationUpdate {
    pub label_id: String,
    pub chat_jid: Jid,
    pub timestamp: DateTime<Utc>,
    pub action: Box<wa::sync_action_value::LabelAssociationAction>,
    pub from_full_sync: bool,
}
```

**Fields:**

* `label_id` — Identifier of the label being attached or detached
* `chat_jid` — Chat whose label set changed
* `action.labeled` — `Some(true)` when the label was added, `Some(false)` when removed
* `from_full_sync` — `true` while replaying the initial app state full sync

**Example:**

```rust theme={null}
Event::LabelAssociationUpdate(update) => {
    let attached = update.action.labeled == Some(true);
    println!(
        "Label {} {} chat {}",
        update.label_id,
        if attached { "added to" } else { "removed from" },
        update.chat_jid,
    );
}
```

See [Labels](/api/labels) for the outbound API that emits these events on other devices.

## History sync events

### HistorySync

**Emitted:** For chat history synchronization

```rust theme={null}
Event::HistorySync(Box<LazyHistorySync>)
```

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

```rust theme={null}
pub struct LazyHistorySync {
    compressed: Bytes,         // original zlib payload (reference-counted)
    decompressed_size: usize,  // exact inflated byte count
    sync_type: i32,
    chunk_order: Option<u32>,
    progress: Option<u32>,
    peer_data_request_session_id: Option<String>,
    parsed: OnceLock<Option<Box<wa::HistorySync>>>,
}

impl LazyHistorySync {
    /// History sync type (e.g. InitialBootstrap, Recent, PushName).
    /// Available without decoding the proto.
    pub fn sync_type(&self) -> i32;

    /// Chunk ordering for multi-chunk transfers.
    pub fn chunk_order(&self) -> Option<u32>;

    /// Sync progress (0-100).
    pub fn progress(&self) -> Option<u32>;

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

    /// Exact inflated byte count (known without decompressing).
    pub fn decompressed_size(&self) -> usize;

    /// The stored compressed bytes (reference-counted, zero-copy).
    pub fn compressed_bytes(&self) -> &Bytes;

    /// Inflate the payload into a fresh buffer on each call (no caching).
    /// Use `get()` for repeated full-proto access.
    pub fn decompress(&self) -> std::io::Result<Bytes>;

    /// Streaming reader: yields one conversation at a time with bounded
    /// peak memory. See the `HistorySyncStream` section below.
    pub fn stream(&self) -> HistorySyncStream<'_>;

    /// Full decode of the history sync proto, cached via OnceLock.
    /// Returns `None` if decoding fails. The compressed payload stays
    /// accessible after this call — `compressed_bytes()`, `decompress()`,
    /// and `stream()` all keep working.
    pub fn get(&self) -> Option<&wa::HistorySync>;
}
```

**Key characteristics:**

* **Metadata without decoding** — `sync_type()`, `chunk_order()`, `progress()`, and `peer_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 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 clone** — `Clone` 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 demand** — `decompress()` 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
* **Streaming** — `stream()` 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 correlation** — `peer_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.

<Tip>
  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.
</Tip>

**Example — full decode:**

```rust theme={null}
Event::HistorySync(lazy_sync) => {
    println!("History sync type: {}, progress: {:?}",
        lazy_sync.sync_type(), lazy_sync.progress());

    // Full decode (cached after first call)
    if let Some(history) = lazy_sync.get() {
        for conv in &history.conversations {
            println!("Conversation: {:?}", conv.id);
        }
    }
}
```

**Example — streaming (memory-bounded):**

```rust theme={null}
Event::HistorySync(lazy_sync) => {
    let mut stream = lazy_sync.stream();

    while let Some(conv) = stream.next_conversation()? {
        println!("Conversation: {:?}", conv.id);
    }

    // Decode everything that is not a conversation (pushnames, mappings, …)
    let remainder = stream.remainder()?;
    println!("Pushnames: {}", remainder.pushnames.len());
}
```

**Example — decompress for custom parsing:**

```rust theme={null}
Event::HistorySync(lazy_sync) => {
    let bytes = lazy_sync.decompress()?;
    let history = wa::HistorySync::decode(bytes.as_ref())?;
}
```

<Note>
  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.
</Note>

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

```rust theme={null}
pub struct HistorySyncStream<'a> { /* … */ }

impl<'a> HistorySyncStream<'a> {
    /// Wrap a compressed blob with a decompressed-size cap.
    /// Use `MAX_DECOMPRESSED` (64 MB) as the default cap, or
    /// `LazyHistorySync::decompressed_size()` for an exact-size bound.
    pub fn new(compressed: &'a [u8], max_decompressed: u64) -> Self;

    /// Advance to the next conversation, returning its raw bytes.
    /// Returns `Ok(None)` when all conversations are exhausted.
    pub fn next_conversation_bytes(&mut self) -> Result<Option<&[u8]>, HistorySyncError>;

    /// Advance to the next conversation, decoding it via buffa.
    /// Corrupt entries are skipped and counted via `skipped_conversations()`.
    /// Allocates a fresh `Conversation` per call — a drain loop over
    /// thousands of entries should prefer `next_conversation_into`.
    pub fn next_conversation(&mut self) -> Result<Option<wa::Conversation>, HistorySyncError>;

    /// In-place variant of `next_conversation`: decodes into `conversation`,
    /// returning `Ok(false)` at clean EOF. buffa's `clear()` retains `Vec`
    /// capacity, so reusing one struct across a drain loop skips the
    /// per-conversation message-spine reallocations. Corrupt entries are
    /// skipped and counted via `skipped_conversations()`, matching the
    /// behaviour of `next_conversation`. After `Ok(false)` (or an error)
    /// the struct's contents are unspecified.
    pub fn next_conversation_into(&mut self, conversation: &mut wa::Conversation) -> Result<bool, HistorySyncError>;

    /// Number of conversations skipped due to decode errors.
    pub fn skipped_conversations(&self) -> usize;

    /// Decode all non-conversation fields (pushnames, mappings, nctSalt, …)
    /// regardless of wire order.
    ///
    /// Must be called after exhausting conversations. Calling it early
    /// (while unread conversations remain) returns
    /// `Err(HistorySyncError::UnreadConversations)`.
    pub fn remainder(self) -> Result<wa::HistorySync, HistorySyncError>;
}

/// Default decompressed-size cap for `HistorySyncStream::new`.
pub const MAX_DECOMPRESSED: u64 = 64 * 1024 * 1024;
```

**Error variants relevant to streaming:**

| Variant                                 | When                                                     |
| --------------------------------------- | -------------------------------------------------------- |
| `HistorySyncError::UnreadConversations` | `remainder()` called before conversations were exhausted |
| Other I/O variants                      | Truncated zlib stream, malformed length-delimited field  |

### OfflineSyncPreview

**Emitted:** Preview of pending offline sync data when reconnecting

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct OfflineSyncPreview {
    pub total: i32,
    pub app_data_changes: i32,
    pub messages: i32,
    pub notifications: i32,
    pub receipts: i32,
    pub calls: i32,
    pub statuses: i32,
}
```

`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:**

```rust theme={null}
Event::OfflineSyncPreview(preview) => {
    println!("Syncing {} items ({} messages, {} statuses, {} notifications)", 
        preview.total, preview.messages, preview.statuses, preview.notifications);
}
```

### OfflineSyncCompleted

**Emitted:** When offline sync completes after reconnection

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct OfflineSyncCompleted {
    pub count: i32,
}
```

**Example:**

```rust theme={null}
Event::OfflineSyncCompleted(sync) => {
    println!("Offline sync completed: {} items processed", sync.count);
}
```

<Note>
  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.
</Note>

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

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct DirtyState {
    pub dirty_type: DirtyType,
    pub timestamp: Option<u64>,
}
```

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

<Note>
  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`](#raw-stanza-events) or racing the client's own resync.
</Note>

**Example:**

```rust theme={null}
use wacore::iq::dirty::DirtyType;

Event::DirtyState(DirtyState { dirty_type, timestamp, .. }) => {
    match dirty_type {
        DirtyType::Groups => println!("groups cache is stale (as of {timestamp:?})"),
        DirtyType::SyncdAppState => println!("app-state re-sync incoming"),
        other => println!("dirty: {other:?} at {timestamp:?}"),
    }
}
```

## Device Events

### DeviceListUpdate

**Emitted:** When a user's device list changes (a companion device is added, removed, or updated)

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct DeviceListUpdate {
    pub user: Jid,
    pub lid_user: Option<Jid>,
    pub update_type: DeviceListUpdateType,
    pub devices: Vec<DeviceNotificationInfo>,
    pub key_index: Option<KeyIndexInfo>,
    pub contact_hash: Option<String>,
}

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

#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct DeviceNotificationInfo {
    pub device_id: u32,
    pub key_index: Option<u32>,
}
```

**Fields:**

| Field          | Description                                                               |
| -------------- | ------------------------------------------------------------------------- |
| `user`         | The user whose device list changed (PN JID)                               |
| `lid_user`     | The user's LID JID, if known from the notification                        |
| `update_type`  | Whether a device was added, removed, or updated                           |
| `devices`      | List of affected devices with their IDs and key indexes                   |
| `key_index`    | ADV key index info for device identity verification (add operations only) |
| `contact_hash` | Server-side contact hash for update operations                            |

This event is dispatched after the client has already patched its internal device registry cache. You can use it to track when contacts pair or unpair companion devices. The client also uses device list changes internally to manage [unknown device detection](/advanced/signal-protocol#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.

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct IdentityChange {
    /// The user whose identity changed
    pub user: Jid,
    /// Optional LID for the user
    pub lid_user: Option<Jid>,
    /// `true` when detected locally during decrypt (mirrors WA Web
    /// `saveIdentity` -> `handleNewIdentity`), `false` when triggered by the
    /// server's `<identity/>` notification.
    pub implicit: bool,
}
```

**Fields:**

* `user` — The phone number JID of the user whose identity changed
* `lid_user` — The user's LID JID, if provided in the notification
* `implicit` — `false` 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:

7. Clears the old identity key and retries decryption with the new identity, preserving the old session for in-flight messages
8. Handles `InvalidPreKeyId` errors in the retry path by sending a retry receipt so the sender can establish a new session
9. Re-issues [TC tokens](/api/tctoken) 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 `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](/api/tctoken) 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.

<Note>
  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.
</Note>

**Example:**

```rust theme={null}
Event::IdentityChange(change) => {
    if change.implicit {
        // Detected while decrypting an incoming message — the server
        // <identity/> push will usually follow with the full reset.
        println!("Local identity change for {}", change.user);
    } else {
        println!("Security code changed for user {}", change.user);
    }
    if let Some(lid) = &change.lid_user {
        println!("LID: {}", lid);
    }
    // Show a "security code changed" notice in the chat
}
```

### BusinessStatusUpdate

**Emitted:** When a business account status changes

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct BusinessStatusUpdate {
    pub jid: Jid,
    pub update_type: BusinessUpdateType,
    pub timestamp: DateTime<Utc>,
    pub target_jid: Option<Jid>,
    pub hash: Option<String>,
    pub verified_name: Option<String>,
    pub product_ids: Vec<String>,
    pub collection_ids: Vec<String>,
    pub subscriptions: Vec<BusinessSubscription>,
}

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

## Newsletter Events

### NewsletterLiveUpdate

**Emitted:** When reaction counts change or messages are updated on a newsletter you're subscribed to (via `subscribe_live_updates`).

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct NewsletterLiveUpdate {
    pub newsletter_jid: Jid,
    pub messages: Vec<NewsletterLiveUpdateMessage>,
}

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

#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct NewsletterLiveUpdateReaction {
    pub code: String,
    pub count: u64,
}
```

**Fields:**

* `newsletter_jid` — The newsletter channel this update is for
* `messages` — List of messages with updated reaction counts
* `server_id` — Server-assigned message ID
* `reactions` — Current reaction counts (emoji code and count)

**Example:**

```rust theme={null}
Event::NewsletterLiveUpdate(update) => {
    println!("Newsletter {} updated:", update.newsletter_jid);
    for msg in &update.messages {
        for r in &msg.reactions {
            println!("  Message {}: {} x{}", msg.server_id, r.code, r.count);
        }
    }
}
```

<Note>
  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.
</Note>

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

```rust theme={null}
#[derive(Debug, Clone, Serialize)]
pub struct IncomingCall {
    pub from: Jid,
    /// Stanza id; distinct from `CallAction::call_id`.
    pub stanza_id: String,
    pub notify: Option<String>,
    pub platform: Option<String>,
    pub version: Option<String>,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub timestamp: DateTime<Utc>,
    pub offline: bool,
    pub action: CallAction,
}
```

The `action` field is a tagged enum that mirrors the inner stanza child (`<offer>`, `<offer_notice>`, `<preaccept>`, `<accept>`, `<reject>`, `<terminate>`):

```rust theme={null}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CallAction {
    Offer {
        call_id: String,
        call_creator: Jid,
        caller_pn: Option<Jid>,
        caller_country_code: Option<String>,
        device_class: Option<String>,
        joinable: bool,
        is_video: bool,
        audio: Vec<CallAudioCodec>,
        /// Set on group calls. Primary group signal per `WAWebVoipGatingUtils`.
        group_jid: Option<Jid>,
    },
    /// Group-call notification fan-out to members. The router acks it via the
    /// generic call ack — no offer-receipt is expected.
    OfferNotice {
        call_id: String,
        call_creator: Jid,
        is_video: bool,
        is_group: bool,
    },
    PreAccept { call_id: String, call_creator: Jid },
    Accept    { call_id: String, call_creator: Jid },
    Reject    { call_id: String, call_creator: Jid },
    Terminate {
        call_id: String,
        call_creator: Jid,
        duration: Option<u32>,
        audio_duration: Option<u32>,
    },
}
```

**Behavior:**

* The router automatically acks every `<call>` stanza. For `Offer` it additionally sends an `<receipt><offer/></receipt>` so the caller's UI advances past "ringing".
* `OfferNotice` is the server's fan-out to other group members when a group call starts. No offer-receipt is sent — only the generic ack.
* Use `action.call_id()` and `action.call_creator()` to access the common identifiers without matching every variant.

**Detecting group calls:**

```rust theme={null}
Event::IncomingCall(call) => match &call.action {
    CallAction::Offer { is_video, group_jid: Some(group), .. } => {
        // 1-to-many group call offer addressed directly to you.
        let kind = if *is_video { "video" } else { "voice" };
        println!("Incoming group {kind} call in {group} from {}", call.from);
    }
    CallAction::Offer { is_video, group_jid: None, .. } => {
        let kind = if *is_video { "video" } else { "voice" };
        println!("Incoming 1-on-1 {kind} call from {}", call.from);
    }
    CallAction::OfferNotice { is_group: true, is_video, call_creator, .. } => {
        // Group-call fan-out: another member started a call in a group you belong to.
        let kind = if *is_video { "video" } else { "voice" };
        println!("Group {kind} call started by {call_creator}");
    }
    CallAction::Terminate { call_id, duration, .. } => {
        println!("Call {call_id} ended after {:?}s", duration);
    }
    _ => {}
},
```

<Note>
  `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.
</Note>

## Notification Events

### DisappearingModeChanged

**Emitted:** When a contact changes their default disappearing messages setting. Sent by the server as a `<notification type="disappearing_mode">` stanza.

```rust theme={null}
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct DisappearingModeChanged {
    pub from: Jid,
    pub duration: u32,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub setting_timestamp: DateTime<Utc>,
}
```

**Fields:**

* `from` - The contact whose disappearing messages setting changed
* `duration` - New duration in seconds (`0` = disabled, `86400` = 24 hours, `604800` = 7 days, etc.)
* `setting_timestamp` - `DateTime<Utc>` indicating when the setting was changed (serialized as Unix timestamp in seconds)

<Note>
  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.
</Note>

**Example:**

```rust theme={null}
Event::DisappearingModeChanged(change) => {
    let status = if change.duration == 0 {
        "disabled".to_string()
    } else {
        format!("enabled ({}s)", change.duration)
    };
    println!("Contact {} changed disappearing messages: {}", change.from, status);
}
```

## Raw stanza events

### `RawNode`

**Emitted:** 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).

```rust theme={null}
Event::RawNode(Arc<OwnedNodeRef>)
```

This is a library extension with no WhatsApp Web equivalent. It gives you access to every raw decoded stanza before any routing or parsing occurs. The `OwnedNodeRef` uses yoke-based zero-copy decoding, so string and byte payloads are borrowed directly from the network buffer without allocation.

**Example:**

```rust theme={null}
client.set_raw_node_forwarding(true);

// In your event handler:
Event::RawNode(node) => {
    let node_ref = node.get();
    println!("Raw stanza tag: {}, attrs: {:?}", node_ref.tag, node_ref.attrs);
}
```

<Note>
  `RawNode` is skipped during serialization (`#[serde(skip)]`). Enable it only when debugging or building protocol-level tooling, as it dispatches for every incoming stanza.
</Note>

## Event handler patterns

### Bot builder pattern

```rust theme={null}
use whatsapp_rust::bot::Bot;
use wacore::types::events::{Event, InboundMessage};

let mut bot = Bot::builder()
    .with_backend(backend)
    .on_event(|event, client| async move {
        match &*event {
            Event::Messages(batch) => {
                // Handle each message in the batch
                for InboundMessage { message: msg, info, .. } in batch.iter() { /* … */ }
            }
            Event::Connected(_) => {
                // Handle connection
            }
            _ => {}
        }
    })
    .build()
    .await?;
```

### 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`](/api/bot#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`](/api/client#stats) — 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`](/api/bot#with_event_handler) (or `client.register_handler`) always run `handle_event` inline on the dispatch path — `EventDelivery` only governs the closure-based registrars.

<Note>
  `Ordered` trades throughput and drop-under-load for a stronger ordering guarantee. If dropped events are unacceptable, pair it with an [inbound durability hook](/advanced/inbound-durability) for at-least-once redelivery — the hook's buffering is independent of the delivery mailbox.
</Note>

### Multiple Handlers

```rust theme={null}
struct MessageHandler;
impl EventHandler for MessageHandler {
    fn handle_event(&self, event: Arc<Event>) {
        for InboundMessage { message: msg, info, .. } in event.messages() {
            // Handle messages
        }
    }
}

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

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

### ChannelEventHandler

`ChannelEventHandler` is a built-in event handler that forwards events to an `async_channel` for async consumption. It uses `async-channel` (runtime-agnostic) instead of Tokio channels, so it works with any async executor — including WASM targets.

Events are buffered in an unbounded channel, so events fired before the receiver starts listening are not lost.

```rust theme={null}
use wacore::types::events::{ChannelEventHandler, Event, InboundMessage};
use std::sync::Arc;

let (handler, event_rx) = ChannelEventHandler::new();
client.register_handler(handler);

// Wait for events asynchronously
while let Ok(event) = event_rx.recv().await {
    match &*event {
        Event::Connected(_) => {
            println!("Connected!");
            break;
        }
        Event::Messages(batch) => {
            for InboundMessage { info, .. } in batch.iter() {
                println!("Message from {}", info.source.sender);
            }
        }
        _ => {}
    }
}
```

This is particularly useful for:

* **Testing** — assert on specific event sequences without closures
* **Custom event loops** — process events in your own async task with full control over ordering
* **Runtime-agnostic code** — no dependency on Tokio's `mpsc` channels

<Note>
  `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.
</Note>

### Custom async event handlers

For custom channel-based patterns, you can implement `EventHandler` directly:

```rust theme={null}
use tokio::sync::mpsc;
use std::sync::Arc;

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

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

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

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

// Process events asynchronously
tokio::spawn(async move {
    while let Some(event) = rx.recv().await {
        // event is Arc<Event> — no cloning needed
    }
});
```

Since events are dispatched as `Arc<Event>`, you can forward them to channels without any cloning overhead.

## Performance Optimization

### `LazyHistorySync`

**Purpose:** Avoid parsing large protobuf blobs unless needed. The compressed payload is stored as reference-counted `Bytes`, and full protobuf decoding only happens if your code calls `get()`. See the [HistorySync event reference](#lazyhistorysync) for the complete API.

```rust theme={null}
pub struct LazyHistorySync {
    compressed: Bytes,         // Original zlib payload (reference-counted, ~10× smaller than decompressed)
    decompressed_size: usize,  // Exact inflated byte count
    sync_type: i32,            // Cheap metadata
    chunk_order: Option<u32>,
    progress: Option<u32>,
    peer_data_request_session_id: Option<String>,   // Set only on ON_DEMAND syncs
    parsed: OnceLock<Option<Box<wa::HistorySync>>>, // Decoded on first access
}
```

**Cloning:** `Clone` is a refcount bump on the compressed buffer. No decode cache is carried over — each clone re-inflates independently on demand.

**Usage:**

```rust theme={null}
Event::HistorySync(lazy_sync) => {
    // Metadata is always available without decoding
    let sync_type = lazy_sync.sync_type();
    let progress = lazy_sync.progress();

    // Full decode (cached after first call)
    if let Some(history) = lazy_sync.get() {
        for conv in &history.conversations {
            process_conversation(conv);
        }
    }

    // Streaming: memory-bounded, one conversation at a time
    let mut stream = lazy_sync.stream();
    while let Some(conv) = stream.next_conversation()? { /* … */ }

    // One-shot inflate for custom parsing
    let bytes = lazy_sync.decompress()?;
}
```

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

```rust theme={null}
.on_event(|event, client| async move {
    // Only handle events you care about
    for InboundMessage { message: msg, info, .. } in event.messages() {
        // These are independent filters, not mutually exclusive cases —
        // a non-self-sent group message enters both blocks.
        if info.source.is_group {
            // Only handle group messages
        }
        if !info.source.is_from_me {
            // Only handle messages from others
        }
    }
})
```

### Error Handling

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

async fn handle_event(event: &Event, client: Arc<Client>) -> Result<()> {
    for InboundMessage { message: msg, info, .. } in event.messages() {
        process_message(msg, info, client.clone()).await?
    }
    Ok(())
}
```

### Spawning Tasks

```rust theme={null}
.on_event(|event, client| async move {
    if matches!(&*event, Event::Messages(_)) {
        let client = client.clone();
        let event = event.clone(); // Arc clone — O(1)

        tokio::spawn(async move {
            for InboundMessage { message: msg, info, .. } in event.messages() {
                process_message(msg, info, &client).await;
            }
        });
    }
})
```

## Related Sections

<CardGroup cols={2}>
  <Card title="Architecture" icon="sitemap" href="/concepts/architecture">
    Understand the event bus system
  </Card>

  <Card title="Authentication" icon="key" href="/concepts/authentication">
    Learn about pairing events
  </Card>

  <Card title="Sending messages" icon="message" href="/guides/sending-messages">
    Sending and receiving messages
  </Card>

  <Card title="Client API" icon="code" href="/api/client">
    Complete client API reference
  </Card>
</CardGroup>
