Skip to main content
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
Most users should use the Bot builder instead of creating a Client directly. The Bot provides a simplified API with sensible defaults.

Creating a client

Configure the low-level builder with runtime, persistence manager, transport factory, and HTTP client. Missing dependencies return ClientBuilderError.
ClientBuild::into_client() starts the default major-sync worker. To own that worker, use into_parts() and drain the returned receiver with process_sync_task. The low-level builder starts no periodic saver by default. BotBuilder starts with a 30-second saver. ClientOptions is extensible. Start with Default and assign fields; do not construct a literal. It shares version, cache, history-sync, AB props, presence, Noise verification, prekey, resend, and saver policy across builders. with_options on Client and with_client_options on Bot replace the entire configuration, including saver policy. Later individual setters override that value. Dependencies and callbacks remain separate.

Connection Management

run

Supervise connection and reconnection until terminal shutdown or a classified exit. Keep the returned outcome: ShutdownRequested, AutoReconnectDisabled { connection, connect_error, protocol_error }, Stopped, or AlreadyRunning. Match extensible enums with a fallback arm. ProtocolTerminalReason distinguishes stream codes, connect failures, and conflicts. ConflictKind distinguishes Replaced, DeviceRemoved, and Unknown; an unknown conflict is not proof of credential removal.

Connect admission

with_connect_admission and with_connect_admission_arc install a fast synchronous ConnectAdmission::delay() -> Duration callback. The run loop consults it before each dial, including the first and forced reconnects, after normal backoff and pause checks. Its delay is outside connect timeouts. Direct connect() calls bypass it. Pause or shutdown abandons the reservation; resume asks for a new decision. There is no refund callback. Keep host budget bookkeeping local and nonblocking. The library retains its reconnect and rate-limit penalties.

connect

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). Drive the returned connection with client.connect().await?.read_until_disconnected().await, or use client.run().await to supervise reconnects. Merely opening a socket does not process server frames. 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)
  • Shutdown - added in PR #1258. The client has already been shut down (shutdown(), logout(), or signal_shutdown_sync()); shutdown is final, so build a new client rather than reconnecting this one
  • Paused - added in PR #1265. pause() is in effect. Unlike Shutdown this is not final — resume() lifts it and connect() works again. connect() rechecks the pause at every checkpoint of the connect graph, so an attempt already in flight when pause() lands is retracted rather than published.
  • 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_ready()/wait_for_session_ready() 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 for the full variant reference.

Connection

Added in PR #1258. An established connection that nothing is reading yet — connect() stops right after the handshake, so the frames the server sends next just sit in the transport channel until something drives them. read_until_disconnected() is that read: it decodes frames into nodes and events until the connection ends, tears the connection down, and returns the reason an unexpected end carried (the same reason dispatched as Event::Disconnected just before returning), or None when the end was not one to report — a requested disconnect, or a protocol step like the 515 that follows pairing. run() performs the same read inside its reconnect loop; reach for Connection directly only when a session must not outlive its first connection.
Dropping a Connection without reading it logs a warning and leaves it unread — the socket stays open and the client keeps reporting itself connected, so the next connect() is refused until shutdown() releases it. Dropping the read_until_disconnected() future mid-read (e.g. a wrapping timeout) stops reading without tearing the connection down either, matching what dropping run()’s future has always done.

logout

Attempt companion deregistration, then perform terminal cleanup even when that request fails. Inspect deregistration separately from shutdown: Confirmed, Failed { source }, or NotAttempted with an offline or missing-identity reason. Confirmation is an IQ outcome, not proof that local credentials were erased.

shutdown

Shutdown is terminal; create a new client for a new session. The report exposes inbound, outbound, device, signal_settle, and message_secrets. Inspect storage errors and timeout/interruption outcomes. A completed inbound drain can leave durable replay rows when a hook fails; it does not mean callbacks succeeded. Outbound guards draining does not prove message delivery. This invocation does not join detached host tasks or callback workers. pause() is the reversible way to stop a connection.

store_release

The sticky StoreRelease signal observes release of crate-owned backend holds, including detached saver work. Drop client and persistence-manager owners before waiting. Host backend clones, returned device snapshots, backend-owned I/O, and other stores can outlive this signal. Inspect cleanup results separately; release is not a durability verdict or database-close guarantee.

signal_shutdown_sync

Synchronous, flag-only variant of shutdown() 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 shutdown() 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

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:

reconnect_immediately

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:

pause

Added in PR #1265. Drops the current connection and keeps it down until resume(). It is the middle of the range between reconnect(), which comes back on the library’s schedule, and shutdown(), which does not come back at all. The run() supervision loop stays alive and parked, so the future a caller is awaiting keeps running. The client is not terminal; it is between connections, on purpose — provided enable_auto_reconnect is still set, see the warning below. Once pause() returns, the socket is closed and pending receipts and Signal state are flushed on the same terms as shutdown(). No connection will be opened by anyone until resume(): connect() rechecks the pause at every step of the connect graph — version fetch, transport open, handshake, and the final publish — and refuses with ConnectError::Paused for as long as it holds. An attempt already in flight when pause() lands is retracted rather than published, not merely refused as of the next attempt. pause() is idempotent — pausing an already-paused client just tears down again.
pause() dispatches no Event::Disconnected (the application ended this connection, so the teardown is not news — the same reasoning reconnect() applies), and it is not a protocol-level presence change (the account stays registered, other devices see nothing).
pause() does not override enable_auto_reconnect. If it was already false when pause() tore down a live connection (or interrupted an in-flight connect()), the run loop’s own auto-reconnect check runs before its pause handling — so the loop exits entirely, the same as an ordinary auto-reconnect-disabled disconnect, and the client becomes terminal. resume() afterward only clears the pause flag; there is no running loop left for it to wake.
Example:

resume

Added in PR #1265. Releases a pause(): the run loop reconnects at once, with no backoff owed for the offline window the application chose — provided run() is still driving the client with enable_auto_reconnect set (see the warning under pause(); a pause that landed while auto-reconnect was disabled has already ended the loop, and resume() has no loop left to restart). Returns once the loop has been told, not once it is connected — wait for that with wait_for_session_ready(). A true no-op — no state change, no log, no notification — only on a client that was not paused to begin with. Calling it on a client that has since been shutdown()ed still clears the pause (is_paused() becomes false) and fires the session-state notifier, but that is all it does: it does not undo the shutdown or bring back a connection. is_terminal() stays true, and the next connect() still refuses with ConnectError::Shutdown. Safe to call while a pause() is still tearing down; it does not wait for the teardown to finish, since that teardown ends in an untimed socket close.

