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

# June 9, 2026 — Events & RSVP, quiz polls, disappearing timers, and media builders

> Create events and collect RSVPs, send quiz polls, toggle 1:1 disappearing timers, save contacts, clear chats, mute channels, and build media messages from an upload result — plus breaking changes to download, groups, and receipt APIs.

This entry catches the documentation up with the remaining early-June API work that landed after the [June 8 release](/changelog/2026-06-08).

## New features

**Events with RSVP (`client.events()`)**

A new [`Events`](/api/events) feature creates WhatsApp event messages and collects encrypted RSVPs.

* `client.events().create(to, params)` sends an event and returns `(SendResult, message_secret)`. Like polls, the event carries a per-message secret; **store it** — responders' RSVPs are encrypted against it.
* `client.events().respond(chat, event_id, creator, secret, response, extra_guests)` sends an encrypted `Going` / `NotGoing` / `Maybe` reply.
* `wacore::event::{encrypt_event_response_with_secret, decrypt_event_response_with_secret}` decrypt inbound RSVPs.

```rust theme={null}
use whatsapp_rust::features::{EventCreationParams, EventResponseType};

// Create an event — keep the returned secret to read RSVPs later.
let (sent, secret) = client.events().create(&chat, EventCreationParams {
    name: "Team offsite".into(),
    start_time: Some(1_760_000_000),
    ..Default::default()
}).await?;

// RSVP to an event you received (event id, creator, and secret come from that event).
client.events()
    .respond(&chat, &event_id, &creator, &event_secret, EventResponseType::Going, None)
    .await?;
```

See the [Events API reference](/api/events) for the full field list.

**Quiz polls (`Polls::create_quiz`)**

