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
Arc<dyn Runtime>
required
Async runtime for spawning tasks, sleeping, and blocking operations
Arc<PersistenceManager>
required
State manager for device credentials, sessions, and app state
Arc<dyn TransportFactory>
required
Factory for creating WebSocket connections
Arc<dyn HttpClient>
required
HTTP client for media operations and version fetching
Option<(u32, u32, u32)>
Optional WhatsApp version override (primary, secondary, tertiary)
(Arc<Client>, Receiver<MajorSyncTask>)
Returns the client Arc and a receiver for history/app state sync tasks
Creating with custom cache configuration
Connection Management
run
disconnect(),logout(), orsignal_shutdown_sync()is called- Auto-reconnect is disabled and connection fails
- Client receives a fatal stream error (401 unauthorized, 409 conflict, or 516 device removed)
If you call
disconnect(), logout(), or signal_shutdown_sync() while run() is waiting out its reconnect backoff, the client interrupts the wait immediately. The backoff can otherwise run up to the 900s cap (see Auto-Reconnection). So without this, awaiting run() right after requesting a stop could look like a 15-minute hang. For example, bot.run().await? returns a BotHandle immediately — awaiting that handle waits on this same run(). A per-connection shutdown does not cut the backoff short; that’s the kind run() itself reconnects from. Only the three terminal calls above interrupt the wait.connect
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).
Errors (ConnectError):
AlreadyConnected- a connection is already up, or anotherconnect()attempt is already in flightNotActivated- construction never activated (only reachable with theclient-lifecyclefeature)Shutdown- added in PR #1258. The client has already been shut down (disconnect(),logout(), orsignal_shutdown_sync()); shutdown is final, so build a new client rather than reconnecting this onePaused- added in PR #1265.pause()is in effect. UnlikeShutdownthis is not final —resume()lifts it andconnect()works again.connect()rechecks the pause at every checkpoint of the connect graph, so an attempt already in flight whenpause()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 reportsConnectStage::SocketorReady— those are only produced bywait_for_socket()/wait_for_connected()below.Version(anyhow::Error)/Transport(anyhow::Error)- app version resolution or transport open failed outrightHandshake(HandshakeError)- the Noise handshake failed after the transport was up; checkHandshakeError::is_transient()to decide whether a retry is worthwhile
ConnectError for the full variant reference.
Connection
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.
logout
LoggedOut event.
As of PR #1090,
logout() is infallible ((), not Result<()>). The deregistration IQ is best-effort — it cannot be sent at all while offline — and the local teardown runs either way, so there was nothing for a caller to branch on. A failed IQ is logged at warn.- Disables auto-reconnect
- Sends a
RemoveCompanionDeviceSpecIQ to deregister the companion device (if connected); a failure here is logged, not returned - Disconnects the transport
- Emits
Event::LoggedOutwithreason: ConnectFailureReason::LoggedOut
disconnect
cleanup_connection_state(). That cleanup resets all connection-scoped state — invalidating per-chat message queues so stale workers exit, flushing then clearing the signal cache (so pending sender-key/identity writes are persisted, not lost), draining pending IQ waiters, and resetting offline sync state. The same cleanup_connection_state() also runs from run() after the message loop exits; it is idempotent and race-tolerant, so whichever path wins, connection-scoped state is reset once in effect. See disconnect cleanup for the full list of resources cleaned up.
signal_shutdown_sync
disconnect() for places where you can’t await. It flips expected_disconnect, clears is_running, fires the terminal shutdown_notifier, and notifies the per-connection shutdown so spawned tasks exit on their next poll. It does not flush, close the transport, or touch persistence — prefer disconnect() whenever you can await. Intended for Drop impls on FFI wrappers (e.g. the WASM client) that need to release the runtime without blocking.
reconnect
- Handling network changes (e.g., Wi-Fi to cellular)
- Forcing a fresh server session
- Testing offline message delivery
reconnect_immediately
reconnect(), this sets the expected disconnect flag so the run loop skips the backoff delay.
Example:
pause
resume(). It is the middle of the range between reconnect(), which comes back on the library’s schedule, and disconnect(), 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 disconnect(). 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).resume
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_connected(). 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 disconnect()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
pause() is in effect and no connection will be opened by run() until resume().
wait_for_socket
Duration
required
Maximum time to wait
Result<(), ConnectError>
Ok if socket ready,
ConnectError::Timeout { stage: ConnectStage::Socket, timeout } on timeoutwait_for_connected
Duration
required
Maximum time to wait
Result<(), ConnectError>
Ok once fully ready,
ConnectError::Timeout { stage: ConnectStage::Ready, timeout } on timeoutAs of PR #1090, both methods return
ConnectError instead of anyhow::Error.pair_with_code
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 alphabetplatform_id—Option<CompanionWebClientType>override for<companion_platform_id>.Nonederives the wire id fromDevice.device_props.platform_type(typicallyChrome; AndroidPlatformTypes also map toChromebecause the server requires attestation for the Android letter codes). The matching<companion_platform_display>is always derived; web variants emit<Browser> (<OS>), and explicitAndroidPhone/AndroidTablet/AndroidAmbiguousoverrides emitAndroid (<OS>).
String
The 8-character pairing code to display to the user
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:
PairCodeRejection’s five named variants (BadRequest, Forbidden, RateOverlimit, FeatureNotAvailable, InternalServerError) plus its Unknown(i32) fallback, and for is_throttled().
Example:
cancel_pair_code
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.set_passkey_authenticator
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
<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
<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
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
true if authenticated with WhatsApp servers.
Auto-Reconnection
The client includes automatic reconnection handling with Fibonacci backoff.How it works
- On disconnect: The client detects unexpected disconnections and automatically attempts to reconnect
- 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
- Expected disconnects: Protocol-expected disconnects (e.g., 515 stream error after pairing) trigger immediate reconnection without backoff
- 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
client.stats().reconnect_errors instead of a public field.
Stream error handling
The client handles specific<stream:error> codes from the WhatsApp server:
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 anEvent::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
send_message
Jid
required
Recipient JID (user@s.whatsapp.net or group@g.us)
wa::Message
required
Protobuf message content
Result<SendResult, SendError>
A
SendResult containing the message_id and destination to JIDsend_message_with_options
SendOptions
required
Configuration for message sending behavior. Supports
message_id (override the auto-generated ID), extra_stanza_nodes (custom XML nodes on the stanza), and ephemeral_expiration (disappearing message duration in seconds).edit_message
String
required
ID of the message to edit
wa::Message
required
New message content
edit_message_with_options
send_message_with_options. Accepts an EditOptions (built via EditOptions::default().with_stanza_id(id)) to pin the outer stanza id to an existing message’s id (best-effort — server/client dependent) instead of the fresh id edit_message generates.
See Send API reference for the full EditOptions type and its side-effect notes.
revoke_message
Sender to revoke your own message, or Admin to revoke another user’s message as group admin.
RevokeType
required
RevokeType::Sender (delete your own message) or RevokeType::Admin { original_sender: Jid } (admin revoke in groups)Feature APIs
The Client provides namespaced access to feature-specific operations:blocking
block(jid: &Jid)- Block a contactunblock(jid: &Jid)- Unblock a contactget_blocklist()- Get all blocked contactsis_blocked(jid: &Jid)- Check if contact is blocked
bots
list()- Fetch the bot directory
groups
query_info(jid: &Jid)- Get cached group infoget_metadata(jid: &Jid)- Fetch group metadata from serverget_participating()- List all groups you’re increate_group(options: GroupCreateOptions)- Create a new groupset_subject(jid: &Jid, subject: GroupSubject)- Change group nameset_description(jid: &Jid, desc: Option<GroupDescription>, prev: PreviousDescription<'_>)- Change description;previs an optimistic-concurrency token (PreviousDescription::Resolvereads the current one for you)leave(jid: &Jid)- Leave a groupadd_participants(jid: &Jid, participants: &[Jid])- Add membersremove_participants(jid: &Jid, participants: &[Jid])- Remove memberspromote_participants(jid: &Jid, participants: &[Jid])- Make members adminsdemote_participants(jid: &Jid, participants: &[Jid])- Remove admin statusget_invite_link(jid: &Jid, reset: bool)- Get/reset invite linkjoin_with_invite_code(code: &str)- Join a group via invite code or URLjoin_with_invite_v4(group_jid, code, expiration, admin_jid)- Accept a V4 invite messageget_invite_info(code: &str)- Preview group metadata from invite codeset_locked(jid: &Jid, locked: bool)- Lock/unlock group info editingset_announce(jid: &Jid, announce: bool)- Enable/disable announcement modeset_ephemeral(jid: &Jid, expiration: u32)- Set disappearing messages timerset_membership_approval(jid: &Jid, mode: MembershipApprovalMode)- Require admin approvalget_membership_requests(jid: &Jid)- Get pending membership requestsapprove_membership_requests(jid: &Jid, participants: &[Jid])- Approve pending requestsreject_membership_requests(jid: &Jid, participants: &[Jid])- Reject pending requestsset_member_add_mode(jid: &Jid, mode: MemberAddMode)- Set who can add membersset_no_frequently_forwarded(jid: &Jid, restrict: bool)- Restrict forwarding of frequently forwarded messagesset_allow_admin_reports(jid: &Jid, allow: bool)- Allow or disallow admin reportsset_group_history(jid: &Jid, enabled: bool)- Enable or disable group history for new membersset_member_link_mode(jid: &Jid, mode: MemberLinkMode)- Set member link modeset_member_share_history_mode(jid: &Jid, mode: MemberShareHistoryMode)- Set history sharing mode for new membersset_limit_sharing(jid: &Jid, enabled: bool)- Limit sharing within the groupcancel_membership_requests(jid: &Jid, participants: &[Jid])- Cancel pending membership requestsrevoke_request_code(jid: &Jid, participants: &[Jid])- Revoke request codes for participantsacknowledge(jid: &Jid)- Acknowledge a groupbatch_get_info(jids: Vec<Jid>)- Batch fetch group metadata for multiple groupsget_profile_pictures(group_jids: Vec<Jid>, picture_type: PictureType)- Batch fetch group profile pictures
presence
set(status: PresenceStatus)- Set presence statusset_available()- Set status to available/onlineset_unavailable()- Set status to unavailable/offlinesubscribe(jid: &Jid)- Subscribe to contact’s presence updatesunsubscribe(jid: &Jid)- Unsubscribe from contact’s presence updates
chatstate
send(to: &Jid, state: ChatStateType)- Send a chat state updatesend_composing(to: &Jid)- Send typing indicatorsend_recording(to: &Jid)- Send recording indicatorsend_paused(to: &Jid)- Send paused/stopped typing indicator
contacts
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 JIDget_profile_picture(jid: &Jid, preview: bool)- Get profile picture URL (preview or full size)
tc_token
issue_tokens(jids: &[Jid])- Request tokens for contactsprune_expired()- Remove expired tokensget(jid: &str)- Get a stored token by JIDget_all_jids()- List all JIDs with stored tokens
chat_actions
archive_chat(jid: &Jid, message_range: Option<SyncActionMessageRange>)- Archive a chatunarchive_chat(jid: &Jid, message_range: Option<SyncActionMessageRange>)- Unarchive a chatpin_chat(jid: &Jid)- Pin a chatunpin_chat(jid: &Jid)- Unpin a chatmute_chat(jid: &Jid)- Mute a chat indefinitelymute_chat_until(jid: &Jid, mute_end_timestamp_ms: i64)- Mute until a specific timeunmute_chat(jid: &Jid)- Unmute a chatstar_message(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool)- Star a messageunstar_message(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool)- Unstar a messagemark_chat_as_read(jid: &Jid, read: bool, message_range: Option<SyncActionMessageRange>)- Mark a chat as read or unread across devicesdelete_chat(jid: &Jid, delete_media: bool, message_range: Option<SyncActionMessageRange>)- Delete a chat from all linked devicesdelete_message_for_me(chat_jid: &Jid, participant_jid: Option<&Jid>, message_id: &str, from_me: bool, delete_media: bool, message_timestamp: Option<i64>)- Delete a message locally (not for the other party)
quick_replies
set_quick_reply(id: &str, shortcut: &str, message: &str, keywords: Vec<String>, count: i32)- Create or update a quick replydelete_quick_reply(id: &str)- Delete a quick reply
app_state_settings
set_privacy IQ namespace.
Methods:
set_link_previews_disabled(disabled: bool)- Turn outgoing link previews off or on for the whole account
status
send_text(text, background_argb, font, recipients, options)- Post a text statussend_image(upload, thumbnail, caption, recipients, options)- Post an image statussend_video(upload, thumbnail, duration_seconds, caption, recipients, options)- Post a video statussend_raw(message, recipients, options)- Post any message type as a statusrevoke(message_id, recipients, options)- Delete a posted statussend_reaction(status_owner, server_id, reaction)- React to a status update
mex
query(request: MexRequest)- Execute a GraphQL querymutate(request: MexRequest)- Execute a GraphQL mutationfetch_new_chat_message_capping_info()- Fetch the new-chat message cap for the current cycle
profile
set_push_name(name: &str)- Set display name (syncs across devices)set_status_text(text: &str)- Set profile “About” textset_profile_picture(image_data: Vec<u8>)- Set profile picture (JPEG, 640x640 recommended)remove_profile_picture()- Remove profile picture
newsletter
list_subscribed()- List all subscribed newslettersget_metadata(jid: &Jid)- Get newsletter metadataget_metadata_by_invite(invite: &str)- Get metadata via invite linkcreate(name, description)- Create a new newsletterjoin(jid: &Jid)- Join a newsletterleave(jid: &Jid)- Leave a newsletterupdate(jid, options)- Update newsletter settingssend_reaction(jid, msg_server_id, reaction)- React to a newsletter messageget_messages(jid, count, before)- Fetch newsletter messagessubscribe_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.community
create(options: CreateCommunityOptions)- Create a communitydeactivate(jid: &Jid)- Deactivate a communitylink_subgroups(jid: &Jid, subgroups: &[Jid])- Link groups to a communityunlink_subgroups(jid: &Jid, subgroups: &[Jid], remove_orphan_members: bool)- Unlink groups from a communityget_subgroups(jid: &Jid)- List community subgroupsget_subgroup_participant_counts(jid: &Jid)- Get participant counts per subgroupquery_linked_group(community_jid: &Jid, subgroup_jid: &Jid)- Query a linked group’s community metadatajoin_subgroup(community_jid: &Jid, subgroup_jid: &Jid)- Join a community subgroupget_linked_groups_participants(jid: &Jid)- Get participants across linked groups
polls
create(to: &Jid, name: &str, options: &[String], selectable_count: u32)- Create a poll (returns message ID and secret)vote(chat_jid, poll_msg_id, poll_creator_jid, message_secret, option_names)- Cast a vote on a polldecrypt_vote(enc_payload, enc_iv, message_secret, poll_msg_id, poll_creator_jid, voter_jid)- Decrypt a vote (static method)aggregate_votes(poll_options, votes, message_secret, poll_msg_id, poll_creator_jid)- Tally all votes (static method)
media_reupload
request(req: &MediaReuploadRequest)- Request the server to re-upload expired media
server-error receipt and waits up to 30 seconds for a mediaretry notification with an updated download path.
signal
encrypt_message(jid: &Jid, plaintext: &[u8])- Encrypt plaintext for a single recipientdecrypt_message(jid: &Jid, enc_type: EncType, ciphertext: &[u8])- Decrypt a Signal protocol messageencrypt_group_message(group_jid: &Jid, plaintext: &[u8])- Encrypt plaintext for a group using sender keysdecrypt_group_message(group_jid: &Jid, sender_jid: &Jid, ciphertext: &[u8])- Decrypt a group messagevalidate_session(jid: &Jid)- Check whether a Signal session existsdelete_sessions(jids: &[Jid])- Delete Signal sessions and identity keyscreate_participant_nodes(recipient_jids: &[Jid], message: &Message)- Create encrypted participant nodesassert_sessions(jids: &[Jid])- Ensure E2E sessions existget_user_devices(jids: &[Jid])- Get all device JIDs for users
query_usync
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.
Public fields
http_client
enable_auto_reconnect
true. Set to false to disable auto-reconnect.
custom_enc_handlers
Bot::build and immutable afterward; read lock-free via .get(). Register handlers exclusively through BotBuilder::with_enc_handler() — direct mutation after build is not possible.
RECONNECT_BACKOFF_STEP
reconnect() is called, creating an approximately 5-second offline window before the next connection attempt. This prevents tight reconnect loops after intentional disconnects.
Client Profile
The noise-handshakeClientPayload.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
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).Native profiles (
android, smb_android, ios, macos, windows) automatically omit web_info from the ClientPayload. Only web() includes it.Device State
push_name
Renamed from
get_push_name — the get_ prefix was dropped to match the neighboring accessors.pn
None before pairing completes.
Renamed from
get_pn.lid
None before pairing completes.
Renamed from
get_lid.is_lid_migrated
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
@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>.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
TheLearningSource 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 underUsync, which can never itself trigger another reconcile. - Known-stale sources (
MigrationSyncOld,BlocklistInactive) are additionally stamped withcreated_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.persistence_manager
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:- Sends a
HistorySyncreceipt immediately (so the phone knows delivery succeeded) - Retrieves the data — either from an inline payload (moved via
.take(), not cloned) or by stream-decrypting an external blob in 8KB chunks - Extracts a
compressed_size_hintfrom the notification’sfile_lengthfield, which the decompressor uses with a 4x multiplier for better buffer pre-allocation (avoids repeatedVecreallocation) - Runs decompression and protobuf parsing on a blocking thread (
tokio::task::spawn_blocking) to avoid stalling the async runtime - Wraps the decompressed blob in a
LazyHistorySyncwith cheap metadata (sync type, chunk order, progress) and dispatches it asEvent::HistorySync(Box<LazyHistorySync>). Full protobuf decoding is deferred until the event handler calls.get()
process_sync_task
MajorSyncTask received from the sync channel returned by Client::new. This is the public entry point for handling history sync and app state sync tasks.
The method dispatches to the appropriate internal handler based on the task variant:
MajorSyncTask::HistorySync— downloads and processes history sync dataMajorSyncTask::AppStateSync— synchronizes app state (contacts, mutes, pins, etc.)
If you use the
Bot builder, sync task processing is handled automatically. You only need this method when building a custom client setup.set_skip_history_sync
skip_history_sync_enabled
true if history sync is currently being skipped.
set_wanted_pre_key_count
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.wanted_pre_key_count
set_force_active_delivery_receipts
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
<receipt type="server-error" category="peer"> to the companion device carrying an encrypted retry payload, mirroring WA Web’s WAWebSendHistSyncServerErrorReceiptJob.
Parameters:
message_id— theMessageInfo::idof the failed history-sync notificationmedia_key— the 32-byte key carried by the original<historysync mediaKey="…">element
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 preventspkmsg messages (which carry SKDM for group decryption) from being silently dropped during the transition. See Concurrency gating for details.
Timeout fallback
If the server advertises offline messages but never completes delivery, a 60-second timeout ensures startup is not blocked indefinitely. On timeout:- A warning is logged with the number of processed vs. expected items
- Offline sync is marked complete
OfflineSyncCompletedevent is emitted- Message processing switches from sequential to parallel (64 concurrent tasks)
State reset on reconnect
All offline sync state (counters, timing, concurrency semaphore) is fully reset on reconnect so stale state does not carry over to the next connection. Related events:OfflineSyncPreview, OfflineSyncCompleted
App State
fetch_props
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-memoryAbPropsCache 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.
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 (seewacore::iq::props::WATCHED). If you need to gate your own code on a flag the library doesn’t already watch, register it before the first fetch_props():
code, value_type, and default straight from the WA Web bundle, so behavior tracks WhatsApp Web without hand-maintained config tables.
The AB props cache is internal to the client. You don’t need to interact with it directly — the library automatically checks relevant flags when performing group operations like
create_group and add_participants.fetch_privacy_settings
set_privacy_setting
PrivacyCategory
required
Privacy category enum:
Last, Online, Profile, Status, GroupAdd, ReadReceipts, CallAdd, Messages, or DefenseModePrivacyValue
required
Privacy value enum:
All, Contacts, None, ContactBlacklist, MatchLastSeen, Known, Off, or OnStandardset_privacy_disallowed_list
Last, Profile, Status, and GroupAdd.
See Privacy API for details and examples.
set_default_disappearing_mode
u32
required
Timer duration in seconds. Common values:
86400 (24 hours), 604800 (7 days), 7776000 (90 days). Pass 0 to disable.get_business_profile
None if the account is not a business account or has no business profile.
&Jid
required
JID of the business account to query
business
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)
clean_dirty_bits
DirtyBit struct contains a dirty_type (e.g., AccountSync, Groups, SyncdAppState, NewsletterMetadata) and an optional timestamp.
Protocol Operations
send_node
Node
required
Binary protocol node to send
ClientError::NotConnected- Not connectedClientError::EncryptSend- Encryption/send failure
send_raw_bytes
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
ClientError::NotConnected- Not connectedClientError::Socket-plaintextis not a packed payload, wrappingSocketError::Marshal:BinaryError::UnexpectedFormatBytefor a compressed or otherwise unrecognized leading byte (most often node bytes handed in directly instead of packed ones), orBinaryError::EmptyDatafor an empty buffer or a lone format byte with nothing behind itClientError::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.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()).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.
Example:
generate_message_id
This is intended for advanced users who need to build custom protocol interactions or manage message IDs manually. Most users should use
send_message which handles ID generation automatically.send_iq
InfoQuery
required
IQ query containing stanza type, namespace, content, and optional timeout
execute
S: IqSpec
required
A typed IQ specification that defines the request structure and response parsing
wait_for_node
NodeFilter
required
Filter specifying which node to wait for (by tag and attributes)
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).
NodeFilter
Builder for matching incoming protocol nodes:wait_for_sent_node
wait_for_node.
NodeFilter
required
Filter specifying which outgoing node to intercept (by tag and attributes)
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.register_handler
Arc<dyn EventHandler>
required
Handler implementing the EventHandler trait
ChannelEventHandler:
register_chatstate_handler
Arc so the event dispatcher can share it across threads. Copy-on-write registration lets event dispatch continue while you register another handler. When no handler is registered, the client uses a lock-free fast path.
set_raw_node_forwarding
Event::RawNode is emitted for every decoded stanza before the stanza router dispatches it. Disabled by default to avoid overhead.
bool
required
Whether to emit
Event::RawNode for every incoming stanzaadd_stanza_interceptor
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.
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.
whatsapp_rust::client::interceptor module for the full contract, and Native plugins for the capability-gated version available to plugins.
acquire_decrypted_payload_forwarding
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.acquire_enc_decrypt_failed_forwarding
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.
acquire_sent_frame_forwarding
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.Call management
reject_call
<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.
Spam Reporting
send_spam_report
SpamReportRequest
required
The spam report request containing:
message_id- ID of the message being reportedmessage_timestamp- Timestamp of the messagespam_flow- Context where report was initiated (MessageMenu, GroupInfoReport, etc.)from_jid- Optional sender JIDgroup_jid- Optional group JID for group spamgroup_subject- Optional group name/subject for group reportsparticipant_jid- Optional participant JID in group contextraw_message- Optional raw message bytesmedia_type- Optional media type if reporting medialocal_message_type- Optional local message type
SpamReportResult indicating success or failure
Example:
MessageMenu- Reported from message context menuGroupInfoReport- Reported from group info screenGroupSpamBannerReport- Reported from group spam bannerContactInfo- Reported from contact info screenStatusReport- Reported from status view
Passive Mode
set_passive
false (active), the server starts sending offline messages.
Prekeys
refresh_pre_keys
InMemoryBackend) and the server may still hold pre-key IDs whose private key material you cannot reconstruct.
Any pkmsg referencing those old IDs will fail permanently with InvalidPreKeyId. Calling refresh_pre_keys() uploads a fresh batch that the caller does have locally, and old unmatched IDs drain as peers consume them.
Behavior:
- Acquires the internal
prekey_upload_lockso this force-upload cannot race with the count-based and digest-repair upload paths - Uploads a full batch of
Client::wanted_pre_key_count()pre-keys (default 812, configurable viaBotBuilder::with_wanted_pre_key_countorset_wanted_pre_key_count) with Fibonacci retry backoff (1s, 2s, 3s, 5s, 8s, … capped at 610s) - Retries until success or the connection is lost
send_digest_key_bundle
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
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 ownROTATE_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. Automatic rotation failures are logged and retried on a later connect — 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
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 on every successful reconnect (it counts consecutive failures, not a lifetime total).
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.memory_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. 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, msg_secret_buffer, and pending_device_sync (#1273).
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
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), io_read_bytes / io_write_bytes: Option<u64> (cumulative I/O, when counted).
TransportResourceReport fields: read_buffer_bytes, write_buffer_bytes, tls_state_bytes — all Option<u64>.
HttpResourceReport fields: pool_connections: Option<u64>, pool_buffer_bytes: Option<u64>, inflight_bytes: Option<u64>.
ResourceReport::total_estimated_bytes(&self) -> u64 sums the retained components (client + storage + transport + HTTP). Treat it as a best-effort retained estimate, not a strict lower bound: unreported fields (None) are treated as 0, so components that cannot fully introspect their footprint are silently undercounted — but the storage figure is itself a min(cache cap, db size) upper bound on the SQLite page cache, and can overstate actual heap residency when mmap_size 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
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()/wait_for_connected() 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
- Bot - High-level builder with event handlers
- Events - Event system and types
- Sending Messages - Sending and receiving messages
- Group Management - Working with groups