is_paused

Added in PR #1265. Whether pause() is in effect and no connection will be opened by run() until resume().

wait_for_socket_ready

Waits for the Noise socket to be ready (before login). Useful for pair code flows.
Duration
required
Maximum time to wait
Result<(), ConnectError>
Ok if socket ready, ConnectError::Timeout { stage: ConnectStage::Socket, timeout } on timeout

wait_for_session_ready

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.
Duration
required
Maximum time to wait
Result<(), ConnectError>
Ok once fully ready, ConnectError::Timeout { stage: ConnectStage::Ready, timeout } on timeout
As of PR #1090, both methods return ConnectError instead of anyhow::Error.

pair_with_code

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.
One code at a time. Fails with PairCodeError::CodeAlreadyOutstanding while a previous code is still outstanding, instead of silently replacing it. “Outstanding” means either the previous code’s validity window hasn’t elapsed yet, or its primary_hello was already accepted and a pair-success for it is still pending — that second case can outlast the validity window by up to a minute, and remaining reads as 0 for it since there’s no window left to report. A second code does not replace the first for the phone: the server routes primary_hello by number and never sees the code itself, so whoever is still reading the older one reaches stage 2 regardless. Call cancel_pair_code first when the replacement is intentional. Do not call this on a schedule driven by QR-code rotation — the two flows have unrelated lifetimes. See One code at a time.
On any failure other than CodeAlreadyOutstanding or Cancelled, this also dispatches Event::PairingCodeError before returning the Err — the only surface BotBuilder::with_pair_code can report through, since that path drives this call from a detached task. A direct caller sees the failure both ways: as the returned Err and, unless it’s one of those two exclusions, on the event bus.
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 PlatformTypes 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>).
String
The 8-character pairing code to display to the user
Errors (PairError): PairError::PairCode(PairCodeError) covers validation and crypto failures; PairError::RequestFailed(IqError) covers the IQ transport. PairError also exposes the server’s refusal as a typed status, so a consumer doesn’t have to match the message:
See Pair code failure events for PairCodeRejection’s five named variants (BadRequest, Forbidden, RateOverlimit, FeatureNotAvailable, InternalServerError) plus its Unknown(i32) fallback, and for is_throttled(). Example:

cancel_pair_code

Abandons the outstanding pair-code flow, if any — the explicit reset pair_with_code requires before it will mint a replacement (WA Web’s initializeAltDeviceLinking()). A no-op when no flow is outstanding.
Reliable on both sides of primary_hello. If no primary_hello has been accepted yet, cancellation is immediate and complete — a later primary_hello for the cancelled ref is dropped rather than answered. Stage 2 (deriving the key bundle and sending companion_finish) runs under the same lock cancel_pair_code takes, so the two never interleave mid-derivation: either cancel_pair_code wins and stage 2 finds the flow gone before sending anything, or stage 2 has already sent companion_finish and released the lock by the time cancel_pair_code gets a turn. In that second case, cancelling also re-mints the device’s adv_secret_key — the value stage 2 derived and persisted is keyed to a primary that was just told to stop, so a pair-success that still arrives for it now fails signature verification instead of silently completing the link. A flow that already reached PairCodeState::Completed is left untouched, since that secret belongs to a device that did pair.
Example:

set_passkey_authenticator

Enable the passkey feature before calling set_passkey_authenticator, send_passkey_response, or send_passkey_confirmation below — they don’t exist without it (opt-in, off by default). See Feature flags and Authentication — Passkey linking.
Registers a PasskeyAuthenticator for passkey (SHORTCAKE_PASSKEY) linking. 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.
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.

send_passkey_response

Sends the WebAuthn assertion as <passkey_prologue> and opens the ephemeral-identity handshake. Call after an Event::PairPasskeyRequest. Returns PasskeyError::Flow if a passkey open is already in progress.

send_passkey_confirmation

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 — 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):

Connection State

is_connected

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

is_logged_in

Returns true if authenticated with WhatsApp servers.

reachability

Added in PR #1332. What the client’s connection state means for work handed to it right now — see Reachability below. It’s the single place that state is turned into an answer. A caller no longer has to assemble one from is_connected(), is_logged_in(), and is_paused() individually, because those flags don’t compose safely on their own. A stream-error teardown sets its terminal condition before it clears the session. is_logged_in becomes true one instruction before the connection generation it authenticates. And a rate-limited (429) session keeps its socket open while no longer being reachable. Reads flags only — no lock, no allocation — so it costs the same whether the answer is acted on or discarded.
A refused call (e.g. ClientError::NotConnected) and reachability() answer different questions. The error is a fact about one attempt, and it may already be stale by the time it’s read — the connection could be lost right after a call was admitted, or restored right after one was refused. reachability() is state, re-read on demand. It’s what tells “this comes back on its own” (Reachability::Reconnecting) apart from “stop trying” (Reachability::Finished) — a distinction the error alone can’t make, since a client between connections and a finished one refuse a call with the identical message.

wait_until_reachable

Added in PR #1332. Waits until the client can reach the server again, or until waiting stops being the right answer. Returns the Reachability that ended the wait: Reachability::Reachable once a connection arrives, otherwise the reason none will without the caller doing something about it. Reachability::Reconnecting is never returned — it’s the one state this call waits out. Bounded by the client’s own lifetime rather than by a duration. The reconnect backoff is jittered, capped at 900s, and followed by a handshake, so no constant here would be honest. Cancellation is the caller’s to apply: drop the future, or wrap it in your own tokio::time::timeout. Waiting restores the ability to ask. It never re-sends the request that was refused. Issue the call again after this returns. The answer can already be stale by the time that next call reaches the socket.
Safe from an on_event/on_event_for closure: those run off a spawned or drainer task, never on the read loop. Not safe from a with_event_handler/EventHandler::handle_event implementation — that dispatches synchronously, inline on the read loop, so blocking on this call there (e.g. via a nested runtime) blocks the very connection it’s waiting for.
A pause() ends this wait rather than being sat through. resume() is what actually brings the client back, and that’s the application’s call to make — not something to park through indefinitely while holding the Arc<Client> whose drop would otherwise have been the only other way out. Example: wait_until_reachable() only has something to offer when the connection is what’s wrong. Gate the retry on ErrorChainExt::is_transport_unavailable() rather than on any Err, so a validation or authorization failure gets handled or propagated directly instead of parked behind a connection wait that can’t resolve it:

Reachability enum