`client.polls().create_quiz(to, name, options, correct_index)` sends a single-select quiz poll with one correct answer (`correct_index` is the 0-based index into `options`). It returns `(SendResult, message_secret)` just like [`create`](/api/polls#create), and votes decrypt the same way. See [`create_quiz`](/api/polls#create_quiz).

**1:1 disappearing-message timer (`Client::set_chat_disappearing_timer`)**

Turn disappearing messages on or off for a direct chat with `client.set_chat_disappearing_timer(chat, duration_secs)` (`0` disables). It sends an `EPHEMERAL_SETTING` protocol message and is 1:1-only — use [`Groups::set_ephemeral`](/api/groups#set_ephemeral) for groups. See [`set_chat_disappearing_timer`](/api/send#set_chat_disappearing_timer).

**Save or rename contacts (`ChatActions::save_contact`)**

`client.chat_actions().save_contact(jid, full_name, first_name, save_on_primary_addressbook)` writes a `contact` app-state mutation that syncs the name to your other linked devices. The contact id must be a bare phone-number JID; LIDs and device-specific JIDs are rejected. See [`save_contact`](/api/chat-actions#save_contact).

**Clear a chat's messages (`ChatActions::clear_chat`)**

`client.chat_actions().clear_chat(jid, delete_starred, delete_media)` clears a chat's messages while keeping the chat, syncing across devices. Inbound clears from a linked device arrive as the new [`Event::ClearChatUpdate`](/concepts/events#clearchatupdate). See [`clear_chat`](/api/chat-actions#clear_chat).

**Mute a contact's status updates (`ChatActions::set_user_status_mute`)**

`client.chat_actions().set_user_status_mute(jid, muted)` mutes/unmutes a contact, group, or channel's status updates across devices. Inbound changes arrive as [`Event::UserStatusMuteUpdate`](/concepts/events#userstatusmuteupdate). See [`set_user_status_mute`](/api/chat-actions#set_user_status_mute).

**Mute newsletter notifications (`Newsletter::set_follower_mute` / `set_admin_mute`)**

Silence a channel's follower- or admin-activity notifications via MEX:

```rust theme={null}
client.newsletter().set_follower_mute(&channel, true).await?;  // mute
client.newsletter().set_admin_mute(&channel, false).await?;    // unmute
```

See [`set_follower_mute`](/api/newsletter#set_follower_mute).

**Message-secret encrypted edits (`Client::edit_message_encrypted`)**

`client.edit_message_encrypted(to, original_id, new_content)` edits a message via the `secret_encrypted_message` (`MESSAGE_EDIT`) path instead of the plaintext `protocolMessage` edit. This is the form Community Announcement Groups require and what WhatsApp Web sends when `message_edit_to_message_secret_sender_enabled` is on. Newsletters are rejected — use [`Newsletter::edit_message`](/api/newsletter#edit_message). See [`edit_message_encrypted`](/api/send#edit_message_encrypted).

**High-level media message builders (`whatsapp_rust::media`)**

The new `media` module turns an [`UploadResponse`](/api/upload) into a ready-to-send `wa::Message`, so you no longer hand-assemble the CDN/crypto fields (url, direct\_path, media\_key, the two SHA-256 hashes, file\_length, media\_key\_timestamp, streaming\_sidecar):

```rust theme={null}
use whatsapp_rust::media::{self, ImageOptions};

let upload = client.upload(bytes, MediaType::Image, Default::default()).await?;
let msg = media::image_message(upload, ImageOptions { caption: Some("hi".into()), ..Default::default() });
client.send_message(to, msg).await?;
```

`image_message`, `video_message`, `document_message`, and `audio_message` each take a typed options struct with sensible MIME defaults. See [media message builders](/api/upload#high-level-message-builders).

**Device list from `get_user_info`**

[`UserInfo`](/api/contacts#get_user_info) now carries `devices: Vec<u16>` — the device IDs from the `<devices version="2">` sublist the same usync query returns (device `0` is the primary). Empty when the server omits it.

**`offline` flag on the Receipt event**

[`Event::Receipt`](/concepts/events#receipt) gains `offline: bool`, set when the receipt was drained from the server's offline queue on reconnect rather than delivered live. See the [receipt event structure](/api/receipt#receipt-event-structure).

**Read-receipt status parity**

Outgoing read receipts now match WhatsApp Web: newsletter reads send `read-self`, and status reads carry `context="status"` plus `peer_participant_pn` (the resolved LID→PN) for a LID author. No API change — `mark_as_read` handles it.

**View-once sends emit `<meta view_once="true"/>`**

View-once image/video/voice sends now carry the `view_once` meta attribute, matching WhatsApp Web so recipients render the one-time bubble. Members tagged for [member labels](/api/groups) also emit `appdata` / `tag_reason` meta attributes on group sends.

## Breaking changes

**`Client::custom_enc_handlers` field type changed ([#792](https://github.com/oxidezap/whatsapp-rust/pull/792))**

The `pub custom_enc_handlers` field type changed from `Arc<async_lock::RwLock<HashMap<String, Arc<dyn EncHandler>>>>` to `std::sync::OnceLock<HashMap<String, Arc<dyn EncHandler>>>`. Migration depends on how the field was used: code that read handlers via `.read().await` should switch to `.get()`; code that inserted handlers via `.write().await.insert(...)` must move those registrations to `BotBuilder::with_enc_handler()` before `build()` — runtime insertion is no longer possible.

**`get_participating` returns `HashMap<Jid, GroupMetadata>`**

[`Groups::get_participating`](/api/groups#get_participating) is now keyed by `Jid` instead of `String`. Bind the key as a `Jid`; call `.to_string()` if you need the old string form.

**`download_from_params` takes a `DownloadParams` struct**

[`download_from_params`](/api/download#download_from_params) and `download_from_params_to_writer` now take a single `&DownloadParams` instead of six positional arguments. Build one with `DownloadParams::encrypted(direct_path, media_key, file_sha256, file_enc_sha256, file_length, media_type)`. `DownloadParams` implements `Downloadable`, so it also works directly with `client.download(&params)`.

**`download_to_file` removed**

The dead, fully-buffering `download_to_file` is gone. Use [`download_to_writer`](/api/download#download_to_writer) (streaming, constant-memory) instead — it accepts any `File`/`BufWriter` and returns the writer seeked to 0.

**`set_description` takes `prev: Option<&str>`**

[`Groups::set_description`](/api/groups#set_description) now borrows the previous description id (`Option<&str>`) instead of taking an owned `Option<String>`. Pass `Some("PREV_ID")` or `None`.

**`mark_as_read` / `mark_as_played` take `&[&str]`**

[`mark_as_read`](/api/receipt#mark_as_read) and [`mark_as_played`](/api/receipt#mark_as_played) now take `message_ids: &[&str]` instead of `Vec<String>`, avoiding per-call allocations. Update call sites from `vec!["ID".to_string()]` to `&["ID"]`.

## Fixes & hardening

**App-state anti-tampering parity**: snapshot application now requires a valid snapshot MAC (no silent skip), rejects duplicate indices within a patch, and guards against version rollback — matching WhatsApp Web's validation so a malicious or corrupted server snapshot can't desync or tamper with state.

**Poll wire shape**: poll creation now matches WhatsApp Web (`pollContentType=TEXT`, no vote metadata on the creation message), improving interop with other clients.

**Companion device-identity (ADV) validation**: fetched pre-key bundles are validated against the companion device-identity, rejecting bundles whose identity doesn't check out before a session is built.

**ADV account-key fallback from store** ([#790](https://github.com/oxidezap/whatsapp-rust/pull/790)): Fixed a regression where contacts' companion devices (WhatsApp Web / Desktop) stopped receiving messages after the ADV validation above was introduced. The server legitimately omits `account_signature_key` from a companion's `<device-identity>` because the client already holds that key as the contact's primary (device 0) identity in the Signal store — the prior validation rejected those bundles outright. The fix mirrors WA Web's `validateADVwithIdentityKey` (`e.accountSignatureKey || t`): the in-blob key is preferred; when absent, the contact's stored primary identity is loaded via `Client::load_account_identity` and used as the fallback. The validation result is now the three-state `wacore::adv::AdvValidation` (`Valid` / `Invalid` / `NoAccountKey`) instead of a boolean — an unverifiable-for-lack-of-key bundle is logged and kept rather than dropped, preserving the existing "device-identity absent" behaviour.

**IQ errors keep `error_type` + `backoff`**: failed IQ responses now surface the server's `error_type` and server-directed `backoff` (seconds) instead of dropping them, so callers can honour retry hints.

**wasm**: the cache backend is now target-aware so the `moka-cache` default no longer breaks `wasm32` builds.

**`EncHandler` wasm portability** ([#793](https://github.com/oxidezap/whatsapp-rust/pull/793)): `EncHandler` now uses `MaybeSendSync` as its supertrait and gates `async_trait` with `?Send` on `wasm32`, matching the convention of `EventHandler` and `SendContextResolver`. Custom enc handlers that capture `!Send` JS handles now compile on the wasm32 port. No change on native — the blanket `MaybeSendSync` impl keeps `Arc<dyn EncHandler>` `Send + Sync`.

**Networking traits wasm portability** ([#795](https://github.com/oxidezap/whatsapp-rust/pull/795)): `Transport`, `TransportFactory`, and `HttpClient` now use `MaybeSendSync` as their supertrait, matching the convention established by `EventHandler`, `SendContextResolver`, and `EncHandler`. Custom transport and HTTP implementations that hold `!Send` JS handles (e.g. a browser WebSocket or `fetch` backing) now compile on the `wasm32` port. No change on native — the blanket `MaybeSendSync` impl keeps `Arc<dyn Transport>` and `Arc<dyn HttpClient>` `Send + Sync` there.
