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

# Client

> Core client for WhatsApp connectivity and protocol operations

The `Client` struct is the core of whatsapp-rust, managing connections, encryption, state, and all protocol-level operations.

## Overview

The Client handles:

* WebSocket connection lifecycle and automatic reconnection
* Noise Protocol handshake and encryption
* Signal Protocol E2E encryption for messages
* App state synchronization
* Device state persistence
* Event dispatching

<Note>
  Most users should use the [`Bot`](/api/bot) builder instead of creating a Client directly. The Bot provides a simplified API with sensible defaults.
</Note>

## Creating a Client

```rust theme={null}
use whatsapp_rust::Client;
use whatsapp_rust::TokioRuntime;
use std::sync::Arc;

let (client, sync_receiver) = Client::new(
    Arc::new(TokioRuntime),
    persistence_manager,
    transport_factory,
    http_client,
    None, // version override
).await;
```

<ParamField path="runtime" type="Arc<dyn Runtime>" required>
  Async runtime for spawning tasks, sleeping, and blocking operations
</ParamField>

<ParamField path="persistence_manager" type="Arc<PersistenceManager>" required>
  State manager for device credentials, sessions, and app state
</ParamField>

<ParamField path="transport_factory" type="Arc<dyn TransportFactory>" required>
  Factory for creating WebSocket connections
</ParamField>

<ParamField path="http_client" type="Arc<dyn HttpClient>" required>
  HTTP client for media operations and version fetching
</ParamField>

<ParamField path="override_version" type="Option<(u32, u32, u32)>">
  Optional WhatsApp version override (primary, secondary, tertiary)
</ParamField>

<ParamField path="Returns" type="(Arc<Client>, Receiver<MajorSyncTask>)">
  Returns the client Arc and a receiver for history/app state sync tasks
</ParamField>

### Creating with custom cache configuration

```rust theme={null}
use whatsapp_rust::{Client, CacheConfig, CacheEntryConfig};
use whatsapp_rust::TokioRuntime;
use std::time::Duration;

let cache_config = CacheConfig {
    group_cache: CacheEntryConfig::new(None, 500), // No TTL
    ..Default::default()
};

let (client, sync_receiver) = Client::new_with_cache_config(
    Arc::new(TokioRuntime),
    persistence_manager,
    transport_factory,
    http_client,
    None,
    cache_config,
).await;
```