Added in PR #1332. Reported by reachability() and settled by wait_until_reachable(). Re-exported from the crate root as whatsapp_rust::Reachability. Variants:
  • Reachable — a request sent now has a socket, an authenticated session, and a reader to decode the answer.
  • Reconnecting — between connections, with something driving the client back to one: the normal auto-reconnect backoff, a 429’s extended backoff, or a 503’s normal backoff (see the stream error table — 503 is service-unavailable, not rate limiting). Covers a first connection that hasn’t landed yet as well as a session being restored, and deliberately doesn’t separate them: every marker the client holds (connection_generation, login_counter, and the rest) is a fact about its past, and the question is about its future, and those don’t line up in either direction — a device that authenticated yesterday and has since been revoked reports as “restoring a session” by every persistent marker and will never connect again, while a client whose first attempt lands during a brief outage reports as “never connected” and connects on the next. So this state asserts only that an attempt is being made, never that one will land — a Noise handshake torn down before <success> arrives reads identically to a server that’s momentarily unreachable and is retried the same way, so in principle a wait on this can run for as long as the process does. The one state wait_until_reachable() waits out; only reachability() ever reports it.
  • Paused — pause() is in effect. Like Reconnecting the client is not finished, but what ends it is resume(), and only the application knows when that comes. So wait_until_reachable() returns here instead of sitting through it. The connect path’s own internal wait — used by things like app-state resync — does sit through a pause, since nothing on the next connection would re-issue that work otherwise.
  • Unsupervised — nothing is reading this client: no run() loop is driving it. No answer would ever be decoded and no reconnect will be attempted. Not terminal — the application may still use the connection directly — but waiting cannot fix it. Outranks Paused where both hold, since a paused client with no reader has nothing to reconnect it either way.
  • Finished — the session is over for good: shut down, logged out, replaced, or refused in a way no reconnect recovers.
is_reachable() is true only for Reachable. recovers_on_its_own() is true only for Reconnecting — false for Paused, since that client does come back, but on resume() rather than by itself. It’s an expectation rather than a promise, for the same reason given above: true says something is driving the client back, not that the cause is one a retry actually fixes, since the client has no way to tell those apart from where it sits.

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

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

Stream error handling

The client handles specific <stream:error> codes from the WhatsApp server:
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.

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. As of #1263, the client also dispatches an Event::StreamError for 429 (code "429"). WhatsApp Web’s own handler gives no UI signal for this case — it only special-cases 500..600. An embedder has no UI to fall back on, so 429 is now reported the same way every other coded stream error is. This event fires in addition to Event::Disconnected, not instead of it. The 429 handler never marks the disconnect as expected, so the shared connection-loss path still dispatches Disconnected once the socket closes, the same as every other unexpected drop.

General reconnection behavior


Messaging

Use send(SendRequest) and edit_message(EditRequest) for configurable operations. send_message, send_text, and forward_message are default shortcuts. Reactions, revokes, keep, pin, and unpin use MessageRef; channel actions use NewsletterMessageRef. See sending for typed IDs, request construction, raw interop paths, and result semantics.

Feature APIs

The Client provides namespaced access to feature-specific operations:

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:

bots

Access the server’s directory of first-party AI bots. Methods:
  • list() - Fetch the bot directory
See Bots API for full documentation.

groups

Access group management operations. Methods:
  • list_participating() - List all groups you’re in as slim GroupOverview records
  • fetch_overviews(jids: &[Jid]) - Batch fetch slim GroupOverview records for a chosen subset
  • fetch_metadata(jid: &Jid) - Fetch full group metadata from server
  • resolve_participant_addresses(&mut GroupMetadata) - Opt-in LID → PN backfill on fetched metadata
  • 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 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
  • fetch_metadata_batch(jids: &[Jid]) - Batch fetch full group metadata for multiple groups
  • get_profile_pictures(group_jids: Vec<Jid>, picture_type: PictureType) - Batch fetch group profile pictures
For contact, group and community picture lookup, use client.pictures().lookup(ProfilePictureRequest). Example:

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:

chatstate

Access chat state (typing indicator) operations. Methods:
  • send(to: &Jid, state: ChatActivity) - 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:

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

tc_token

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

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(target: &MessageRef) - Star a message
  • unstar_message(target: &MessageRef) - 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(target: &MessageRef, delete_media: bool, message_timestamp: Option<i64>) - Delete a message locally (not for the other party)

quick_replies

Access WhatsApp Business quick reply operations. Operations sync across all linked devices via app state sync. Methods:
  • set_quick_reply(id: &str, shortcut: &str, message: &str, keywords: Vec<String>, count: i32) - Create or update a quick reply
  • delete_quick_reply(id: &str) - Delete a quick reply
See Quick Replies API for full documentation.

app_state_settings

Access account-wide settings synced via app state rather than the set_privacy IQ namespace. Methods:
  • set_link_previews_disabled(disabled: bool) - Turn outgoing link previews off or on for the whole account
See Privacy API — App-state settings for full documentation.

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 for full documentation.

mex

Access Meta Exchange (GraphQL) operations. Methods:
  • execute(request: MexRequest<V>) - Execute a descriptor-bound query or mutation
  • fetch_new_chat_message_capping_info() - Fetch the new-chat message cap for the current cycle
See MEX API for full documentation.

profile

Access profile operations. Methods:
  • set_push_name(name: &str) - Set display name and return PushNameOutcome; inspect pending app-state synchronization
  • sync_push_name(name: &str) - Retry only app-state synchronization for the still-desired name
  • 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

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(&NewsletterMessageRef, reaction) - React using a channel server-message reference
  • get_messages(jid, count, before) - Fetch newsletter messages
  • subscribe_live_updates(jid: &Jid) - Subscribe to real-time updates
Newsletter message sending is handled by the unified client.send_message() method — pass a newsletter JID and the message is sent as plaintext automatically. See the Send API for details.
Example:
See Newsletter API for full documentation.

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:
See Community API for full documentation.

polls

Access poll operations. Methods:
  • create(to, name, options, selectable_count) - Return CreatedPoll, retaining its send result, crypto creator and validated secret
  • vote(&PollRef, selected_options) - Cast a vote using the creation reference
  • decrypt_vote(ciphertext, &PollRef, voter) - Decrypt option hashes using that same reference
  • vote_raw and decrypt_vote_raw - Accept explicit creation metadata at an interoperability boundary
  • aggregate_votes(poll_options, votes, message_secret, poll_msg_id, poll_creator_jid) - Tally updates through the client, including known PN/LID aliases
Example:
See Polls API for full documentation.

media_reupload

Recover expired media with a validated MessageRef and &[u8; 32] media key:
request_many runs up to 32 distinct operations and restores input order and cardinality. Duplicate subscribers do not consume extra operation slots. Identical requests share one recovery; conflicting targets or keys with the same ID return Conflict. The request requires the local LID, waits for a receipt ACK, then waits for the media-retry notification. See media reupload for cancellation, timeout, and result handling.

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:
These are low-level APIs that bypass the high-level message sending pipeline. Most users should use send_message() which handles encryption automatically.
See Signal API for full documentation.

query_usync

Executes a typed USync (“user sync”) query directly — the same protocol engine that powers contacts() and signal().get_user_devices() 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 for the full UsyncQuery/UsyncResponse model and examples.

Service and configuration access

Client implementation state is private. Use http_client() to borrow the injected Arc<dyn HttpClient>, auto_reconnect_enabled() and set_auto_reconnect(bool) for reconnect preference, and has_enc_handler(&str) to inspect installed custom encryption handlers. Configure those handlers through the builder; they are immutable after construction. Disabling reconnect wakes backoff but does not close the current socket. Re-enabling does not clear terminal shutdown or a protocol verdict.

Client Profile

The noise-handshake ClientPayload.UserAgent identity that this client presents to WhatsApp servers. The default is ClientProfile::web(), 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

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.
ClientProfile
required
The profile to apply. Use the constructors on ClientProfile — web(), android(os_version), smb_android(os_version), ios(os_version), macos(os_version), windows(os_version).
Example:
Native profiles (android, smb_android, ios, macos, windows) automatically omit web_info from the ClientPayload. Only web() includes it.

Device State

push_name

Returns the current push name (display name).
Renamed from get_push_name — the get_ prefix was dropped to match the neighboring accessors.

pn

Returns the phone number JID, or None before pairing completes.
Renamed from get_pn.

lid

Returns the LID (Linked Identity), or None before pairing completes.
Renamed from get_lid.

is_lid_migrated

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). 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 and Authentication — one-to-one LID migration state.
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.