See [Bot - Cache Configuration Reference](/api/bot#cache-configuration-reference) for available cache options.

***

## Connection Management

### run

```rust theme={null}
pub async fn run(self: &Arc<Self>)
```

Main event loop that manages connection lifecycle with automatic reconnection.

Runs indefinitely until:

* `disconnect()` or `logout()` is called
* Auto-reconnect is disabled and connection fails
* Client receives a fatal stream error (401 unauthorized, 409 conflict, or 516 device removed)

**Example:**

```rust theme={null}
let client_clone = client.clone();
tokio::spawn(async move {
    client_clone.run().await;
});
```

### connect

```rust theme={null}
pub async fn connect(self: &Arc<Self>) -> Result<(), ConnectError>
```

Establishes WebSocket connection and performs Noise Protocol handshake. Both the transport connection and the version fetch run in parallel under a **20-second timeout** (`TRANSPORT_CONNECT_TIMEOUT`), matching WhatsApp Web's MQTT and DGW connect timeout defaults. Without this, a dead network would block on the OS TCP SYN timeout (\~60-75s).

The Noise handshake response also has a separate **20-second timeout** (`NOISE_HANDSHAKE_RESPONSE_TIMEOUT`).

<Note>
  As of PR #1090, `connect()` returns [`ConnectError`](/api/errors#connecterror) instead of `anyhow::Error`. `ClientError::AlreadyConnected` was removed — that case is now `ConnectError::AlreadyConnected`.
</Note>

**Errors (`ConnectError`):**

* `AlreadyConnected` - a connection is already up, or another `connect()` attempt is already in flight
* `NotActivated` - construction never activated (only reachable with the `client-lifecycle` feature)
* `Timeout { stage, timeout }` - the version fetch or the transport open ran out of time, independently, each under the same 20s budget (`ConnectStage::VersionFetch`/`Transport`). `connect()` itself never reports `ConnectStage::Socket` or `Ready` — those are only produced by `wait_for_socket()`/`wait_for_connected()` below.
* `Version(anyhow::Error)` / `Transport(anyhow::Error)` - app version resolution or transport open failed outright
* `Handshake(HandshakeError)` - the Noise handshake failed after the transport was up; check `HandshakeError::is_transient()` to decide whether a retry is worthwhile

See [`ConnectError`](/api/errors#connecterror) for the full variant reference.

### logout

```rust theme={null}
pub async fn logout(self: &Arc<Self>)
```

Deregisters this companion device from WhatsApp and disconnects. This sends a device removal IQ to the server, disables auto-reconnect, disconnects the transport, and emits a `LoggedOut` event.

<Note>
  As of PR #1090, `logout()` is infallible (`()`, not `Result<()>`). The deregistration IQ is best-effort — it cannot be sent at all while offline — and the local teardown runs either way, so there was nothing for a caller to branch on. A failed IQ is logged at `warn`.
</Note>

<Warning>
  This does **not** wipe stored keys or credentials. To fully clear session data, delete the storage backend after calling `logout()`.
</Warning>

**Example:**

```rust theme={null}
// Logout and clean up
client.logout().await;

// Optionally delete stored credentials
// std::fs::remove_dir_all("./whatsapp_data")?;
```

**Behavior:**

1. Disables auto-reconnect
2. Sends a `RemoveCompanionDeviceSpec` IQ to deregister the companion device (if connected); a failure here is logged, not returned
3. Disconnects the transport
4. Emits `Event::LoggedOut` with `reason: ConnectFailureReason::LoggedOut`

### disconnect

```rust theme={null}
pub async fn disconnect(self: &Arc<Self>)
```

Disconnects gracefully and disables auto-reconnect. It signals shutdown (sets the expected-disconnect and stop flags, fires the shutdown notifiers), flushes pending outbound receipts and device state, closes the transport, and then runs `cleanup_connection_state()`. That cleanup resets all connection-scoped state — invalidating per-chat message queues so stale workers exit, flushing then clearing the signal cache (so pending sender-key/identity writes are persisted, not lost), draining pending IQ waiters, and resetting offline sync state. The same `cleanup_connection_state()` also runs from `run()` after the message loop exits; it is idempotent and race-tolerant, so whichever path wins, connection-scoped state is reset once in effect. See [disconnect cleanup](/concepts/architecture#disconnect-cleanup) for the full list of resources cleaned up.

### signal\_shutdown\_sync

```rust theme={null}
pub fn signal_shutdown_sync(&self)
```

Synchronous, flag-only variant of `disconnect()` for places where you can't `await`. It flips `expected_disconnect`, clears `is_running`, fires the terminal `shutdown_notifier`, and notifies the per-connection shutdown so spawned tasks exit on their next poll. It does **not** flush, close the transport, or touch persistence — prefer `disconnect()` whenever you can await. Intended for `Drop` impls on FFI wrappers (e.g. the WASM client) that need to release the runtime without blocking.

### reconnect

```rust theme={null}
pub async fn reconnect(self: &Arc<Self>)
```

Drops the current connection and triggers auto-reconnect with a deliberate \~5s offline window (Fibonacci backoff step 4). The run loop stays active.

Use this for:

* Handling network changes (e.g., Wi-Fi to cellular)
* Forcing a fresh server session
* Testing offline message delivery

**Example:**

```rust theme={null}
// Force reconnection with backoff delay
client.reconnect().await;

// Wait for the new connection to be ready
client.wait_for_connected(Duration::from_secs(30)).await?;
```

### reconnect\_immediately

```rust theme={null}
pub async fn reconnect_immediately(self: &Arc<Self>)
```

Drops the current connection and reconnects immediately with no delay. Unlike `reconnect()`, this sets the expected disconnect flag so the run loop skips the backoff delay.

**Example:**

```rust theme={null}
// Force immediate reconnection (no backoff)
client.reconnect_immediately().await;
```

### wait\_for\_socket

```rust theme={null}
pub async fn wait_for_socket(
    &self,
    timeout: std::time::Duration
) -> Result<(), ConnectError>
```

Waits for the Noise socket to be ready (before login). Useful for pair code flows.

<ParamField path="timeout" type="Duration" required>
  Maximum time to wait
</ParamField>

<ParamField path="Returns" type="Result<(), ConnectError>">
  Ok if socket ready, `ConnectError::Timeout { stage: ConnectStage::Socket, timeout }` on timeout
</ParamField>

### wait\_for\_connected

```rust theme={null}
pub async fn wait_for_connected(
    &self,
    timeout: std::time::Duration
) -> Result<(), ConnectError>
```

Waits for full connection and authentication to complete, including offline sync. Post-login tasks (presence, background queries) are gated behind offline sync completion, which resolves either when the server sends the end marker, all expected items arrive, or the 60-second timeout fires.

<ParamField path="timeout" type="Duration" required>
  Maximum time to wait
</ParamField>

<ParamField path="Returns" type="Result<(), ConnectError>">
  Ok once fully ready, `ConnectError::Timeout { stage: ConnectStage::Ready, timeout }` on timeout
</ParamField>

<Note>
  As of PR #1090, both methods return [`ConnectError`](/api/errors#connecterror) instead of `anyhow::Error`.
</Note>

### pair\_with\_code

```rust theme={null}
pub async fn pair_with_code(
    self: &Arc<Self>,
    options: PairCodeOptions,
) -> Result<String, PairError>
```

Initiates pair code authentication as an alternative to QR code pairing. The returned 8-character code should be displayed to the user, who enters it on their phone under **WhatsApp > Linked Devices > Link a Device > Link with phone number instead**.

This can run concurrently with QR code pairing — whichever completes first wins.

<ParamField path="options" type="PairCodeOptions" required>
  Configuration for pair code authentication:

  * `phone_number` — Phone number in international format (e.g., `"15551234567"`)
  * `show_push_notification` — Whether to show a push notification on the phone (default: `true`)
  * `custom_code` — Optional custom 8-character code using Crockford Base32 alphabet
  * `platform_id` — `Option<CompanionWebClientType>` override for `<companion_platform_id>`. `None` derives the wire id from `Device.device_props.platform_type` (typically `Chrome`; Android `PlatformType`s also map to `Chrome` because the server requires attestation for the Android letter codes). The matching `<companion_platform_display>` is always derived; web variants emit `<Browser> (<OS>)`, and explicit `AndroidPhone`/`AndroidTablet`/`AndroidAmbiguous` overrides emit `Android (<OS>)`.
</ParamField>

<ResponseField name="code" type="String">
  The 8-character pairing code to display to the user
</ResponseField>

**Errors (`PairError`):**

`PairError::PairCode(PairCodeError)` covers validation and crypto failures; `PairError::RequestFailed(IqError)` covers the IQ transport.

| Variant                                                              | Cause                                                                            |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `PairCode(PhoneNumberRequired)`                                      | Empty phone number                                                               |
| `PairCode(PhoneNumberTooShort)`                                      | Fewer than 7 digits                                                              |
| `PairCode(PhoneNumberNotInternational)`                              | Starts with `0` (not international format)                                       |
| `PairCode(InvalidCustomCode)`                                        | Custom code is not 8 valid Crockford Base32 characters                           |
| `PairCode(InvalidPrimaryEphemeralKey)` / `InvalidPrimaryIdentityKey` | Peer key parsing failed (typed `CurveError` source)                              |
| `PairCode(EphemeralKeyAgreement)` / `IdentityKeyAgreement`           | Diffie–Hellman failed (typed `CurveError` source)                                |
| `PairCode(AdvSecretKeyDerivation)` / `BundleKeyDerivation`           | HKDF expand failed                                                               |
| `PairCode(BundleAead)`                                               | AES-GCM encryption of the key bundle failed (typed `CryptoProviderError` source) |
| `PairCode(MissingPairingRef)`                                        | Server response missing pairing ref                                              |
| `RequestFailed`                                                      | Server IQ request failed (typed `IqError` source)                                |

**Example:**

```rust theme={null}
use whatsapp_rust::pair_code::PairCodeOptions;

let options = PairCodeOptions {
    phone_number: "15551234567".to_string(),
    show_push_notification: true,
    custom_code: None, // Generate random code
    ..Default::default()
};

let code = client.pair_with_code(options).await?;
println!("Enter this code on your phone: {}", code);
```

***

### set\_passkey\_authenticator

```rust theme={null}
pub async fn set_passkey_authenticator(&self, authenticator: Arc<dyn PasskeyAuthenticator>)
```

Registers a [`PasskeyAuthenticator`](/concepts/authentication#passkeyauthenticator-trait) for [passkey (SHORTCAKE\_PASSKEY) linking](/concepts/authentication#passkey-linking-shortcake_passkey). Once set, the client auto-drives the flow end-to-end: it calls `get_assertion` when the server requests one, sends the response, and auto-confirms a re-link whose `skip_handoff_ux` is `true`. Leave it unset to drive every step manually from the `Event::PairPasskey*` events.

<ParamField path="authenticator" type="Arc<dyn PasskeyAuthenticator>" required>
  Produces a WebAuthn assertion for the server's challenge — typically backed by Android Credential Manager, hybrid/caBLE, or a software vault. Use `whatsapp_rust::passkey::CallbackAuthenticator::new(f)` to wrap an async closure.
</ParamField>

### send\_passkey\_response

```rust theme={null}
pub async fn send_passkey_response(&self, assertion: Assertion) -> Result<(), PasskeyError>
```

Sends the WebAuthn assertion as `<passkey_prologue>` and opens the ephemeral-identity handshake. Call after an [`Event::PairPasskeyRequest`](/concepts/events#pairpasskeyrequest). Returns `PasskeyError::Flow` if a passkey open is already in progress.

### send\_passkey\_confirmation

```rust theme={null}
pub async fn send_passkey_confirmation(&self) -> Result<(), PasskeyError>
```

Finishes the link: encrypts the rotated ADV secret under the derived key, sends `<encrypted_pairing_request>`, and commits the secret rotation. For a fresh link, call this only after the user confirms the code from an [`Event::PairPasskeyConfirmation`](/concepts/events#pairpasskeyconfirmation) — a proven re-link (`skip_handoff_ux: true`) can call it immediately, and the automatic driver does so itself. Returns `PasskeyError::Flow` if called before the confirmation stage or without an active session.

**Errors (`PasskeyError`):**

| Variant                  | Cause                                                              |
| ------------------------ | ------------------------------------------------------------------ |
| `NoCredential`           | No passkey registered for this account on the authenticator        |
| `Cancelled`              | User cancelled or the WebAuthn ceremony timed out                  |
| `InvalidOptions(String)` | Malformed `PublicKeyCredentialRequestOptions` JSON from the server |
| `Backend(String)`        | Authenticator backend error                                        |
| `Flow(String)`           | Protocol/state error (wrong stage, no active session, IQ failure)  |

***

## Connection State

### is\_connected

```rust theme={null}
pub fn is_connected(&self) -> bool
```

Returns `true` if the Noise socket is established. This method uses an internal `AtomicBool` flag (with `Acquire` ordering) instead of probing the noise socket mutex, making it lock-free and immune to false negatives under mutex contention.

<Note>
  Prior to this design, connection checks used `try_lock()` on the noise socket mutex. Under contention (e.g., during frame encryption), `try_lock()` would fail and incorrectly report the client as disconnected — silently dropping receipt acks. The `AtomicBool` approach eliminates this race condition entirely.
</Note>

### is\_logged\_in

```rust theme={null}
pub fn is_logged_in(&self) -> bool
```

Returns `true` if authenticated with WhatsApp servers.

***

## Auto-Reconnection

The client includes automatic reconnection handling with Fibonacci backoff.

### How it works

1. **On disconnect**: The client detects unexpected disconnections and automatically attempts to reconnect
2. **Fibonacci backoff**: Each failed attempt increases the delay following the Fibonacci sequence (1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s...) with a maximum of 900 seconds (15 minutes) and +/-10% jitter
3. **Expected disconnects**: Protocol-expected disconnects (e.g., 515 stream error after pairing) trigger immediate reconnection without backoff
4. **Keepalive monitoring**: A keepalive loop sends periodic pings (every 15-30s) and forces reconnection if the socket appears dead (no data received for 20s after a send)

### Controlling auto-reconnect

```rust theme={null}
// Disable auto-reconnect (default: enabled)
client.enable_auto_reconnect.store(false, Ordering::Relaxed);

// Check current state
let enabled = client.enable_auto_reconnect.load(Ordering::Relaxed);
```

The consecutive-failure counter used for Fibonacci backoff is internal; read it via [`client.stats().reconnect_errors`](#diagnostics) instead of a public field.

### Stream error handling

The client handles specific `<stream:error>` codes from the WhatsApp server:

| Stream error code | Meaning                                  | Event emitted               | Auto-reconnect                        |
| ----------------- | ---------------------------------------- | --------------------------- | ------------------------------------- |
| **401**           | Session invalidated (unauthorized)       | `LoggedOut`                 | Disabled — must re-pair               |
| **409**           | Another client connected (conflict)      | `StreamReplaced`            | Disabled — prevents displacement loop |
| **429**           | Rate limited (too many connections)      | None (emits `Disconnected`) | Yes, with extended backoff (+5 steps) |
| **503**           | Service unavailable                      | None                        | Yes, normal backoff                   |
| **515**           | Expected disconnect (e.g., post-pairing) | None                        | Yes, immediate (no backoff)           |
| **516**           | Device removed                           | `LoggedOut`                 | Disabled — must re-pair               |
| Unknown           | Unrecognized code                        | `StreamError`               | Disabled                              |

<Warning>
  When you receive a `LoggedOut` or `StreamReplaced` event, auto-reconnect is permanently disabled for that session. You must create a new client and re-pair to continue.
</Warning>

### Rate limiting (429)

When the server returns a 429 stream error, the client bumps the internal backoff counter by 5 Fibonacci steps before reconnecting. This means the reconnection delay jumps significantly (e.g., from \~1s to \~13s on the first rate limit) to respect the server's throttling.

### General reconnection behavior

| Scenario                         | Behavior                                  |
| -------------------------------- | ----------------------------------------- |
| Unexpected disconnect            | Reconnect with Fibonacci backoff          |
| 515 stream error (after pairing) | Immediate reconnect                       |
| Keepalive dead socket (20s)      | Force disconnect and reconnect            |
| `disconnect()` called            | No reconnect attempt                      |
| `logout()` called                | No reconnect attempt, device deregistered |
| Auto-reconnect disabled          | No reconnect attempt                      |

***

## Messaging

### send\_message

```rust theme={null}
pub async fn send_message(
    &self,
    to: Jid,
    message: wa::Message
) -> Result<SendResult, SendError>
```

Sends an encrypted message to a chat.

<ParamField path="to" type="Jid" required>
  Recipient JID ([user@s.whatsapp.net](mailto:user@s.whatsapp.net) or [group@g.us](mailto:group@g.us))
</ParamField>

<ParamField path="message" type="wa::Message" required>
  Protobuf message content
</ParamField>

<ParamField path="Returns" type="Result<SendResult, SendError>">
  A [`SendResult`](/api/send#sendresult) containing the `message_id` and destination `to` JID
</ParamField>

**Example:**

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

let message = wa::Message {
    conversation: Some("Hello!".to_string()),
    ..Default::default()
};

let result = client.send_message(jid, message).await?;
println!("Sent message ID: {}", result.message_id);
```

### send\_message\_with\_options

```rust theme={null}
pub async fn send_message_with_options(
    &self,
    to: Jid,
    message: wa::Message,
    options: SendOptions
) -> Result<SendResult, SendError>
```

Sends a message with advanced options like a custom message ID, extra stanza nodes, or ephemeral expiration.

<ParamField path="options" type="SendOptions" required>
  Configuration for message sending behavior. Supports `message_id` (override the auto-generated ID), `extra_stanza_nodes` (custom XML nodes on the stanza), and `ephemeral_expiration` (disappearing message duration in seconds).
</ParamField>

See [Send API reference](/api/send#sendoptions) for available options.

### edit\_message

```rust theme={null}
pub async fn edit_message(
    &self,
    to: Jid,
    original_id: impl Into<String>,
    new_content: wa::Message
) -> Result<String, SendError>
```

Edits a previously sent message.

<ParamField path="original_id" type="String" required>
  ID of the message to edit
</ParamField>

<ParamField path="new_content" type="wa::Message" required>
  New message content
</ParamField>

### edit\_message\_with\_options

```rust theme={null}
pub async fn edit_message_with_options(
    &self,
    to: impl Into<Jid>,
    original_id: impl Into<String>,
    new_content: wa::Message,
    options: EditOptions
) -> Result<String, SendError>
```

Edit-path counterpart of `send_message_with_options`. Accepts an [`EditOptions`](/api/send#editoptions) (built via `EditOptions::default().with_stanza_id(id)`) to pin the outer stanza id to an existing message's id (best-effort — server/client dependent) instead of the fresh id `edit_message` generates.

See [Send API reference](/api/send#edit_message_with_options) for the full `EditOptions` type and its side-effect notes.

### revoke\_message

```rust theme={null}
pub async fn revoke_message(
    &self,
    to: Jid,
    message_id: impl Into<String>,
    revoke_type: RevokeType
) -> Result<(), SendError>
```

Deletes a message. Use `Sender` to revoke your own message, or `Admin` to revoke another user's message as group admin.

<ParamField path="revoke_type" type="RevokeType" required>
  `RevokeType::Sender` (delete your own message) or `RevokeType::Admin { original_sender: Jid }` (admin revoke in groups)
</ParamField>

***

## Feature APIs

The Client provides namespaced access to feature-specific operations:

### blocking

```rust theme={null}
pub fn blocking(&self) -> Blocking<'_>
```

Access blocking operations.

**Methods:**

* `block(jid: &Jid)` - Block a contact
* `unblock(jid: &Jid)` - Unblock a contact
* `get_blocklist()` - Get all blocked contacts
* `is_blocked(jid: &Jid)` - Check if contact is blocked

**Example:**

```rust theme={null}
client.blocking().block(&jid).await?;
let blocked = client.blocking().get_blocklist().await?;
```

### groups

```rust theme={null}
pub fn groups(&self) -> Groups<'_>
```

Access group management operations.

**Methods:**

* `query_info(jid: &Jid)` - Get cached group info
* `get_metadata(jid: &Jid)` - Fetch group metadata from server
* `get_participating()` - List all groups you're in
* `create_group(options: GroupCreateOptions)` - Create a new group
* `set_subject(jid: &Jid, subject: GroupSubject)` - Change group name
* `set_description(jid: &Jid, desc: Option<GroupDescription>, prev: PreviousDescription<'_>)` - Change description; `prev` is an optimistic-concurrency token ([`PreviousDescription::Resolve`](/api/groups#previousdescription) reads the current one for you)
* `leave(jid: &Jid)` - Leave a group
* `add_participants(jid: &Jid, participants: &[Jid])` - Add members
* `remove_participants(jid: &Jid, participants: &[Jid])` - Remove members
* `promote_participants(jid: &Jid, participants: &[Jid])` - Make members admins
* `demote_participants(jid: &Jid, participants: &[Jid])` - Remove admin status
* `get_invite_link(jid: &Jid, reset: bool)` - Get/reset invite link
* `join_with_invite_code(code: &str)` - Join a group via invite code or URL
* `join_with_invite_v4(group_jid, code, expiration, admin_jid)` - Accept a V4 invite message
* `get_invite_info(code: &str)` - Preview group metadata from invite code
* `set_locked(jid: &Jid, locked: bool)` - Lock/unlock group info editing
* `set_announce(jid: &Jid, announce: bool)` - Enable/disable announcement mode
* `set_ephemeral(jid: &Jid, expiration: u32)` - Set disappearing messages timer
* `set_membership_approval(jid: &Jid, mode: MembershipApprovalMode)` - Require admin approval
* `get_membership_requests(jid: &Jid)` - Get pending membership requests
* `approve_membership_requests(jid: &Jid, participants: &[Jid])` - Approve pending requests
* `reject_membership_requests(jid: &Jid, participants: &[Jid])` - Reject pending requests
* `set_member_add_mode(jid: &Jid, mode: MemberAddMode)` - Set who can add members
* `set_no_frequently_forwarded(jid: &Jid, restrict: bool)` - Restrict forwarding of frequently forwarded messages
* `set_allow_admin_reports(jid: &Jid, allow: bool)` - Allow or disallow admin reports
* `set_group_history(jid: &Jid, enabled: bool)` - Enable or disable group history for new members
* `set_member_link_mode(jid: &Jid, mode: MemberLinkMode)` - Set member link mode
* `set_member_share_history_mode(jid: &Jid, mode: MemberShareHistoryMode)` - Set history sharing mode for new members
* `set_limit_sharing(jid: &Jid, enabled: bool)` - Limit sharing within the group
* `cancel_membership_requests(jid: &Jid, participants: &[Jid])` - Cancel pending membership requests
* `revoke_request_code(jid: &Jid, participants: &[Jid])` - Revoke request codes for participants
* `acknowledge(jid: &Jid)` - Acknowledge a group
* `batch_get_info(jids: Vec<Jid>)` - Batch fetch group metadata for multiple groups
* `get_profile_pictures(group_jids: Vec<Jid>, picture_type: PictureType)` - Batch fetch group profile pictures

**Example:**

```rust theme={null}
use whatsapp_rust::features::groups::{GroupCreateOptions, GroupParticipantOptions};

let options = GroupCreateOptions::builder()
    .subject("My Group")
    .participants(vec![GroupParticipantOptions::new(participant_jid)])
    .build();
let result = client.groups().create_group(options).await?;
```

### presence

```rust theme={null}
pub fn presence(&self) -> Presence<'_>
```

Access presence operations.

**Methods:**

* `set(status: PresenceStatus)` - Set presence status
* `set_available()` - Set status to available/online
* `set_unavailable()` - Set status to unavailable/offline
* `subscribe(jid: &Jid)` - Subscribe to contact's presence updates
* `unsubscribe(jid: &Jid)` - Unsubscribe from contact's presence updates

Subscriptions are automatically tracked and re-subscribed on reconnect.

**Example:**

```rust theme={null}
client.presence().set_available().await?;
client.presence().subscribe(&jid).await?;

// Later, stop receiving updates
client.presence().unsubscribe(&jid).await?;
```

### chatstate

```rust theme={null}
pub fn chatstate(&self) -> Chatstate<'_>
```

Access chat state (typing indicator) operations.

**Methods:**

* `send(to: &Jid, state: ChatStateType)` - Send a chat state update
* `send_composing(to: &Jid)` - Send typing indicator
* `send_recording(to: &Jid)` - Send recording indicator
* `send_paused(to: &Jid)` - Send paused/stopped typing indicator

**Example:**

```rust theme={null}
// Send typing indicator
client.chatstate().send_composing(&jid).await?;

// Send recording indicator
client.chatstate().send_recording(&jid).await?;

// Stop typing indicator
client.chatstate().send_paused(&jid).await?;
```

### contacts

```rust theme={null}
pub fn contacts(&self) -> Contacts<'_>
```

Access contact operations.

**Methods:**

* `is_on_whatsapp(jids: &[Jid])` - Check if JIDs are registered on WhatsApp (supports PN and LID JIDs)
* `get_user_info(jids: &[Jid])` - Get profile info for users by JID
* `get_profile_picture(jid: &Jid, preview: bool)` - Get profile picture URL (preview or full size)

### tc\_token

```rust theme={null}
pub fn tc_token(&self) -> TcToken<'_>
```

Access trust/privacy token operations.

**Methods:**

* `issue_tokens(jids: &[Jid])` - Request tokens for contacts
* `prune_expired()` - Remove expired tokens
* `get(jid: &str)` - Get a stored token by JID
* `get_all_jids()` - List all JIDs with stored tokens

### chat\_actions

```rust theme={null}
pub fn chat_actions(&self) -> ChatActions<'_>
```

Access chat management actions. Operations sync across all linked devices via app state sync.

**Methods:**

* `archive_chat(jid: &Jid, message_range: Option<SyncActionMessageRange>)` - Archive a chat
* `unarchive_chat(jid: &Jid, message_range: Option<SyncActionMessageRange>)` - Unarchive a chat
* `pin_chat(jid: &Jid)` - Pin a chat
* `unpin_chat(jid: &Jid)` - Unpin a chat
* `mute_chat(jid: &Jid)` - Mute a chat indefinitely
* `mute_chat_until(jid: &Jid, mute_end_timestamp_ms: i64)` - Mute until a specific time
* `unmute_chat(jid: &Jid)` - Unmute a chat
* `star_message(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool)` - Star a message
* `unstar_message(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool)` - Unstar a message
* `mark_chat_as_read(jid: &Jid, read: bool, message_range: Option<SyncActionMessageRange>)` - Mark a chat as read or unread across devices
* `delete_chat(jid: &Jid, delete_media: bool, message_range: Option<SyncActionMessageRange>)` - Delete a chat from all linked devices
* `delete_message_for_me(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool, delete_media: bool, message_timestamp: Option<i64>)` - Delete a message locally (not for the other party)

### status

```rust theme={null}
pub fn status(&self) -> Status<'_>
```

Access status/story operations.

**Methods:**

* `send_text(text, background_argb, font, recipients, options)` - Post a text status
* `send_image(upload, thumbnail, caption, recipients, options)` - Post an image status
* `send_video(upload, thumbnail, duration_seconds, caption, recipients, options)` - Post a video status
* `send_raw(message, recipients, options)` - Post any message type as a status
* `revoke(message_id, recipients, options)` - Delete a posted status
* `send_reaction(status_owner, server_id, reaction)` - React to a status update

See [Status API](/api/status) for full documentation.

### mex

```rust theme={null}
pub fn mex(&self) -> Mex<'_>
```

Access Meta Exchange (GraphQL) operations.

**Methods:**

* `query(request: MexRequest)` - Execute a GraphQL query
* `mutate(request: MexRequest)` - Execute a GraphQL mutation

See [MEX API](/api/mex) for full documentation.

### profile

```rust theme={null}
pub fn profile(&self) -> Profile<'_>
```

Access profile operations.

**Methods:**

* `set_push_name(name: &str)` - Set display name (syncs across devices)
* `set_status_text(text: &str)` - Set profile "About" text
* `set_profile_picture(image_data: Vec<u8>)` - Set profile picture (JPEG, 640x640 recommended)
* `remove_profile_picture()` - Remove profile picture

### newsletter

```rust theme={null}
pub fn newsletter(&self) -> Newsletter<'_>
```

Access newsletter (channel) operations.

**Methods:**

* `list_subscribed()` - List all subscribed newsletters
* `get_metadata(jid: &Jid)` - Get newsletter metadata
* `get_metadata_by_invite(invite: &str)` - Get metadata via invite link
* `create(name, description)` - Create a new newsletter
* `join(jid: &Jid)` - Join a newsletter
* `leave(jid: &Jid)` - Leave a newsletter
* `update(jid, options)` - Update newsletter settings
* `send_reaction(jid, msg_server_id, reaction)` - React to a newsletter message
* `get_messages(jid, count, before)` - Fetch newsletter messages
* `subscribe_live_updates(jid: &Jid)` - Subscribe to real-time updates

<Note>
  Newsletter message sending is handled by the unified [`client.send_message()`](#send_message) method — pass a newsletter JID and the message is sent as plaintext automatically. See the [Send API](/api/send#send_message) for details.
</Note>

**Example:**

```rust theme={null}
let newsletters = client.newsletter().list_subscribed().await?;
let metadata = client.newsletter().get_metadata(&newsletter_jid).await?;
```

See [Newsletter API](/api/newsletter) for full documentation.

### community

```rust theme={null}
pub fn community(&self) -> Community<'_>
```

Access community operations.

**Methods:**

* `create(options: CreateCommunityOptions)` - Create a community
* `deactivate(jid: &Jid)` - Deactivate a community
* `link_subgroups(jid: &Jid, subgroups: &[Jid])` - Link groups to a community
* `unlink_subgroups(jid: &Jid, subgroups: &[Jid], remove_orphan_members: bool)` - Unlink groups from a community
* `get_subgroups(jid: &Jid)` - List community subgroups
* `get_subgroup_participant_counts(jid: &Jid)` - Get participant counts per subgroup
* `query_linked_group(community_jid: &Jid, subgroup_jid: &Jid)` - Query a linked group's community metadata
* `join_subgroup(community_jid: &Jid, subgroup_jid: &Jid)` - Join a community subgroup
* `get_linked_groups_participants(jid: &Jid)` - Get participants across linked groups

**Example:**

```rust theme={null}
let subgroups = client.community().get_subgroups(&community_jid).await?;
```

See [Community API](/api/community) for full documentation.

### polls

```rust theme={null}
pub fn polls(&self) -> Polls<'_>
```

Access poll operations.

**Methods:**

* `create(to: &Jid, name: &str, options: &[String], selectable_count: u32)` - Create a poll (returns message ID and secret)
* `vote(chat_jid, poll_msg_id, poll_creator_jid, message_secret, option_names)` - Cast a vote on a poll
* `decrypt_vote(enc_payload, enc_iv, message_secret, poll_msg_id, poll_creator_jid, voter_jid)` - Decrypt a vote (static method)
* `aggregate_votes(poll_options, votes, message_secret, poll_msg_id, poll_creator_jid)` - Tally all votes (static method)

**Example:**

```rust theme={null}
let options = vec!["Yes".to_string(), "No".to_string()];
let (msg_id, secret) = client.polls().create(&chat_jid, "Agree?", &options, 1).await?;
```

See [Polls API](/api/polls) for full documentation.

### media\_reupload

```rust theme={null}
pub fn media_reupload(&self) -> MediaReupload<'_>
```

Access media reupload operations. Use this when a media download fails because the URL has expired.

**Methods:**

* `request(req: &MediaReuploadRequest)` - Request the server to re-upload expired media

**Example:**

```rust theme={null}
let result = client.media_reupload().request(&MediaReuploadRequest {
    msg_id: "ABCD1234",
    chat_jid: &chat_jid,
    media_key: &media_key_bytes,
    is_from_me: false,
    participant: Some(&sender_jid),
}).await?;
```

The request sends a `server-error` receipt and waits up to 30 seconds for a `mediaretry` notification with an updated download path.

### signal

```rust theme={null}
pub fn signal(&self) -> Signal<'_>
```

Access low-level Signal protocol operations for direct encryption, decryption, and session management.

**Methods:**

* `encrypt_message(jid: &Jid, plaintext: &[u8])` - Encrypt plaintext for a single recipient
* `decrypt_message(jid: &Jid, enc_type: EncType, ciphertext: &[u8])` - Decrypt a Signal protocol message
* `encrypt_group_message(group_jid: &Jid, plaintext: &[u8])` - Encrypt plaintext for a group using sender keys
* `decrypt_group_message(group_jid: &Jid, sender_jid: &Jid, ciphertext: &[u8])` - Decrypt a group message
* `validate_session(jid: &Jid)` - Check whether a Signal session exists
* `delete_sessions(jids: &[Jid])` - Delete Signal sessions and identity keys
* `create_participant_nodes(recipient_jids: &[Jid], message: &Message)` - Create encrypted participant nodes
* `assert_sessions(jids: &[Jid])` - Ensure E2E sessions exist
* `get_user_devices(jids: &[Jid])` - Get all device JIDs for users

**Example:**

```rust theme={null}
// Check if a session exists, then encrypt
let has_session = client.signal().validate_session(&jid).await?;
if !has_session {
    client.signal().assert_sessions(&[jid.clone()]).await?;
}
let (enc_type, ciphertext) = client.signal().encrypt_message(&jid, plaintext).await?;
```

<Warning>
  These are low-level APIs that bypass the high-level message sending pipeline. Most users should use [`send_message()`](#send_message) which handles encryption automatically.
</Warning>

See [Signal API](/api/signal) for full documentation.

### `query_usync`

```rust theme={null}
pub async fn query_usync(&self, query: UsyncQuery) -> Result<UsyncResponse, IqError>
```

Executes a typed USync ("user sync") query directly — the same protocol engine that powers [`contacts()`](#contacts) and [`signal().get_user_devices()`](#signal) under the hood. Use it for protocol combinations not covered by a specialized helper (bot profile lookup, username resolution, `disappearing_mode`/`text_status`, feature flags). This is a neutral operation: it only returns decoded wire data, with no cache or persistence side effects.

See [USync API](/api/usync) for the full `UsyncQuery`/`UsyncResponse` model and examples.

***

## Public fields

### http\_client

```rust theme={null}
pub http_client: Arc<dyn HttpClient>
```

The HTTP client used for media operations, version fetching, and other HTTP requests. This field is public and can be used directly for custom HTTP operations that share the same client configuration.

### enable\_auto\_reconnect

```rust theme={null}
pub enable_auto_reconnect: Arc<AtomicBool>
```

Controls whether the client automatically reconnects after an unexpected disconnection. Defaults to `true`. Set to `false` to disable auto-reconnect.

### custom\_enc\_handlers

```rust theme={null}
pub custom_enc_handlers: std::sync::OnceLock<HashMap<String, Arc<dyn EncHandler>>>
```

Custom handlers for encrypted message types. Set once at `Bot::build` and immutable afterward; read lock-free via `.get()`. Register handlers exclusively through `BotBuilder::with_enc_handler()` — direct mutation after build is not possible.

### RECONNECT\_BACKOFF\_STEP

```rust theme={null}
pub const RECONNECT_BACKOFF_STEP: u32 = 4;
```

The number of Fibonacci steps added to the backoff counter when `reconnect()` is called, creating an approximately 5-second offline window before the next connection attempt. This prevents tight reconnect loops after intentional disconnects.

***

## Client Profile

The noise-handshake `ClientPayload.UserAgent` identity that this client presents to WhatsApp servers. The default is [`ClientProfile::web()`](/concepts/authentication#clientprofile), which matches the legacy desktop-web payload (platform `Web`, device `Desktop`, OS version `0.1.0`, and an attached `web_info` field).

This is independent of `DeviceProps` — `device_props` controls what is reported during companion registration (e.g., the entry shown under **Linked Devices** on the phone), while `ClientProfile` controls the user agent fields used during the Noise handshake on every connect.

### set\_client\_profile

```rust theme={null}
pub async fn set_client_profile(&self, profile: wacore::client_profile::ClientProfile)
```

Sets the noise-handshake `ClientPayload` profile. The profile is held in-memory only (`#[serde(skip)]` on `Device.client_profile`), so you must call this before each `connect()` on a fresh process.

<ParamField path="profile" type="ClientProfile" required>
  The profile to apply. Use the constructors on [`ClientProfile`](/concepts/authentication#clientprofile) — `web()`, `android(os_version)`, `smb_android(os_version)`, `ios(os_version)`, `macos(os_version)`, `windows(os_version)`.
</ParamField>

**Example:**

```rust theme={null}
use whatsapp_rust::ClientProfile;

// Present as Android 13 on the next connect
client.set_client_profile(ClientProfile::android("13")).await;
client.connect().await?;
```

<Note>
  Native profiles (`android`, `smb_android`, `ios`, `macos`, `windows`) automatically omit `web_info` from the `ClientPayload`. Only `web()` includes it.
</Note>

***

## Device State

### push\_name

```rust theme={null}
pub fn push_name(&self) -> String
```

Returns the current push name (display name).

<Note>
  Renamed from `get_push_name` — the `get_` prefix was dropped to match the neighboring accessors.
</Note>

### pn

```rust theme={null}
pub fn pn(&self) -> Option<Jid>
```

Returns the phone number JID, or `None` before pairing completes.

<Note>
  Renamed from `get_pn`.
</Note>

### lid

```rust theme={null}
pub fn lid(&self) -> Option<Jid>
```

Returns the LID (Linked Identity), or `None` before pairing completes.

<Note>
  Renamed from `get_lid`.
</Note>

### is\_lid\_migrated

```rust theme={null}
pub async fn is_lid_migrated(&self) -> bool
```

Whether the account is 1:1-LID-migrated on WhatsApp's servers. This gates outbound DM wire addressing (the stanza `to`/`<participants>` namespace) — an unmigrated account keeps DMs on PN even when a LID mapping is cached, since the server rejects LID-addressed DMs from unmigrated accounts with `ack error="400"` ([#941](https://github.com/oxidezap/whatsapp-rust/issues/941)). Signal session addressing is unaffected either way.

Returns `true` if the persisted `Device.lid_migrated` flag is set, or (as a fallback for accounts paired before the flag existed) if the `lid_one_on_one_migration_enabled` ab prop is currently enabled. See [Signal Protocol — DM wire namespace vs. Signal session addressing](/advanced/signal-protocol#dm-wire-namespace-vs-signal-session-addressing) and [Authentication — one-to-one LID migration state](/concepts/authentication#one-to-one-lid-migration-state).

<Note>
  This is normally handled automatically by the send path — you don't need to call it yourself before sending. It's exposed for diagnostics/telemetry.
</Note>

### get\_lid\_pn\_entry

```rust theme={null}
pub async fn get_lid_pn_entry(&self, jid: &Jid) -> Option<LidPnEntry>
```

Unified LID-PN lookup that auto-routes based on the JID type. Pass a phone number JID (`@s.whatsapp.net`) to look up its LID, or a LID JID (`@lid`) to look up its phone number. Returns `None` for non-user JIDs (groups, newsletters, etc.) or if no mapping is cached.

<ParamField path="jid" type="&Jid" required>
  The JID to look up — either a PN JID or a LID JID
</ParamField>

<ResponseField name="LidPnEntry" type="Option<LidPnEntry>">
  Contains `lid` (`Arc<str>`), `phone_number` (`Arc<str>`), `created_at` (i64 Unix timestamp), and `learning_source` ([`LearningSource`](#learningsource)). Use `&*entry.lid` for `&str` comparisons or pass directly to anything that accepts `AsRef<str>`.
</ResponseField>

**Example:**

```rust theme={null}
use wacore_binary::jid::Jid;

// Look up by phone number JID
let pn_jid: Jid = "15551234567@s.whatsapp.net".parse()?;
if let Some(entry) = client.get_lid_pn_entry(&pn_jid).await {
    println!("LID: {}", entry.lid);
    println!("Phone: {}", entry.phone_number);
}

// Look up by LID JID
let lid_jid: Jid = "100000012345678@lid".parse()?;
if let Some(entry) = client.get_lid_pn_entry(&lid_jid).await {
    println!("Phone: {}", entry.phone_number);
}
```

<Note>
  This replaces the previous `get_phone_number_from_lid` method. The new API accepts a full `Jid` instead of a raw string and supports bidirectional lookup — pass either a PN or LID JID to resolve the mapping in either direction.
</Note>

#### LearningSource

The `LearningSource` enum indicates how a LID-PN mapping was discovered. The source is not mere provenance — it also selects the **write policy** applied when the pair reaches the cache, mirroring WhatsApp Web's `createLidPnMappings` (`WAWebDBCreateLidPnMappings`) `switch (learningSource)`:

* **Directed sources** (`Usync`, `PeerPnMessage`, `PeerLidMessage`, `RecipientLatestLid`, `MigrationSyncLatest`, `MigrationSyncOld`, `BlocklistActive`, `BlocklistInactive`) overwrite the cache on any change from what's already stored.
* **Observational bulk sources** (`Other`, `Pairing`, `DeviceNotification`) only seed a LID that isn't cached yet. If the pair conflicts with an already-known LID for that phone, the observational pair is **not** applied — the client instead fires one background live LID query (`LidQuerySpec`) and learns the authoritative result under `Usync`, which can never itself trigger another reconcile.
* **Known-stale sources** (`MigrationSyncOld`, `BlocklistInactive`) are additionally stamped with `created_at = 0`, so a fresher mapping for the same phone always outranks them in the cache's most-recent-wins (PN→LID) resolution. This only guards the forward direction — the LID→PN reverse map always takes the latest write.

| Variant               | Description                                                                         | Write policy                                                |
| --------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `Usync`               | From a device sync query response                                                   | Directed — overwrites on conflict                           |
| `PeerPnMessage`       | From an incoming message with `sender_lid` attribute (sender is PN)                 | Directed — overwrites on conflict                           |
| `PeerLidMessage`      | From an incoming message with `sender_pn` attribute (sender is LID)                 | Directed — overwrites on conflict                           |
| `RecipientLatestLid`  | From looking up a recipient's latest LID                                            | Directed — overwrites on conflict                           |
| `MigrationSyncLatest` | From the live 1:1 LID migration flow                                                | Directed — overwrites on conflict                           |
| `MigrationSyncOld`    | From old history sync records                                                       | Directed — overwrites on conflict; `created_at = 0` (stale) |
| `BlocklistActive`     | From an active blocklist entry                                                      | Directed — overwrites on conflict                           |
| `BlocklistInactive`   | From an inactive blocklist entry                                                    | Directed — overwrites on conflict; `created_at = 0` (stale) |
| `Pairing`             | From device pairing (own JID to LID)                                                | Observational — seeds new LIDs only                         |
| `DeviceNotification`  | From a device notification with `lid` attribute                                     | Observational — seeds new LIDs only                         |
| `Other`               | From an unknown/bulk source (e.g. the history-sync `phoneNumberToLidMappings` seed) | Observational — seeds new LIDs only                         |

<Note>
  A pair that already matches the cache's current mapping always re-affirms durability regardless of source — it is never treated as a conflict. This includes an exact match, and also a reverse-only match: the LID-PN cache is capacity-bounded (see [`lid_pn_cache`](/api/bot#cache-configuration-reference)), so the PN→LID entry can be evicted while the LID→PN entry survives, and a re-learn of that surviving pair still counts as self-consistent.
</Note>

### persistence\_manager

```rust theme={null}
pub fn persistence_manager(&self) -> Arc<PersistenceManager>
```

Access to the persistence manager for multi-account scenarios.

***

## History Sync

History sync transfers chat history from the phone to the linked device. The client processes history sync notifications through a RAM-optimized pipeline that minimizes peak memory usage.

### Processing pipeline

When a history sync notification arrives, the client:

1. Sends a `HistorySync` receipt immediately (so the phone knows delivery succeeded)
2. Retrieves the data — either from an inline payload (moved via `.take()`, not cloned) or by stream-decrypting an external blob in 8KB chunks
3. Extracts a `compressed_size_hint` from the notification's `file_length` field, which the decompressor uses with a 4x multiplier for better buffer pre-allocation (avoids repeated `Vec` reallocation)
4. Runs decompression and protobuf parsing on a blocking thread (`tokio::task::spawn_blocking`) to avoid stalling the async runtime
5. Wraps the decompressed blob in a [`LazyHistorySync`](/concepts/events#lazyhistorysync) with cheap metadata (sync type, chunk order, progress) and dispatches it as `Event::HistorySync(Box<LazyHistorySync>)`. Full protobuf decoding is deferred until the event handler calls `.get()`

If no event handlers are registered, the blob is not retained — only internal data (pushname, NCT salt, TC tokens) is extracted.

### process\_sync\_task

```rust theme={null}
pub async fn process_sync_task(self: &Arc<Self>, task: MajorSyncTask)
```

Processes a `MajorSyncTask` received from the sync channel returned by `Client::new`. This is the public entry point for handling history sync and app state sync tasks.

The method dispatches to the appropriate internal handler based on the task variant:

* `MajorSyncTask::HistorySync` — downloads and processes history sync data
* `MajorSyncTask::AppStateSync` — synchronizes app state (contacts, mutes, pins, etc.)

**Example:**

```rust theme={null}
let (client, mut sync_receiver) = Client::new(/* ... */).await;

// Process sync tasks from the channel
tokio::spawn({
    let client = client.clone();
    async move {
        while let Ok(task) = sync_receiver.recv().await {
            client.process_sync_task(task).await;
        }
    }
});
```

<Note>
  If you use the [`Bot`](/api/bot) builder, sync task processing is handled automatically. You only need this method when building a custom client setup.
</Note>

### set\_skip\_history\_sync

```rust theme={null}
pub fn set_skip_history_sync(&self, enabled: bool)
```

Enable or disable skipping of history sync notifications at runtime.

When skipping is enabled, the client sends a receipt (so the phone stops retrying uploads) but does not download or process any data.

### skip\_history\_sync\_enabled

```rust theme={null}
pub fn skip_history_sync_enabled(&self) -> bool
```

Returns `true` if history sync is currently being skipped.

### set\_wanted\_pre\_key\_count

```rust theme={null}
pub fn set_wanted_pre_key_count(&self, count: usize)
```

Sets the number of one-time pre-keys generated and uploaded per batch. Mirrors WhatsApp Web's `UPLOAD_KEYS_COUNT`. Default: `812`.

Intended for consumers that construct `Client` directly (rather than via `Bot::builder().with_wanted_pre_key_count(...)`). Set this before calling `connect()`. The value is clamped at upload time to `5..=65_535`; out-of-range values log a `warn!`.

<ParamField path="count" type="usize" required>
  Pre-keys per upload batch. Clamped to `5..=65_535`.
</ParamField>

**Example:**

```rust theme={null}
// On a memory-constrained host, upload smaller batches
client.set_wanted_pre_key_count(256);
client.connect().await?;
```

<Warning>
  The floor of 5 prevents an empty-but-flagged pool and a re-upload loop. The ceiling of 65,535 matches the upload IQ's `u16` list-length encoding — larger batches would generate keys locally and then fail to encode.
</Warning>

### wanted\_pre\_key\_count

```rust theme={null}
pub fn wanted_pre_key_count(&self) -> usize
```

Returns the currently configured pre-key upload batch size.

### set\_force\_active\_delivery\_receipts

```rust theme={null}
pub fn set_force_active_delivery_receipts(&self, active: bool)
```

Force the client to send active delivery receipts (matching the recipient pattern WA Web uses for foreground chats) regardless of the local `delivery_receipt_active` setting. v0.6 added this knob so consumers can opt every incoming message into active receipts during a known foreground session.

When `active` is `true`, the client emits `<receipt>` stanzas without the silent flag for every successful decrypt. When `false` (default), behavior follows the existing per-chat heuristic. The setting is mirrored across offline-resume so the post-resume ack pattern matches the live one.

### send\_history\_sync\_server\_error\_receipt

```rust theme={null}
pub async fn send_history_sync_server_error_receipt(
    &self,
    message_id: &str,
    media_key: &[u8],
) -> Result<()>
```

Ask the phone to re-upload a history-sync blob whose download failed (corrupt body, mismatched HMAC, missing CDN file, etc.). The client emits a `<receipt type="server-error" category="peer">` to the companion device carrying an encrypted retry payload, mirroring WA Web's `WAWebSendHistSyncServerErrorReceiptJob`.

**Parameters:**

* `message_id` — the `MessageInfo::id` of the failed history-sync notification
* `media_key` — the 32-byte key carried by the original `<historysync mediaKey="…">` element

**When to call:** Typically inside your `Event::HistorySync` (or upstream download) error path, once you've determined the blob can't be recovered locally. The phone will then retry the upload, producing a fresh `HistorySync` notification.

```rust theme={null}
if let Err(err) = download_history_blob(&lazy_sync).await {
    tracing::warn!(?err, "history sync download failed; asking phone to retry");
    client
        .send_history_sync_server_error_receipt(&info.id, &media_key)
        .await?;
}
```

***

## Offline sync

The client automatically manages offline message sync when reconnecting. During sync, message processing is restricted to sequential mode (1 concurrent task) to preserve ordering.

### Semaphore transition safety

When offline sync completes, the concurrency semaphore is swapped from 1 permit to 64 permits. Tasks that were already waiting on the old semaphore use a **generation-checked re-acquire loop** to safely transition — they detect the swap via an atomic generation counter, drop the stale permit, and re-acquire from the new semaphore. This prevents `pkmsg` messages (which carry SKDM for group decryption) from being silently dropped during the transition. See [Concurrency gating](/concepts/architecture#offline-sync) for details.

### Timeout fallback

If the server advertises offline messages but never completes delivery, a 60-second timeout ensures startup is not blocked indefinitely. On timeout:

1. A warning is logged with the number of processed vs. expected items
2. Offline sync is marked complete
3. `OfflineSyncCompleted` event is emitted
4. Message processing switches from sequential to parallel (64 concurrent tasks)

### State reset on reconnect

All offline sync state (counters, timing, concurrency semaphore) is fully reset on reconnect so stale state does not carry over to the next connection.

**Related events:** [`OfflineSyncPreview`](/concepts/events#offlinesyncpreview), [`OfflineSyncCompleted`](/concepts/events#offlinesynccompleted)

***

## App State

### fetch\_props

```rust theme={null}
pub async fn fetch_props(&self) -> Result<(), IqError>
```

Fetches A/B experiment properties from WhatsApp servers and updates the in-memory `AbPropsCache`.

When a stored props hash exists **and** the cache has been seeded (at least one full fetch has occurred), the request includes the hash for a delta update — the server only returns changed props. Otherwise, a full fetch is performed and all cached props are replaced.

After the response is applied to the cache, the new hash (if present) is persisted for future delta requests.

Features like group privacy token attachment query the `AbPropsCache` to check whether specific experiment flags are enabled. See [AB props cache](#ab-props-cache) for details.

### AB props cache

The client maintains an in-memory `AbPropsCache` that stores server-side A/B experiment properties. The cache is populated each time `fetch_props()` runs (automatically on connect) and is **not persisted** — props are re-fetched on every connection.

Features query the cache by passing a typed `AbProp` constant from the vendored [`wacore::iq::abprops`](/api/wacore#a-b-props-registry) registry. A bool prop is considered enabled when its value is `"1"`, `"true"`, or `"enabled"` (case-insensitive), falling back to the registry default when the server didn't send it.

```rust theme={null}
use wacore::iq::abprops::web;

if client.ab_props().is_enabled(web::PRIVACY_TOKEN_SENDING_ON_GROUP_CREATE).await {
    // gated behavior
}

let bucket_duration = client
    .ab_props()
    .get_int(web::TCTOKEN_DURATION)
    .await;
```

The cache exposes:

| Method             | Purpose                                                           |
| ------------------ | ----------------------------------------------------------------- |
| `is_enabled(prop)` | Truthy check for bool flags, falling back to the registry default |
| `get(prop)`        | Raw `Option<CompactString>` value if the server sent it           |
| `get_int(prop)`    | Parsed `i64` for int flags, falling back to the registry default  |
| `is_seeded()`      | `true` after the first full (non-delta) fetch has been applied    |

#### Watching additional flags

Only flags in the cache's *interest set* are retained when props come in — every other server prop is discarded to avoid allocating for the \~2,000+ flags WhatsApp ships. The interest set is pre-seeded with the flags the library itself reads (see `wacore::iq::props::WATCHED`). If you need to gate your own code on a flag the library doesn't already watch, register it before the first `fetch_props()`:

```rust theme={null}
use wacore::iq::abprops::web;

client.ab_props().watch(web::ADMIN_REVOKE_RECEIVER).await;
// or in bulk:
client.ab_props().watch_many(&[
    web::ADD_MEMBER_SYSTEM_MESSAGE,
    web::ADMIN_ONLY_MENTION_EVERYONE_GROUP_SIZE,
]).await;
```

Flags retain their `code`, `value_type`, and `default` straight from the WA Web bundle, so behavior tracks WhatsApp Web without hand-maintained config tables.

<Note>
  The AB props cache is internal to the client. You don't need to interact with it directly — the library automatically checks relevant flags when performing group operations like `create_group` and `add_participants`.
</Note>

### fetch\_privacy\_settings

```rust theme={null}
pub async fn fetch_privacy_settings(&self) -> Result<PrivacySettingsResponse, IqError>
```

Fetches privacy settings (last seen, profile photo, about, etc.). See [Privacy API](/api/privacy) for types and details.

### set\_privacy\_setting

```rust theme={null}
pub async fn set_privacy_setting(
    &self,
    category: PrivacyCategory,
    value: PrivacyValue,
) -> Result<SetPrivacySettingResponse, IqError>
```

Sets a privacy setting for a specific category using type-safe enums.

<ParamField path="category" type="PrivacyCategory" required>
  Privacy category enum: `Last`, `Online`, `Profile`, `Status`, `GroupAdd`, `ReadReceipts`, `CallAdd`, `Messages`, or `DefenseMode`
</ParamField>

<ParamField path="value" type="PrivacyValue" required>
  Privacy value enum: `All`, `Contacts`, `None`, `ContactBlacklist`, `MatchLastSeen`, `Known`, `Off`, or `OnStandard`
</ParamField>

**Example:**

```rust theme={null}
use whatsapp_rust::privacy_settings::{PrivacyCategory, PrivacyValue};

// Hide last seen from everyone
client.set_privacy_setting(PrivacyCategory::Last, PrivacyValue::None).await?;

// Show profile photo only to contacts
client.set_privacy_setting(PrivacyCategory::Profile, PrivacyValue::Contacts).await?;
```

See [Privacy API](/api/privacy) for all categories, values, and valid combinations.

### set\_privacy\_disallowed\_list

```rust theme={null}
pub async fn set_privacy_disallowed_list(
    &self,
    category: PrivacyCategory,
    update: DisallowedListUpdate,
) -> Result<SetPrivacySettingResponse, IqError>
```

Updates a privacy category's disallowed list (contacts-except-specific-users mode). Only available for `Last`, `Profile`, `Status`, and `GroupAdd`.

See [Privacy API](/api/privacy#set_privacy_disallowed_list) for details and examples.

### set\_default\_disappearing\_mode

```rust theme={null}
pub async fn set_default_disappearing_mode(
    &self,
    duration: u32,
) -> Result<(), IqError>
```

Sets the default disappearing messages duration for new chats.

<ParamField path="duration" type="u32" required>
  Timer duration in seconds. Common values: `86400` (24 hours), `604800` (7 days), `7776000` (90 days). Pass `0` to disable.
</ParamField>

**Example:**

```rust theme={null}
// Enable 7-day default disappearing messages
client.set_default_disappearing_mode(604800).await?;

// Disable default disappearing messages
client.set_default_disappearing_mode(0).await?;
```

### get\_business\_profile

```rust theme={null}
pub async fn get_business_profile(
    &self,
    jid: &Jid,
) -> Result<Option<BusinessProfile>, IqError>
```

Fetches the business profile for a WhatsApp Business account. Returns `None` if the account is not a business account or has no business profile.

<ParamField path="jid" type="&Jid" required>
  JID of the business account to query
</ParamField>

**Example:**

```rust theme={null}
let jid: Jid = "15551234567@s.whatsapp.net".parse()?;

if let Some(profile) = client.get_business_profile(&jid).await? {
    println!("Description: {}", profile.description);
    println!("Email: {:?}", profile.email);
    println!("Website: {:?}", profile.website);
    println!("Address: {:?}", profile.address);

    for category in &profile.categories {
        println!("Category: {} ({})", category.name, category.id);
    }
} else {
    println!("Not a business account");
}
```

See [Business API](/api/business) for full type details.

### clean\_dirty\_bits

```rust theme={null}
pub async fn clean_dirty_bits(
    &self,
    bit: wacore::iq::dirty::DirtyBit
) -> Result<(), IqError>
```

Cleans app state dirty bits. The `DirtyBit` struct contains a `dirty_type` (e.g., `AccountSync`, `Groups`, `SyncdAppState`, `NewsletterMetadata`) and an optional `timestamp`.

***

## Protocol Operations

### send\_node

```rust theme={null}
pub async fn send_node(&self, node: Node) -> Result<(), ClientError>
```

Sends a raw protocol node (advanced usage).

<ParamField path="node" type="Node" required>
  Binary protocol node to send
</ParamField>

**Errors:**

* `ClientError::NotConnected` - Not connected
* `ClientError::EncryptSend` - Encryption/send failure

### send\_raw\_bytes

```rust theme={null}
pub async fn send_raw_bytes(&self, plaintext: Vec<u8>) -> Result<(), ClientError>
```

Send pre-marshaled plaintext bytes through the noise socket. The bytes must be a valid WABinary-marshaled stanza (as produced by `wacore_binary::marshal::marshal_to`). Sending malformed data will cause the server to close the connection.

<ParamField path="plaintext" type="Vec<u8>" required>
  WABinary-marshaled stanza bytes
</ParamField>

**Errors:**

* `ClientError::NotConnected` - Not connected
* `ClientError::EncryptSend` - Encryption/send failure

<Warning>
  This bypasses node logging and `wait_for_sent_node` waiter resolution. Use [`send_node`](#send_node) for normal stanza sending. This method is intended for performance-critical paths where you already have marshaled bytes.
</Warning>

### flush\_pending\_signal\_state

```rust theme={null}
pub async fn flush_pending_signal_state(&self) -> Result<(), SignalMaintenanceError>
```

<Note>
  As of PR #1090, this returns [`SignalMaintenanceError`](/api/errors#signalmaintenanceerror) instead of `anyhow::Error` (a `Storage` failure keeps the typed backend cause reachable via `source()`).
</Note>

Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails).

Ordinarily — without calling this method — the backend trails the in-memory cache. Most sends — DM, group, and status alike — are already covered by a durable lease (`SessionRecord`'s sender-chain counter lease for DMs, `SenderKeyRecord`'s chain iteration lease for group/status), so they only schedule the coalesced write-behind; only the roughly-1-in-64 send that exhausts the current lease flushes synchronously — and because the pre-wire flush check is global, a pending flush on an unrelated session or sender key can force a synchronous flush too. Status reactions are the DM-branch exception and follow the DM lease behavior instead of the group/status one. The live receive path always schedules a coalesced flush, on a \~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)).

A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call has **no hard wall-clock bound** — it can wait on locks, or on slow or failing storage, and a backend outage extends it until the retry loop succeeds. Check the returned `Result`: a failure means the flush did not complete, and state is still pending, not persisted.

<Warning>
  Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe.
</Warning>

**Example:**

```rust theme={null}
// Force durability before reading Signal state directly, or before a
// non-graceful shutdown (process kill, container stop).
client.flush_pending_signal_state().await?;
```

See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model.

### generate\_message\_id

```rust theme={null}
pub fn generate_message_id(&self) -> String
```

Generates a unique WhatsApp-protocol-conformant message ID. Combines timestamp, user JID, and random components for uniqueness.

<Note>
  This is intended for advanced users who need to build custom protocol interactions or manage message IDs manually. Most users should use `send_message` which handles ID generation automatically.
</Note>

### send\_iq

```rust theme={null}
pub async fn send_iq(&self, query: InfoQuery<'_>) -> Result<Arc<OwnedNodeRef>, IqError>
```

Sends a custom IQ (Info/Query) stanza to the WhatsApp server.

<ParamField path="query" type="InfoQuery" required>
  IQ query containing stanza type, namespace, content, and optional timeout
</ParamField>

**Example:**

```rust theme={null}
use wacore::request::{InfoQuery, InfoQueryType};
use wacore_binary::builder::NodeBuilder;
use wacore_binary::node::NodeContent;
use wacore_binary::jid::{Jid, SERVER_JID};

let query_node = NodeBuilder::new("presence")
    .attr("type", "available")
    .build();

let server_jid = Jid::new("", SERVER_JID);

let query = InfoQuery {
    query_type: InfoQueryType::Set,
    namespace: "presence",
    to: server_jid,
    target: None,
    content: Some(NodeContent::Nodes(vec![query_node])),
    id: None,
    timeout: None,
};

let response = client.send_iq(query).await?;
```

<Warning>
  This bypasses higher-level abstractions and safety checks. You should be familiar with the WhatsApp protocol and IQ stanza format before using this.
</Warning>

### execute

```rust theme={null}
pub async fn execute<S: IqSpec>(&self, spec: S) -> Result<S::Response, IqError>
```

Executes a typed IQ specification. This is the preferred way to send IQ stanzas — each spec type handles building the request and parsing the response.

<ParamField path="spec" type="S: IqSpec" required>
  A typed IQ specification that defines the request structure and response parsing
</ParamField>

**Example:**

```rust theme={null}
// Fetch group metadata using a typed spec
let metadata = client.execute(GroupQueryIq::new(&group_jid)).await?;
```

### wait\_for\_node

```rust theme={null}
pub fn wait_for_node(&self, filter: NodeFilter) -> oneshot::Receiver<Arc<OwnedNodeRef>>
```

Waits for a specific incoming protocol node matching the given filter. Returns a receiver that resolves when a matching node arrives.

<ParamField path="filter" type="NodeFilter" required>
  Filter specifying which node to wait for (by tag and attributes)
</ParamField>

**Example:**

```rust theme={null}
// Wait for a group notification
let waiter = client.wait_for_node(
    NodeFilter::tag("notification").attr("type", "w:gp2"),
);

// Perform the action that triggers the node
client.groups().add_participants(&group_jid, &[jid]).await?;

// Receive the notification
let node = waiter.await.expect("notification arrived");
```

<Note>
  Register the waiter **before** performing the action that triggers the expected node. When no waiters are active, this has zero cost (single atomic load per incoming node).
</Note>

#### NodeFilter

Builder for matching incoming protocol nodes:

```rust theme={null}
// Match by tag
let filter = NodeFilter::tag("notification");

// Match by tag and attributes
let filter = NodeFilter::tag("notification")
    .attr("type", "w:gp2");

// Match by tag and source JID
let filter = NodeFilter::tag("notification")
    .from_jid(&group_jid);
```

### wait\_for\_sent\_node

```rust theme={null}
pub fn wait_for_sent_node(&self, filter: NodeFilter) -> oneshot::Receiver<Arc<Node>>
```

Waits for a specific **outgoing** protocol node matching the given filter. Returns a receiver that resolves when a matching node is sent by the client. This is the outbound counterpart to `wait_for_node`.

<ParamField path="filter" type="NodeFilter" required>
  Filter specifying which outgoing node to intercept (by tag and attributes)
</ParamField>

**Example:**

```rust theme={null}
// Assert that a privacy token was attached to an outgoing message stanza
let waiter = client.wait_for_sent_node(
    NodeFilter::tag("message").attr("to", "15551234567@s.whatsapp.net"),
);

client.send_message(jid, message).await?;

let sent_node = waiter.await.expect("message stanza sent");
// Inspect the stanza to verify tctoken or cstoken was attached
```

<Note>
  Register the waiter **before** performing the action that produces the outgoing node. When no sent-node waiters are active, this has zero cost (single atomic load per outgoing node). Useful for testing whether `<tctoken>` or `<cstoken>` was attached to a sent stanza.
</Note>

### register\_handler

```rust theme={null}
pub fn register_handler(&self, handler: Arc<dyn EventHandler>)
```

Registers an event handler for protocol events.

<ParamField path="handler" type="Arc<dyn EventHandler>" required>
  Handler implementing the EventHandler trait
</ParamField>

**Example:**

```rust theme={null}
use wacore::types::events::{Event, EventHandler, 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!("New message from {}: {:?}", info.source.sender, msg);
                }
            }
            Event::Connected(_) => println!("Connected!"),
            _ => {}
        }
    }
}

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

**Using ChannelEventHandler:**

For channel-based event processing (useful for testing and custom event loops), use the built-in `ChannelEventHandler`:

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

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

// Process events asynchronously via async-channel (runtime-agnostic)
// event_rx yields Arc<Event>
while let Ok(event) = event_rx.recv().await {
    match &*event {
        Event::Connected(_) => break,
        _ => {}
    }
}
```

See [ChannelEventHandler](/concepts/events#channeleventhandler) for details.

### register\_chatstate\_handler

```rust theme={null}
pub async fn register_chatstate_handler(
    &self,
    handler: Arc<dyn Fn(ChatStateEvent) + Send + Sync>,
)
```

Registers a handler for chat state events (typing indicators). The handler is wrapped in `Arc` for thread-safe sharing across the event dispatching system.

### set\_raw\_node\_forwarding

```rust theme={null}
pub fn set_raw_node_forwarding(&self, enabled: bool)
```

Enable or disable raw node forwarding. When enabled, `Event::RawNode` is emitted for every decoded stanza before the stanza router dispatches it. Disabled by default to avoid overhead.

<ParamField path="enabled" type="bool" required>
  Whether to emit `Event::RawNode` for every incoming stanza
</ParamField>

**Example:**

```rust theme={null}
// Enable raw protocol access (e.g. for voice call stanzas)
client.set_raw_node_forwarding(true);

// Handle raw nodes in your event handler
bot.on_event(|event, _client| async move {
    if let Event::RawNode(node) = &*event {
        println!("Raw stanza: {} {:?}", node.tag, node.attrs);
    }
});
```

<Warning>
  Only enable this when you need raw protocol access. Every decoded stanza triggers the event, which adds overhead to the message processing pipeline.
</Warning>

***

## Call management

### reject\_call

```rust theme={null}
pub async fn reject_call(
    &self,
    call_id: &str,
    call_from: &Jid,
) -> Result<(), anyhow::Error>
```

Reject an incoming call. This is fire-and-forget — no server response is expected. Sends a `<call><reject>` stanza to the WhatsApp server.

<ParamField path="call_id" type="&str" required>
  The ID of the incoming call to reject. Must not be empty.
</ParamField>

<ParamField path="call_from" type="&Jid" required>
  The JID of the caller.
</ParamField>

**Example:**

```rust theme={null}
bot.on_event(|event, client| async move {
    if let Event::Notification(node) = &*event {
        // Handle incoming call notification and reject it
        if let Some(call_id) = extract_call_id(&node) {
            let caller = extract_caller(&node);
            client.reject_call(&call_id, &caller).await.ok();
        }
    }
});
```

***

## Spam Reporting

### send\_spam\_report

```rust theme={null}
pub async fn send_spam_report(
    &self,
    request: SpamReportRequest
) -> Result<SpamReportResult, IqError>
```

Send a spam report to WhatsApp for messages or groups.

<ParamField path="request" type="SpamReportRequest" required>
  The spam report request containing:

  * `message_id` - ID of the message being reported
  * `message_timestamp` - Timestamp of the message
  * `spam_flow` - Context where report was initiated (MessageMenu, GroupInfoReport, etc.)
  * `from_jid` - Optional sender JID
  * `group_jid` - Optional group JID for group spam
  * `group_subject` - Optional group name/subject for group reports
  * `participant_jid` - Optional participant JID in group context
  * `raw_message` - Optional raw message bytes
  * `media_type` - Optional media type if reporting media
  * `local_message_type` - Optional local message type
</ParamField>

**Returns:** `SpamReportResult` indicating success or failure

**Example:**

```rust theme={null}
use whatsapp_rust::{SpamReportRequest, SpamFlow};

// Report a spam message
let result = client.send_spam_report(SpamReportRequest {
    message_id: "MESSAGE_ID".to_string(),
    message_timestamp: 1234567890,
    from_jid: Some(sender_jid),
    spam_flow: SpamFlow::MessageMenu,
    ..Default::default()
}).await?;
```

**SpamFlow variants:**

* `MessageMenu` - Reported from message context menu
* `GroupInfoReport` - Reported from group info screen
* `GroupSpamBannerReport` - Reported from group spam banner
* `ContactInfo` - Reported from contact info screen
* `StatusReport` - Reported from status view

***

## Passive Mode

### set\_passive

```rust theme={null}
pub async fn set_passive(&self, passive: bool) -> Result<(), IqError>
```

Sets passive mode. When `false` (active), the server starts sending offline messages.

***

## Prekeys

### refresh\_pre\_keys

```rust theme={null}
pub async fn refresh_pre_keys(&self) -> Result<(), anyhow::Error>
```

Force-refreshes the server's one-time pre-key pool with a fresh batch. This is intended for device migration scenarios where you restore a device from an external source (e.g., migrating a Baileys session into an `InMemoryBackend`) and the server may still hold pre-key IDs whose private key material you cannot reconstruct.

Any `pkmsg` referencing those old IDs will fail permanently with `InvalidPreKeyId`. Calling `refresh_pre_keys()` uploads a fresh batch that the caller *does* have locally, and old unmatched IDs drain as peers consume them.

**Behavior:**

* Acquires the internal `prekey_upload_lock` so this force-upload cannot race with the count-based and digest-repair upload paths
* Uploads a full batch of [`Client::wanted_pre_key_count()`](#wanted_pre_key_count) pre-keys (default 812, configurable via [`BotBuilder::with_wanted_pre_key_count`](/api/bot#with-wanted-pre-key-count) or [`set_wanted_pre_key_count`](#set_wanted_pre_key_count)) with Fibonacci retry backoff (1s, 2s, 3s, 5s, 8s, ... capped at 610s)
* Retries until success or the connection is lost

<Warning>
  Only call this after restoring a device from an external session store. Under normal operation, the client manages pre-key uploads automatically.
</Warning>

**Example:**

```rust theme={null}
// After migrating a session from another library (e.g., Baileys)
client.refresh_pre_keys().await?;
```

### send\_digest\_key\_bundle

```rust theme={null}
pub async fn send_digest_key_bundle(&self) -> Result<(), IqError>
```

Validates that the server's copy of the Signal Protocol key bundle matches local keys by querying a digest endpoint and comparing SHA-1 hashes. This matches WhatsApp Web's `WAWebDigestKeyJob.digestKey()` flow.

**Behavior:**

* Queries the server for the current key bundle digest (identity key, signed pre-key, pre-key IDs, and a SHA-1 hash)
* If the server returns **404** (no record), triggers a full pre-key re-upload
* On success, loads local keys, computes the same SHA-1 digest, and compares
* Hash mismatches or missing keys are logged but do not trigger re-upload — only 404 does

See [Signal Protocol - Digest key validation](/advanced/signal-protocol#digest-key-validation) for the wire format and detailed validation process.

### Signed pre-key rotation

Rotation itself runs automatically: once per connection, after the post-login pre-key upload, the client checks whether the signed pre-key is due for rotation (weekly by default) and, if so, generates a fresh one, uploads it via an `encrypt`/`<rotate>` IQ, and retains the previous key so in-flight prekey messages still decrypt. Automatic rotation failures are logged and retried on a later connect — they never fail login.

```rust theme={null}
pub async fn rotate_signed_pre_key(&self) -> Result<(), SignalMaintenanceError>
```

`rotate_signed_pre_key()` is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the weekly check. It shares a lock with the automatic path (so a manual call can't race a background rotation) and, unlike the automatic path, propagates failures to the caller as [`SignalMaintenanceError`](/api/errors#signalmaintenanceerror) instead of only logging them. As of PR #1090 this replaces bare `anyhow::Error`.

See [Signal Protocol - Signed pre-key rotation (RotateKeyJob)](/advanced/signal-protocol#signed-pre-key-rotation-rotatekeyjob) for the full sequence, wire format, and error handling.

***

## Diagnostics

Three on-`Client` surfaces answer "what does this session cost?" without any feature flag: always-on wire I/O counters via [`stats()`](#stats), an on-demand client-only memory breakdown via [`memory_report()`](#memory_report), and an on-demand unified estimate — client plus storage, transport, and HTTP — via [`resource_report()`](#resource_report). All three are dependency-free and safe to call once per client even when running many clients in one process. For CPU/custom attribution (e.g. per-session allocator tracking), see [`BotBuilder::with_task_instrument`](/api/bot#with_task_instrument) and [`BotBuilder::with_alloc_meter`](/api/bot#with_alloc_meter).

### stats

```rust theme={null}
pub fn stats(&self) -> StatsSnapshot
```

Cumulative wire I/O and activity counters for this client session, always recorded — no feature gate. Byte counts are post-noise wire bytes (frame headers and AEAD tags included; handshake and TLS/WebSocket overhead excluded), so sessions from different clients in the same process can be compared directly. Cheap to call: it's a copy of a handful of atomics.

**`StatsSnapshot` fields** (`#[non_exhaustive]`):

| Field                   | Type  | Description                                                                                                                                                                                                  |
| ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `bytes_sent`            | `u64` | Post-noise wire bytes written to the transport                                                                                                                                                               |
| `bytes_received`        | `u64` | Wire bytes received from the transport                                                                                                                                                                       |
| `frames_sent`           | `u64` | Encrypted frames written                                                                                                                                                                                     |
| `frames_received`       | `u64` | Decodable frames received                                                                                                                                                                                    |
| `messages_sent`         | `u64` | Outgoing message send attempts (DM/group/status)                                                                                                                                                             |
| `messages_received`     | `u64` | Incoming messages successfully decrypted and dispatched                                                                                                                                                      |
| `events_dropped`        | `u64` | Inbound events shed because a consumer's bounded delivery mailbox was full (opt-in [`EventDelivery::Ordered`](/api/bot#with_event_delivery)) — a non-zero, growing value flags a consumer that can't keep up |
| `reconnects`            | `u64` | Reconnect attempts started by the auto-reconnect loop                                                                                                                                                        |
| `reconnect_errors`      | `u32` | Consecutive reconnect failures (resets on success)                                                                                                                                                           |
| `resends_throttled`     | `u64` | Outbound resends dropped by the per-chat rate limiter — surfaces storm chats                                                                                                                                 |
| `last_data_received_ms` | `u64` | Timestamp (ms since UNIX epoch) of the last received data                                                                                                                                                    |

Most counters are monotonic over the client's lifetime and survive reconnects. `last_data_received_ms` is the exception: it resets on connection teardown. `reconnect_errors` also resets, to `0` on every successful reconnect (it counts *consecutive* failures, not a lifetime total).

<Note>
  **Breaking**: `last_data_sent_ms` was removed — nothing internal ever read it, and stamping it cost a clock read on every frame written (the client's hottest path, and a call out of the module on wasm32/embedded targets). `frames_sent` answers "is it still sending?"; there is no drop-in replacement for "when did I last write?" — an embedder that needs that timestamp should stamp it at its own send call site rather than have the wire path pay for it.
</Note>

**Example:**

```rust theme={null}
let stats = client.stats();
println!(
    "sent {} bytes / {} frames, received {} bytes / {} frames, {} reconnects",
    stats.bytes_sent, stats.frames_sent, stats.bytes_received, stats.frames_received, stats.reconnects
);
```

### memory\_report

```rust theme={null}
pub async fn memory_report(&self) -> MemoryReport
```

Entry counts plus estimated retained heap bytes for the client's internal collections. On-demand only — it walks the in-process caches under their locks when called, and costs nothing otherwise (unused report code is eliminated by fat LTO). Counts are approximate (caches may have pending evictions). Byte figures are honest estimates rather than byte-exact accounting — Signal records use their protobuf encoded size, other collections sum key/payload capacities — suitable for per-session attribution and leak detection. Store-backed caches (e.g. Redis-backed custom stores) report `bytes: 0`, since their entries don't live in this process's memory.

**`MemoryReport` fields** (`#[non_exhaustive]`):

| Field                                     | Type              | Description                                                                                                                                                                                                                              |
| ----------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `group_cache`                             | `CollectionStats` | Group metadata cache                                                                                                                                                                                                                     |
| `device_registry_cache`                   | `CollectionStats` | Device registry cache                                                                                                                                                                                                                    |
| `lid_pn_lid_entries`                      | `CollectionStats` | LID-to-PN mapping entries                                                                                                                                                                                                                |
| `lid_pn_pn_entries`                       | `CollectionStats` | PN-to-LID mapping entries (shares payloads with `lid_pn_lid_entries`; bytes here cover only entries the LID map no longer holds)                                                                                                         |
| `recent_messages`                         | `CollectionStats` | Recent message dedup cache                                                                                                                                                                                                               |
| `sender_key_device_cache`                 | `CollectionStats` | Per-group sender key device tracking cache                                                                                                                                                                                               |
| `group_devices_memo`                      | `CollectionStats` | Resolved group-devices memoization cache                                                                                                                                                                                                 |
| `dm_devices_memo`                         | `CollectionStats` | Resolved per-recipient DM-devices memoization cache (recipient + own-companion devices, partitioned, with their phash)                                                                                                                   |
| `message_retry_counts`                    | `u64`             | Message retry counter cache (count only)                                                                                                                                                                                                 |
| `undecryptable_dispatched`                | `u64`             | Undecryptable-message dedup cache (count only)                                                                                                                                                                                           |
| `pdo_pending_requests`                    | `u64`             | PDO pending request cache (count only)                                                                                                                                                                                                   |
| `pdo_requested`                           | `u64`             | PDO once-per-message memo cache (count only)                                                                                                                                                                                             |
| `session_locks`                           | `u64`             | Per-device session locks (count only)                                                                                                                                                                                                    |
| `chat_lanes`                              | `u64`             | Per-chat lanes — combined enqueue lock + message queue (count only)                                                                                                                                                                      |
| `group_distribution_locks`                | `u64`             | Live per-group sender-key distribution lanes (count only)                                                                                                                                                                                |
| `group_distribution_lock_evictions`       | `u64`             | Cumulative capacity evictions of cold (non-live) distribution lanes; poll successive reports to derive a rate                                                                                                                            |
| `group_distribution_lock_eviction_blocks` | `u64`             | Cumulative attempts that kept a live lane and temporarily exceeded `group_distribution_locks_capacity` instead of evicting it                                                                                                            |
| `resend_rate_limiter_chats`               | `u64`             | Chats tracked by the per-chat resend rate limiter (count only)                                                                                                                                                                           |
| `transport_ack_queue`                     | `usize`           | Deferred transport acks queued for the ack worker. Each entry retains the full inbound node plus a flush guard, so a stalled transport shows up here as a growing backlog ([#1116](https://github.com/oxidezap/whatsapp-rust/pull/1116)) |
| `delivery_receipt_queue`                  | `usize`           | Delivery receipts queued for their worker, same shape as `transport_ack_queue` ([#1116](https://github.com/oxidezap/whatsapp-rust/pull/1116))                                                                                            |
| `response_waiters`                        | `usize`           | Active IQ response waiters                                                                                                                                                                                                               |
| `node_waiters`                            | `usize`           | Active node waiters                                                                                                                                                                                                                      |
| `pending_retries`                         | `usize`           | Pending message retries                                                                                                                                                                                                                  |
| `presence_subscriptions`                  | `usize`           | Active presence subscriptions                                                                                                                                                                                                            |
| `app_state_key_requests`                  | `usize`           | Pending app state key requests                                                                                                                                                                                                           |
| `app_state_syncing`                       | `usize`           | Active app state sync operations                                                                                                                                                                                                         |
| `signal_sessions`                         | `CollectionStats` | Cached Signal sessions                                                                                                                                                                                                                   |
| `signal_identities`                       | `CollectionStats` | Cached Signal identities                                                                                                                                                                                                                 |
| `signal_sender_keys`                      | `CollectionStats` | Cached sender keys                                                                                                                                                                                                                       |
| `history_sync_tasks`                      | `CollectionStats` | Queued/running history-sync tasks and their logical compressed-payload byte sum. A shared `Bytes` slice may retain a larger backing allocation, whose capacity isn't exposed by the type                                                 |
| `history_sync_tasks_peak`                 | `u64`             | Lifetime high-water mark of queued/running history-sync tasks                                                                                                                                                                            |
| `history_sync_payload_bytes_peak`         | `u64`             | Lifetime high-water mark of logical compressed-payload bytes                                                                                                                                                                             |
| `chatstate_handlers`                      | `usize`           | Registered chat state handlers                                                                                                                                                                                                           |
| `custom_enc_handlers`                     | `usize`           | Registered custom encryption handlers                                                                                                                                                                                                    |

`CollectionStats` carries both `entries: u64` and `bytes: u64`. `MemoryReport::total_estimated_bytes(&self) -> u64` sums `.bytes` across every byte-carrying field. `MemoryReport` implements `Display` for a pretty-printed, human-readable breakdown. This output includes an `--- In-flight history sync ---` section with the two peak fields above.

**Example:**

```rust theme={null}
let report = client.memory_report().await;
println!("{report}"); // pretty-prints every collection's count and bytes
println!("total estimated bytes: {}", report.total_estimated_bytes());
println!(
    "signal sessions: {} entries, {} bytes",
    report.signal_sessions.entries, report.signal_sessions.bytes
);
```

<Note>
  `Client::stats()`, `MemoryReport`, `CollectionStats`, and `StatsSnapshot` were introduced to replace the old `debug-diagnostics`-gated `memory_diagnostics()` / `MemoryDiagnostics`, which have been removed. `CollectionStats`, `MemoryReport`, and `StatsSnapshot` are re-exported from the `whatsapp_rust` crate root.
</Note>

### resource\_report

```rust theme={null}
pub async fn resource_report(&self) -> ResourceReport
```

Unified per-session resource estimate: [`memory_report()`](#memory_report)'s client-only collections **plus** the components that live *outside* the `Client` and dominate real per-session RAM — the storage backend's page cache, the transport's buffers and TLS/noise state, and the HTTP client's connection pool. When an [`AllocMeter`](/api/bot#with_alloc_meter) is installed via [`BotBuilder::with_alloc_meter`](/api/bot#with_alloc_meter), the report also folds in an allocation-churn snapshot.

On-demand only, no hot-path cost. Each out-of-client figure is best-effort — a component reports only what it can introspect, so absent (`None`) means "not reported", not "zero". `resource_report()`'s future is `Send`, so multi-session consumers can await it off a worker task (e.g. from an axum handler).

**`ResourceReport` fields** (`#[non_exhaustive]`):

| Field       | Type                              | Description                                                                                                                                |
| ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `client`    | `MemoryReport`                    | Identical to [`memory_report()`](#memory_report)'s result                                                                                  |
| `storage`   | `StorageResourceReport`           | Storage-backend footprint (e.g. SQLite page cache). All-`None` for backends that don't report                                              |
| `transport` | `Option<TransportResourceReport>` | Transport read/write buffers plus a TLS/noise state estimate, if the transport reports them                                                |
| `http`      | `Option<HttpResourceReport>`      | HTTP connection-pool and in-flight footprint, if the client reports it                                                                     |
| `alloc`     | `Option<AllocSnapshot>`           | Allocation churn attributed via an installed `AllocMeter`; excluded from `total_estimated_bytes()` since it's churn, not a retained figure |

`StorageResourceReport` fields: `memory_bytes: Option<u64>` (retained bytes; `Some(0)` for remote/store-backed backends whose data isn't process memory), `pages: Option<u64>` (backing page/entry count), `io_read_bytes` / `io_write_bytes: Option<u64>` (cumulative I/O, when counted).

`TransportResourceReport` fields: `read_buffer_bytes`, `write_buffer_bytes`, `tls_state_bytes` — all `Option<u64>`.

`HttpResourceReport` fields: `pool_connections: Option<u64>`, `pool_buffer_bytes: Option<u64>`, `inflight_bytes: Option<u64>`.

`ResourceReport::total_estimated_bytes(&self) -> u64` sums the **retained** components (client + storage + transport + HTTP). Treat it as a best-effort retained estimate, not a strict lower bound: unreported fields (`None`) are treated as `0`, so components that cannot fully introspect their footprint are silently undercounted — but the storage figure is itself a `min(cache cap, db size)` *upper* bound on the SQLite page cache, and can overstate actual heap residency when [`mmap_size`](/api/store#database-configuration) is enabled (see the caveat there), so the total can run either high or low depending on configuration. `alloc` (churn) is deliberately excluded. `ResourceReport` implements `Display` for a pretty-printed breakdown alongside `memory_report()`'s.

**Example:**

```rust theme={null}
let report = client.resource_report().await;
println!("{report}");
println!("estimated retained bytes: {}", report.total_estimated_bytes());

if let Some(alloc) = report.alloc {
    println!("allocated (churn): {} bytes", alloc.allocated_bytes);
}
```

<Note>
  Storage, transport, and HTTP reports are supplied by the trait implementations behind `Client` — see [`DeviceStore::resource_report`](/api/store#resource_report), [`Transport::resource_report`](/api/transport#resource_report), and [`HttpClient::resource_report`](/api/http-client#resource_report). `AllocSnapshot`, `StorageResourceReport`, `TransportResourceReport`, and `HttpResourceReport` are re-exported from `wacore::stats`; all four are also re-exported from the `whatsapp_rust` crate root.
</Note>

## Error Types

```rust theme={null}
pub enum ClientError {
    NotConnected,
    Socket(SocketError),
    EncryptSend(EncryptSendError),
    NotLoggedIn,
}
```

<Note>
  As of PR #1090, `connect()`/`wait_for_socket()`/`wait_for_connected()` return [`ConnectError`](/api/errors#connecterror) (`ClientError::AlreadyConnected` was removed in favor of `ConnectError::AlreadyConnected`), and `rotate_signed_pre_key()`/`flush_pending_signal_state()` return [`SignalMaintenanceError`](/api/errors#signalmaintenanceerror). See [Error Types](/api/errors) for the full reference across the crate.
</Note>

***

## See Also

* [Bot](/api/bot) - High-level builder with event handlers
* [Events](/concepts/events) - Event system and types
* [Sending Messages](/guides/sending-messages) - Sending and receiving messages
* [Group Management](/guides/group-management) - Working with groups