get_lid_pn_entry

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.
&Jid
required
The JID to look up — either a PN JID or a LID JID
Option<LidPnEntry>
Contains lid (Arc<str>), phone_number (Arc<str>), created_at (i64 Unix timestamp), and learning_source (LearningSource). Use &*entry.lid for &str comparisons or pass directly to anything that accepts AsRef<str>.
Example:
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.

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.
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), 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.
History sync’s own harvesting logic collapses its two mapping sources to one pair per LID before either reaches this policy. The bulk block outranks a conversation-derived pair naming the same LID or phone number. Ties within one source keep the first occurrence. See PN/LID mapping cache — LID-PN mappings (#1345).

Refresh on refresh_lid acks

A send-message ack can carry refresh_lid="true", the server’s way of telling the client that the LID-PN mapping it holds for the acking peer is stale. It’s the only invalidation the client gets for this mapping: lookups are cache-aside, so nothing else re-checks an entry just because it might have gone stale. Without acting on the flag, a stale mapping would stay stale for the process lifetime, and every Signal address derived from it would keep resolving to the wrong identity (#1313). On a flagged ack, the client re-resolves the peer with the same authoritative live query an observational LearningSource conflict uses: LidQuerySpec, requesting only <lid>. It persists the corrected pair through add_lid_pn_mapping under LearningSource::Usync. A few details worth knowing if you’re watching this happen:
  • The query is addressed by phone number, not by the LID under suspicion. Asking about the LID itself would look the answer up under the very key that might be wrong. A peer with no cached mapping yet has nothing stale to correct, so it triggers no query.
  • Hosted peers (@hosted/@hosted.lid) are refreshed the same way as @lid/@s.whatsapp.net peers, since they resolve their Signal address from the same cache.
  • A burst of sends to one stale peer acks one message at a time, and every ack repeats the flag. Refreshes for the same number are coalesced, so a second flagged ack while one is already in flight does not start a duplicate query. The coalescing key is scoped to the connection that started the refresh, so a task still parked on a dead socket’s IQ can never suppress the next connection’s refresh for the same peer.
  • In-flight refresh reservations are visible via pending_lid_refreshes in memory_report().

persistence_manager

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

Use Client::builder().build().await? to obtain ClientBuild. into_client() starts the default major-sync worker. Use into_parts() only when your host owns the sole receiver and drains it:
process_sync_task handles history sync and app-state tasks. Failing to drain this receiver prevents major sync from progressing. Bot installs the worker automatically.

set_skip_history_sync

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

Returns true if history sync is currently being skipped.

set_wanted_pre_key_count

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!.
usize
required
Pre-keys per upload batch. Clamped to 5..=65_535.
Example:
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.

wanted_pre_key_count

Returns the currently configured pre-key upload batch size.

set_force_active_delivery_receipts

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

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.

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 for details.

Stall timeout

If the server advertises offline messages but stops sending stanzas before the end marker arrives, while the connection itself stays up, an inactivity watchdog forces completion instead of leaving the drain open indefinitely (added in PR #1380, mirroring WhatsApp Web’s own stall timer). The watchdog re-arms on every offline stanza, so it fires somewhere between 60 and 120 seconds after the last one, not at a fixed 60 seconds. On expiry:
  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)

Interrupted resume

If the connection ends before the drain finishes — rather than the end marker arriving or the stall timer firing — the resume is never left silent. It is reported once as OfflineSyncInterrupted { total, delivered } instead of OfflineSyncCompleted (added in PR #1380). The event doesn’t by itself say what gets redelivered — that follows the pre-existing commit-batch ack contract; see Inbound Durability → Batching for exactly which batch a mid-drain disconnect does and doesn’t redeliver the next time the client reconnects and gets a fresh OfflineSyncPreview.

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 — reported first as an interrupted resume, above, if a drain was still active. Related events: OfflineSyncPreview, OfflineSyncCompleted, OfflineSyncInterrupted

App State

fetch_props

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 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 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.
The cache exposes:

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). To gate your own code on a flag the library doesn’t already watch, register it on the builder before the first fetch_props(). ClientBuilder::with_watched_ab_props (and the matching BotBuilder::with_watched_ab_props) accumulate across calls and take any IntoIterator<Item = AbProp>, so you can register single flags or whole slices:
Flags retain their code, value_type, and default straight from the WA Web bundle, so behavior tracks WhatsApp Web without hand-maintained config tables.

ab_prop_enabled

Reads a watched boolean A/B prop as the server sent it. Use this to gate application behavior on the same server flags WhatsApp Web reads — for example AURA_PINNED_CHATS_BENEFIT_ACTIVE, the WhatsApp Plus pinned-chats-limit gate. Returns None when the client holds no value for prop:
  • The prop was not registered with ClientBuilder::with_watched_ab_props, so the cache dropped it while the catalog streamed in.
  • The catalog has not been fetched yet on this connection. fetch_props() runs automatically on connect unless with_ab_props_fetch(false) turned it off.
  • The server did not include prop in its response.
Unlike AbPropsCache::is_enabled, ab_prop_enabled does not substitute the registry default, so a caller can tell “off” from “unknown”. A cached value is truthy when it equals "1", "true", or "enabled" (case-insensitive).
AbProp
required
Typed flag from wacore::iq::abprops.
Example:
The AB props cache itself is internal — the library automatically checks relevant flags when performing group operations like create_group and add_participants. You only need with_watched_ab_props + ab_prop_enabled when your application needs to read a server gate the library doesn’t already watch.

Disabling the fetch

ClientBuilder::with_ab_props_fetch(bool) controls whether fetch_props() runs at all on connect; the default is true. The catalog fetch is now streamed (see execute_streaming), so on a host it costs nothing worth turning off. The switch exists for a target whose whole heap can’t afford even the compressed frame plus the inflate state at the moment it arrives. Turned off, no abt request is sent — whatsmeow never sends one either, and the server accepts both. Every flag then reads as its registry default: an account the server has 1:1-LID-migrated is not recognized as such, and the privacy-token and trusted-contact-token gates run on their defaults instead of the server’s values.

fetch_privacy_settings

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

set_privacy_setting

Sets a privacy setting for a specific category using type-safe enums.
PrivacyCategory
required
Privacy category enum: Last, Online, Profile, Status, GroupAdd, ReadReceipts, CallAdd, Messages, or DefenseMode
PrivacyValue
required
Privacy value enum: All, Contacts, None, ContactBlacklist, MatchLastSeen, Known, Off, or OnStandard
Example:
See Privacy API for all categories, values, and valid combinations.

set_privacy_disallowed_list

Updates a privacy category’s disallowed list (contacts-except-specific-users mode). Only available for Last, Profile, Status, and GroupAdd. See Privacy API for details and examples.

set_default_disappearing_mode

Sets the default disappearing messages duration for new chats.
u32
required
Timer duration in seconds. Common values: 86400 (24 hours), 604800 (7 days), 7776000 (90 days). Pass 0 to disable.
Example:

get_business_profile

Fetches the business profile for a WhatsApp Business account. Returns None if the account is not a business account or has no business profile.
&Jid
required
JID of the business account to query
Example:
See Business API for full type details.

business

Access business catalog, collections, order lookup, and business-profile writes. Reading a business’s profile stays on get_business_profile above — business() covers everything else. Methods:
  • get_catalog(jid: &Jid, options: &CatalogOptions) - Fetch one page of a business’s product catalog (MEX)
  • get_collections(jid: &Jid, options: &CollectionOptions) - Fetch one page of a business’s collections, products inline (MEX)
  • get_order(jid: &Jid, order_id: &str, token: &str) - Look up an order’s line items and totals (MEX)
  • update_profile(update: &BusinessProfileUpdate) - Apply a delta to your own business profile (IQ)
  • set_cover_photo(upload: CoverPhotoUpload) - Point the profile at an already-uploaded cover photo (IQ)
  • remove_cover_photo(id: &str) - Remove the profile’s cover photo (IQ)
Example:
See Business API for full documentation.

clean_dirty_bits

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

request_syncd_snapshot_recovery

Asks the primary device to resend an app-state collection whose snapshot failed local MAC verification here — the escalation the client also triggers automatically on a SnapshotMACMismatch/SnapshotMACMissing apply failure. See Peer snapshot recovery for the full mechanism, including why this is safe against a collection whose value encryption can’t be followed. Fire-and-forget: the primary’s reply arrives later as an ordinary peer message and is applied then, dispatching mutations through the same events an ordinary sync uses. On success returns the request id the reply will be correlated by (or an empty string if a request for this collection was already outstanding); an Err means the ask itself was refused or couldn’t be sent — not that the primary declined to answer.
&str
required
App-state collection name (e.g. "regular", "regular_low"). Refused with an Err for "critical_block" (never recovered this way — rebuilding the block list from a possibly-behind primary risks messaging someone who was blocked) and for any name this client doesn’t recognize. Also refused when the account has the enable_peer_snapshot_recovery ab-prop explicitly turned off.

resync_app_state

Ask the server to re-sync one or more app-state collections. Every other app-state sync in the client is raised by the server (bootstrap, server_sync notifications, dirty bits). This is the escape hatch a consumer raises itself when its own mirror of chat metadata (mutes, pins, archives, contacts, labels, locks) has drifted from the server’s copy.
impl IntoIterator<Item = WAPatchName>
required
The collections to re-sync. Duplicates are asked for once. An empty iterator is a no-op and never reaches the wire. Passing WAPatchName::Unknown returns AppStateError::InvalidRequest.
AppStateResyncMode
required
Incremental asks for patches recorded after the persisted version — the cheap repair for when the client’s own store is behind. Snapshot discards what is stored and rebuilds the collection from the server’s snapshot, replaying every mutation the server still holds with from_full_sync: true. Use Snapshot when a consumer’s mirror drifted from the client’s store (a handler that failed, an event dropped on a full mailbox, a database restored behind the client’s). See Consumer replay caveats below.
Returns: AppStateResyncReport, which reports per-collection outcome (synced, fatal, retryable, skipped). Err is reserved for the request never reaching the server at all (NotConnected, Internal, InvalidRequest). Example:
This waits for a usable connection before asking anything, bounded only by the client’s lifetime. Bound it with a timeout if a caller must, but bound the wait, not the whole call: dropping the future once a response is being applied cancels between the writes that replace a collection’s baseline. Nothing is lost that a later sync cannot rebuild, but a cancelled apply is not a clean stop.resync_app_state does not retry on its own. What a run left retryable comes back in the report; re-asking in a tight loop is how a client earns a 403. Space attempts out, and treat fatal as an answer rather than as a failure to retry.

resync_app_state_collection

Convenience wrapper around resync_app_state for the common case: replay one collection from the beginning by discarding the stored version and rerunning the full sync.
Equivalent to resync_app_state([name], AppStateResyncMode::Snapshot).
WAPatchName
required
The collection to replay. Passing WAPatchName::Unknown returns AppStateError::InvalidRequest.
When to use it:
  • A consumer added a handler for an event kind (for example, LockChatUpdate) it did not have before, and needs to replay past mutations the server still holds — an incremental sync alone would send nothing, because the client’s store is already up to date.
  • A local mirror was rebuilt from scratch or restored from a backup and needs the server’s snapshot to reseed it.
Example:

Consumer replay caveats

A snapshot replays; it does not reconcile. The response carries what the collection holds now, and every record is dispatched as a fresh mutation with from_full_sync: true and the original server timestamp. Nothing is dispatched for a record the server no longer has, so a consumer’s mirror that carries a contact, label, mute, or lock the server has since deleted still carries it afterwards. Consequences a consumer must plan for:
  • Be idempotent and order-guarded. The replay may re-deliver mutations the consumer already applied.
  • Clear before replay if the goal is to purge records the server dropped. Empty the local mirror of the collection first, then call resync_app_state_collection.
  • Report is about the sync, not the mirror. A collection reported synced only guarantees the server response was fetched, applied, and persisted, and mutations were dispatched to the event bus. It does not mean handlers have run. Under EventDelivery::Ordered, a full mailbox can drop callbacks.

AppStateResyncReport

Helpers: all_synced() returns true when every requested collection came back synced; unsynced() iterates every collection that was not synced, whatever the reason.

Protocol Operations

send_node

Sends a raw protocol node (advanced usage).
Node
required
Binary protocol node to send
Errors:
  • ClientError::NotConnected - Not connected
  • ClientError::EncryptSend - Encryption/send failure

send_raw_bytes

Send a pre-marshaled stanza through the noise socket. The bytes must be a packed payload — the format byte followed by the node bytes — which is what every wacore_binary::marshal::marshal* function (marshal, marshal_to_vec, marshal_exact, marshal_auto) writes. A stanza that came off the wire isn’t already in that shape: OwnedNodeRef::backing_bytes() returns node bytes only — the format byte is gone, and if the frame arrived FORMAT_COMPRESSED those are the decompressed bytes, not a fixed number of bytes shorter than what was on the wire. Forward it through wacore_binary::util::pack first — see Binary Protocol: the format byte.
Vec<u8>
required
A packed payload (format byte + node bytes) as produced by marshal
Errors:
  • ClientError::NotConnected - Not connected
  • ClientError::Socket - plaintext is not a packed payload, wrapping SocketError::Marshal: BinaryError::UnexpectedFormatByte for a compressed or otherwise unrecognized leading byte (most often node bytes handed in directly instead of packed ones), or BinaryError::EmptyData for an empty buffer or a lone format byte with nothing behind it
  • ClientError::EncryptSend - Encryption/send failure
As of PR #1259, plaintext’s shape — leading byte plus at least one more — is checked before it reaches the socket. That catches an empty buffer, a lone format byte, or node bytes handed in directly (wrong leading byte); it does not decode the node bytes, so a payload with the right shape but a malformed node inside it still reaches the socket, same as before.
This bypasses node logging and wait_for_sent_node waiter resolution. Use send_node for normal stanza sending. This method is intended for performance-critical paths where you already have marshaled bytes.

flush_pending_signal_state

As of PR #1090, this returns SignalMaintenanceError instead of anyhow::Error (a Storage failure keeps the typed backend cause reachable via source()).
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). 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.
Never call this from inside an InboundDurabilityHook — 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 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.
Example:
See Signal Protocol — flush scheduling for the full durability model.

generate_message_id

Pass the generated ID directly to SendOptions::default().with_message_id(id). Use as_str() or into_string() only at a raw boundary. MessageRetransmission takes the original content MessageId; operation ACK correlation uses StanzaId. Generates a unique WhatsApp-protocol-conformant message ID. Combines timestamp, user JID, and random components for uniqueness.
This is intended for advanced users who need to build custom protocol interactions or manage message IDs manually. Use Client::send(SendRequest) for the canonical request interface; it generates an ID unless you provide one through SendOptions.

send_iq

Sends a custom IQ (Info/Query) stanza to the WhatsApp server.
InfoQuery
required
IQ query containing stanza type, namespace, content, and optional timeout. The timeout (75s when None) covers writing the stanza to the socket and waiting for the response together, so a write that never completes still returns IqError::Timeout.
Example:
This bypasses higher-level abstractions and safety checks. You should be familiar with the WhatsApp protocol and IQ stanza format before using this.

execute

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.
S: IqSpec
required
A typed IQ specification that defines the request structure and response parsing
Example:

execute_streaming

The streaming counterpart to execute, for a spec whose response is consumed as it is decoded rather than handed over as a tree. The A/B props catalog (fetch_props) — a response of a few thousand small children — is what this exists for. The response is walked on the read loop, inside the frame’s decode, so it never exists as more than the inflate window and one child at a time. An error response, or one that arrives while a raw-node observer is attached, is still decoded whole and goes through the spec’s IqSpec::parse_response instead — which is why IqStreamSpec requires both.
S: IqStreamSpec + Send + 'static
required
A typed IQ specification whose response is consumed from a NodeStream as it decodes, rather than parsed from an already-materialized tree
Example:

wait_for_node

Waits for a specific incoming protocol node matching the given filter. Returns an owned future that resolves when a matching node arrives.
NodeFilter
required
Filter specifying which node to wait for (by tag and attributes)
Example:
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).
The NodeWaiter<T> future resolves to Result<Arc<T>, futures::channel::oneshot::Canceled>. Its try_recv(&mut self) returns Result<Option<Arc<T>>, futures::channel::oneshot::Canceled>. Dropping it removes its subscription immediately, without waiting for traffic and without retaining the Client. An active incoming waiter survives reconnect. Outgoing diagnostic waiters are cancelled during connection cleanup. Use a timeout when the response may never arrive.

NodeFilter

Builder for matching incoming protocol nodes:

wait_for_sent_node

Waits for a specific outgoing protocol node matching the given filter. Returns an owned future that resolves when a matching node is sent by the client. This is the outbound counterpart to wait_for_node.
NodeFilter
required
Filter specifying which outgoing node to intercept (by tag and attributes)
Example:
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.

subscribe

Keep the returned Subscription. Dropping it stops future bus registrations; an existing dispatch snapshot or already queued callback can still finish. subscribe_handler uses the handler’s registration-time interest hint. update_interest changes future filtering; detach deliberately leaves a registration installed. See events.

subscribe_chatstate_handler

Use client.chatstate().subscribe_handler(handler) or client.subscribe_chatstate_handler(handler). Both return the same RAII Subscription contract.

acquire_raw_node_forwarding

Hold client.acquire_raw_node_forwarding() while observing Event::RawNode. Every lease holder contributes to the forwarding gate; dropping one lease does not disable another consumer.

add_stanza_interceptor

Register an interceptor that sees eligible decoded stanzas before built-in handling, and may take them. This is the seam for acting on a stanza this client does not model — StanzaRouter::register panics on a duplicate tag, so even an existing tag can’t be handled differently any other way — instead of watching it get nacked. On main snapshot 35a69a14, an optional GroupNotificationDurabilityHook runs before interceptors for notification type="w:gp2" with a valid from JID. Failed or canceled capture prevents that stanza from reaching an interceptor and withholds its effects and ACK. A successful capture admits interception only while the connection remains active. RawNode diagnostic observers can run before capture. See group notification capture.
Arc<dyn StanzaInterceptor>
required
The interceptor to register. A plain closure of type Fn(&OwnedNodeRef) -> Interception implements StanzaInterceptor too, so client.add_stanza_interceptor(Arc::new(|node: &OwnedNodeRef| { .. })) works without a named type.
InterceptorHandle
RAII token for the registration. Dropping it removes the interceptor. The handle holds only a weak client reference, so a forgotten handle cannot keep the client alive, and dropping one after its client is already gone is a no-op rather than a panic.
MaybeSendSync is Send + Sync on native targets and carries no bounds on wasm32, matching the convention used by EventHandler, Transport, and HttpClient. Interceptors run in registration order; the first one to return Interception::Handled wins and the rest — including the built-in pipeline — are skipped. Registration order is therefore priority order: an earlier registration can shadow a later one. What an interceptor never sees: success, failure, stream:error, and ack settle connection state (authentication, shutdown/reconnection, and the waiters a send blocks on), and a server-initiated <iq> ping is withheld for the same reason — a claimed ping is a pong never sent, and the server drops the connection over it. Offline-sync tracking and response-waiter resolution run before dispatch and keep running whether or not a stanza is claimed. Every other stanza, including <iq> traffic the client already answers on its own, is offered. What claiming owes the server: a claim doesn’t change what the server is owed. Where the client would have acked a stanza, it still acks; where it would have nacked a tag it doesn’t model, the claim turns that into an ack instead, since something did handle it — answering nothing would leave the stanza in the offline queue. A tag the client models but answers some other way (a delivery <receipt> for a direct <message>, an <iq type="result">) gets nothing from the claimed-stanza path — the claimant owes that reply itself.
Cost while unused: one relaxed atomic load on the read loop, checked before any lock. Registering is what turns the check into a walk over the registered interceptors.
Example:
Interception runs before the built-in pipeline — including Signal decryption — so a claimed <message> was never decrypted and a claimed prekey-bearing <notification> never tops up prekeys. The ack that follows tells the server not to redeliver, so that work never happens again. Match narrowly.
See the whatsapp_rust::client::interceptor module for the full contract, and Native plugins for the capability-gated version available to plugins.

acquire_decrypted_payload_forwarding

Acquire a lease that keeps Event::DecryptedPayload enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its interest() away from the default EventInterest::ALL also needs EventKind::DecryptedPayload added back in, or it won’t see the event even while a lease is held. The event carries a message’s plaintext before it is decoded into a wa::Message — the only way to recover a payload that decrypts successfully but fails to decode (a field a build predates, a message type it doesn’t model). Nothing can ask for those bytes again: opening them already consumed state that won’t recur — the Signal ratchet advances, or (for a bot’s message_secret payload) the single-use secret is spent — so the same ciphertext will never open a second time.
DecryptedPayloadLease
RAII lease. Event::DecryptedPayload stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive.
While no lease is held, nothing is emitted and nothing is cloned — the path costs one relaxed atomic load. Under a lease, the forwarded payload is the same bytes::Bytes the decoder receives, so forwarding it is a refcount bump rather than a copy.
Example:

acquire_enc_decrypt_failed_forwarding

Acquire a lease that keeps Event::EncDecryptFailed enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its interest() away from the default EventInterest::ALL also needs EventKind::EncDecryptFailed added back in, or it won’t see the event even while a lease is held. This is the failing counterpart of acquire_decrypted_payload_forwarding — same per-<enc> granularity, same enc_index numbering — but tracked by a separate counter on purpose: a consumer that wants both halves of a stanza’s decryption holds both leases, one that wants only failures does not make the success path clone plaintext, and one that wants only successes pays nothing extra on the failure paths.
EncDecryptFailedLease
RAII lease. Event::EncDecryptFailed stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive.
While no lease is held, nothing is emitted and nothing is built — each failure branch costs one relaxed atomic load.
Example:

acquire_sent_frame_forwarding

Acquire a lease that keeps Event::SentFrame enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its interest() away from the default EventInterest::ALL also needs EventKind::SentFrame added back in, or it won’t see the event even while a lease is held. This is the outbound counterpart of acquire_decrypted_payload_forwarding: the event carries the marshaled plaintext of every frame the transport accepted, and — unlike wait_for_sent_node — it is neither filtered nor one-shot, and covers every send path, including acks, delivery receipts, and direct-encoded IQs that never build a Node at all.
SentFrameLease
RAII lease. Event::SentFrame stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive.
While no lease is held, nothing is emitted and nothing is cloned — the path costs one relaxed atomic load on the noise sender task. Under a lease, the forwarded frame is the same bytes::Bytes the caller handed to the socket, so forwarding it is a refcount bump rather than a copy.
Example:

Call management

reject_call

Reject an incoming call. This is fire-and-forget — no server response is expected. Sends a <call><reject> stanza to the WhatsApp server.
&str
required
The ID of the incoming call to reject. Must not be empty.
&Jid
required
The JID of the caller.
Example:

Spam Reporting

send_spam_report

Send a spam report to WhatsApp for messages or groups.
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
Returns: SpamReportResult indicating success or failure Example:
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

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

Prekeys

refresh_pre_keys

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:
Only call this after restoring a device from an external session store. Under normal operation, the client manages pre-key uploads automatically.
Example:

send_digest_key_bundle

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 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 (every 27 days, matching WA Web’s own ROTATE_KEY cadence — not configurable) 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. As of whatsapp-rust#1411, the same check also runs on a ~6-hour keepalive maintenance tick for the rest of a connection’s lifetime, so a session held open past the 27-day cadence without ever reconnecting still rotates. Automatic rotation failures are logged and retried according to their scheduled backoff — they never fail login. As of #1237, a failed upload also schedules its retry: a 5xx gets a 24-hour backoff, while any other 4xx rejection the server actually issued (e.g. 406, 409, 429) consumes the full cadence instead of re-running the upload on every reconnect; an ambiguous transport failure — the server may have already accepted the upload — retries on the next connect instead.
rotate_signed_pre_key() is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the 27-day 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 instead of only logging them. As of PR #1090 this replaces bare anyhow::Error. A failure from a manual call never reschedules the automatic cadence — only the automatic path’s own failures do. See 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(), an on-demand client-only memory breakdown via memory_report(), and an on-demand unified estimate — client plus storage, transport, and HTTP — via 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 and BotBuilder::with_alloc_meter. A fourth, always-on surface answers a different question — “are the group-send device-list memos actually being hit?” — via device_memo_stats().

stats

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]): 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, but not until a disconnect finds that the connection it is ending stayed up for at least the 30s stability window (see Fibonacci backoff) — a successful reconnect alone does not clear it, since the check runs on the way out of a connection, not on the way in. It counts consecutive failures, not a lifetime total. Since whatsapp-rust#1410, it also saturates at 64 rather than growing without bound on a link that flaps for weeks — the reconnect delay itself is already pinned at its 900s cap well before that point, so the saturation only keeps the counter itself readable, without changing any wait.
StatsSnapshot::devices_unkeyed_total() sums the five devices_unkeyed_* fields above — every keying attempt this client lost, whatever the reason (#1304). Usually the device is dropped and the send continues to everyone else, which is unchanged behavior (parity with WA Web). A batch-wide 406 or a Required distribution that cannot reach every target aborts the send instead; those attempts are still counted here. Either way, these counters make the state measurable instead of visible only in a log line. They count attempts, not distinct devices: a cold DM runs session establishment twice, so one send can record the same device more than once, and a retry that fails the same way counts again. See wa_unkeyable_device_total for the per-reason breakdown by label.
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.
Example:

memory_report

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]): CollectionStats carries both entries: u64 and bytes: u64. MemoryReport::total_estimated_bytes(&self) -> u64 sums .bytes across every byte-carrying field, subsystems included. 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, followed by a --- Transient retention --- section for inbound_commit_batch, offline_receipt_buffer (#1405), msg_secret_buffer, and pending_device_sync (#1273). Reading a subsystem’s collections:
Looks up one collection of one attached subsystem by SubsystemCollection, a compile-checked constant rather than a string — naming a collection that doesn’t exist is a build error, and None means either the subsystem isn’t attached to this build or it doesn’t report that collection. The voip subsystem — enabled by voip, voip-encoded, voip-mlow, or voip-libopus (see Feature flags) — exposes its constants under whatsapp_rust::voip::collections:
Breaking change (unreleased): MemoryReport used to carry three individual VoIP-gated fields (active_calls, pending_outgoing_calls, pending_call_link_updates) directly on the struct, each a plain CollectionStats. They’re now reached through subsystems / subsystem() above instead, so a build with no optional subsystem attached pays no cfg’d field for one it doesn’t use. Migration is not a direct field swap — subsystem() returns Option<CollectionStats>, None when the build has no voip subsystem attached:
Example:
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.

resource_report

Unified per-session resource estimate: 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 is installed via BotBuilder::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]): 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), free_pages: Option<u64> (how many of pages sit on the store’s free list — SQLite: freelist_count — the part of the file retention sweeps have already emptied and only a VACUUM returns to the filesystem), wal_bytes: Option<u64> (bytes the write-ahead log occupies on disk, showing whether a single large transaction has left it permanently big), io_read_bytes / io_write_bytes: Option<u64> (cumulative I/O, when counted). free_pages and wal_bytes were added in whatsapp-rust#1411; StorageResourceReport is not #[non_exhaustive], so a struct-literal constructor outside the tree needs both fields. 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 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:
Storage, transport, and HTTP reports are supplied by the trait implementations behind Client — see DeviceStore::resource_report, Transport::resource_report, and HttpClient::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.

device_memo_stats

Per-term hit/miss counts for the two device-list memos the group-send path depends on — the group-devices memo and the SKDM-targets memo — cumulative since the client was built. Always on, no feature gate. Recording is one indexed relaxed atomic add per resolver call, with one exception: a call whose resolved SKDM target set can’t be memoized also bumps the separate not_stored counter, so that call pays two adds (see not_stored below). The reporting types are dropped by LTO in a binary that never calls this method. The two memos are chained: resolve_skdm_targets_memoized compares the Arc that resolve_group_devices_memoized returned, so a group-memo recompute forces skdm_targets.miss_devices — but only when an SKDM entry already exists for the group. If none does yet (first send, or an eviction), that call reports miss_absent instead, regardless of what the group memo just did. Read group_devices first — skdm_targets only carries independent information once the group half is hitting. GroupDevicesMemoStats fields (#[non_exhaustive]): calls() sums all six fields; served_rate() returns (hits + restamps) / calls() — the share resolved without a per-member registry fan-out — or None before the first call. SkdmTargetsMemoStats fields (#[non_exhaustive]): calls() sums every field except not_stored, which describes the store rather than a lookup outcome. hit_rate() returns hits / calls(). resolve_failed is included in that denominator on purpose — folding failed resolutions out would let a client whose group sends are failing upstream read a healthy rate. hit_rate() returns None before the first call. DeviceMemoStats implements Display for a one-line-per-memo summary, and since(&self, earlier: &Self) -> Self saturating-subtracts an earlier snapshot to scope a window without resetting the counters (a reset would race a send in flight). Example:
DeviceMemoStats, GroupDevicesMemoStats, and SkdmTargetsMemoStats are public in whatsapp_rust::client but, unlike StatsSnapshot/MemoryReport/ResourceReport, not re-exported from the crate root. Added in #1292 as a characterization tool: measured against that PR’s fixtures, both memos hit on every warm send at group sizes 8–512, so the instrumentation shipped without a corresponding fix.

Error Types

As of PR #1090, connect()/wait_for_socket_ready()/wait_for_session_ready() return ConnectError (ClientError::AlreadyConnected was removed in favor of ConnectError::AlreadyConnected), and rotate_signed_pre_key()/flush_pending_signal_state() return SignalMaintenanceError. See Error Types for the full reference across the crate.

See Also

Pictures

Use client.pictures().lookup(ProfilePictureRequest) for contact, group, and community lookups. Set and remove operations remain on the owning feature facade.

Retry policies

Configure with_retry_admission or with_retry_admission_arc to gate inbound group/status retry repair. Configure with_decrypt_failure_admission or with_decrypt_failure_admission_arc to gate automatic outbound decrypt-failure retry receipts. Both builders accept owned or shared implementations; the last registration for each policy wins before assembly. Keep runtime tuning inside the policy. See retry admission for contexts, decisions and refusal consequences.