Skip to main content

Overview

This guide covers message event handling, decryption, and receipt management in whatsapp-rust.

Event System

Subscribing to Events

Use the typed registrars on BotBuilder — they extract the relevant payload before calling your handler, so you never pattern-match on Arc<Event> for the common cases:
Available typed registrars: on_message, on_qr_code, on_pair_code, on_connected, on_logged_out. All handlers accumulate — registering a second one no longer silently replaces the first. For events without a typed registrar, use the catch-all on_event / on_event_for:
For stateful handlers that hold shared state in &self, register a struct implementing EventHandler directly:
See Bot API reference for full details.

Available Events

Message Structure

MessageInfo

Every message event includes metadata:
The ephemeral_expiration field contains the disappearing messages timer in seconds, extracted from the message’s contextInfo.expiration. This tells you how long the message will be visible before it auto-deletes. Use this value when sending replies to the same chat via SendOptions.ephemeral_expiration. The unavailable_request_id field is set when a message was recovered via PDO rather than normal decryption. It contains the PDO request message ID, which you can use to correlate recovered messages with the original UndecryptableMessage event. The comment_target field is set when the dispatched message is a decrypted CAG channel comment. It contains the MessageKey of the parent post. The inner Message proto has no slot for the threading link, so it surfaces here instead. See Channel comments below.

Message content extraction

Use the MessageExt trait to extract content:
See WAProto API reference for the full message type hierarchy.

Message Types

Text Messages

Media Messages

See Media Handling Guide for download details.

Reactions

Incoming reactions — including encrypted CAG reactions — are dispatched in the same reaction_message shape. Encrypted reactions from Community Announcement Groups are decrypted transparently on the receive path; the key field is filled from the envelope’s target_message_key before dispatch.

Channel Comments

Encrypted channel comments from Community Announcement Groups are decrypted transparently and dispatched as Event::Messages carrying the comment body. The parent post key surfaces on MessageInfo::comment_target (the inner Message proto has no slot for the threading link):
The comment’s own messageSecret (carried in the outer envelope) is persisted under the comment’s id and sender, so that future encrypted reactions targeting the comment can be decrypted. comment_target is None for all other message types.

Quoted Messages

Message Unwrapping

DeviceSentMessage handling

When you send a message from one device, other devices receive it as a DeviceSentMessage wrapper. The library automatically unwraps this and merges messageContextInfo from both the outer envelope and inner message:
Self-sent messages synced from your primary device are automatically unwrapped. The messageContextInfo is merged following WhatsApp Web’s logic, ensuring metadata like thread IDs and bot metadata are preserved correctly.

Decryption

Automatic decryption

Messages are automatically decrypted by the client:

Undecryptable Messages

When decryption fails, you receive an UndecryptableMessage event:
The client automatically handles decryption retries using the retry receipt mechanism. Failed messages trigger Event::UndecryptableMessage, and the client will request re-encryption from the sender.
See Signal Protocol and Events reference for more details.

Two-pass decryption model

Group messages arrive with two types of <enc> nodes in a single stanza:
  1. Session messages (pkmsg/msg) — carry the Sender Key Distribution Message (SKDM) via a pairwise Signal session
  2. Group messages (skmsg) — carry the actual message content, encrypted with the sender key
The client decrypts these in two passes:
  1. Pass 1: Process session <enc> nodes to extract the SKDM, which establishes the sender key for the group.
  2. Pass 2: Process group <enc> nodes using the sender key from Pass 1.
Pass 2 acquires a per-(group, sender) sender-key chain lock around each skmsg decrypt, mirroring the per-device session lock the 1:1 path already holds around its decrypt. Two workers can otherwise coexist for the same (group, sender) — most commonly after a chat lane is capacity-evicted and a later stanza spawns a fresh worker at the same connection generation — and without the lock they can race the sender-key ratchet advance, silently dropping a chain step and its persisted skipped-message keys. See Per-device session locks for the analogous 1:1 lock. If session messages fail to decrypt, the SKDM they carried is lost. In this case, the client skips skmsg decryption entirely (since it would always fail with NoSenderKey) and dispatches an UndecryptableMessage event. The retry receipt for the session message causes the sender to resend the entire message including the SKDM. Before looking up or storing sender keys, the client normalizes the sender JID to its bare form (stripping the device component via to_non_ad()). This is necessary because WhatsApp delivers pkmsg stanzas (carrying SKDM) with a device-qualified participant JID, while skmsg stanzas use a bare participant JID. Without normalization, the sender key stored during SKDM processing would not match the key looked up during skmsg decryption. See Sender key address normalization for details. When a group skmsg decryption fails with NoSenderKeyState (the sender key is missing or was never received), the client dispatches an UndecryptableMessage event before spawning the retry receipt. This ensures your application is immediately notified that the message is pending decryption, matching the behavior of the session-based decrypt path. Exceptions where skmsg is still processed even without successful session decryption:
  • No session messages present — the sender key was already established from a prior message
  • Duplicate session messages — the SKDM was already processed in a previous delivery
This matches WhatsApp Web’s canDecryptNext pattern. It prevents unnecessary retry receipts for skmsg nodes that can never succeed without the SKDM.Beyond NoSenderKeyState, a skmsg decrypt can also fail with SignatureValidationFailed, InvalidSenderKeySession, UnrecognizedMessageVersion, or InvalidMessage (a distinct error variant, separate from NoSenderKeyState and DuplicatedMessage — a MAC or format failure on an otherwise-recognized sender-key ciphertext) — all recoverable sender-key desyncs (a participant rotated their sender key or re-registered), not corrupt messages. These are classified the same way as NoSenderKeyState: the client dispatches UndecryptableMessage and sends a retry receipt, which prompts the sender to redistribute the SKDM. Only a genuinely non-Signal error falls through to a terminal NACK, which tells the server to stop retransmitting the stanza. This mirrors WhatsApp Web, which treats every SignalDecryptionError on the group path as retryable — the 1:1 decrypt path already applied the same recoverable/terminal split.
Because pkmsg messages carry SKDM, silently dropping a pkmsg during processing causes all subsequent skmsg messages from that sender to fail with NoSenderKeyState. The client uses a generation-checked re-acquire loop during the offline-to-online semaphore transition to ensure pkmsg messages are never dropped. See Concurrency gating for details on how this works.

Decrypt-fail mode

Each incoming message has a decrypt_fail_mode attribute parsed from the <enc> nodes:
  • DecryptFailMode::Show — the recipient should show a “waiting for this message” placeholder in the chat
  • DecryptFailMode::Hide — the message should be silently hidden on failure (used for infrastructure messages like reactions, poll votes, pin changes, secret encrypted event/poll edits, message history notices, and certain protocol messages)
If any <enc> node in the stanza has decrypt-fail="hide", the entire message uses Hide mode. See Decrypt-fail suppression for which outgoing message types set this attribute.

Decryption retry mechanism

The library automatically:
  1. Detects decryption failures (no session, invalid keys, MAC errors)
  2. Sends retry receipts with fresh prekeys
  3. Tracks retry count (max 5 attempts)
  4. Sends a parallel PDO (Peer Data Operation) request on the first retry
  5. Falls back to immediate PDO as last resort when retries are exhausted

Requesting a retry manually

Client::request_message_retry exposes the same retry-receipt path the automatic pipeline uses, for callers that intercept raw stanzas themselves (custom transports, replay tooling):
stanza must be a <message> node with id and from attrs, or the call fails fast with RetryRequestError::UnsupportedStanzaClass/MissingAttribute before any I/O. The client then parses the stanza once via the canonical message-info parser rather than reusing whatever metadata the caller already extracted — for a group or status-broadcast from, that parser also requires a valid participant attr, surfacing as RetryRequestError::InvalidStanza if it’s missing or unparseable. RetryRequestOptions is a small builder:
RetryRequestOutcome reports what happened without the caller needing to inspect internal counters:
  • Sent { retry_count, included_keys } — the retry receipt reached the transport
  • Suppressed { retry_count } — the protocol excludes this sender/chat combination from retry receipts, but the shared counter still advanced
  • LimitReached — the shared retry counter had already hit its cap (5 attempts)
This call only sends the retry receipt; a transport ack (to clear the stanza from the server’s offline queue) remains the caller’s responsibility — see Manual stanza acknowledgement.
Key material is attached to a retry receipt only when retry_count >= 2, force_include_keys is explicitly set, or the destination is stateless/hosted — never on the strength of reason alone. The diagnostic RetryReason only affects what’s reported to the sender, not whether keys go out.

Retransmitting a message manually

Client::retransmit_message is the sending-side counterpart to request_message_retry above: for callers that intercept <receipt type="retry"> stanzas themselves, it exposes the same targeted-resend path the automatic pipeline uses to answer one.
The client derives the wire stanza from the native wa::Message and keeps ownership of routing, encryption, session/sender-key state, persistence, and transport — MessageRetransmission never accepts a pre-built stanza. MessageRetransmission is a small builder:
  • chat — the conversation the original message belongs to. Its JID class selects the route. Pass a group JID (@g.us) to retransmit over the group’s sender-key path. Pass status@broadcast to retransmit over the status sender-key path. Pass a broadcast-list JID to retransmit pairwise. Pass any other user JID for a direct (1:1) retransmission.
  • requester — the requesting device’s JID. Retransmissions are always pairwise to this one device. Group and broadcast retries never fan out to the full audience — this matches the requesting participant’s own retry receipt.
  • message / message_id / retry_count — the original message content, its original ID, and the retry count carried on the requesting receipt. The client preserves message_id verbatim on the outgoing stanza. It preserves retry_count too, except on the status route — see below, where the wire format has no field for it.
  • with_recipient — only accepted for a direct retransmission. Set it when requester is one of your own companion devices: a self-device retransmission must say which device the message is actually addressed to. Leave it unset otherwise. When you do set it, requester must be a local companion device or a bot. This mirrors the recipient attribute WA Web propagates on self-device and bot retry receipts.
  • with_group_metadata_freshness — for group retransmissions, selects the Freshness policy used to load the group’s participant list and addressing mode. Use CachePreferred (default) to reuse a cached snapshot, or Refresh to force a fresh fetch first.
retransmit_message validates every field before touching Signal ratchet state. chat and requester must be non-empty. message_id must be non-empty. retry_count must fall in 1..MAX_RETRY_COUNT. requester must be a user device JID, not a group/broadcast/server JID. recipient is rejected outside the direct+local/bot case described above. Any violation returns SendError::InvalidRequest before ratchet advancement or persistence. For a group retransmission, the client re-resolves group metadata under your requested freshness policy. It preserves the group’s addressing mode on the outgoing stanza. A broadcast-list retransmission omits the addressing mode entirely. For a status retransmission, the client rebuilds the sender key immediately, but only for the requesting device. It distributes the sender key too, when the requesting device needs it. It never fans out to the rest of the status audience. The status retry count itself stays operation-level state — the captured WhatsApp Web wire format has no field for it on the status receipt. Example:
Like request_message_retry, this only performs the resend; acking the original stanza that triggered the retry (if any) remains the caller’s responsibility — see Manual stanza acknowledgement.

Unavailable message recovery via PDO

When the server delivers a message with an <unavailable> child node instead of <enc> nodes, the message content is not present in the stanza. The client classifies the <unavailable> node into an UnavailableType:
  • ViewOnce — a view-once message already viewed on another device (<unavailable type="view_once">)
  • Hosted — hosted content the phone does not fan out to companion devices (<unavailable hosted="true">, wire-boolean, also matches hosted="1")
  • Bot — an AI bot message fanout, signaled by a sibling <bot> child on the stanza
  • Unknown — a plain fanout with none of the above markers; the server just could not deliver the encrypted payload for some other reason
Classification follows WhatsApp Web’s own precedence (bot > hosted > view_once) via UnavailableType::from_fanout_flags. ViewOnce, Hosted, and Bot are exactly the three subtypes WhatsApp Web itself never placeholder-resends (WAWebNonMessageDataRequestPlaceholderMessageResendUtils excludes them). The phone won’t share that content with a companion device, so a PDO request for them would always come back empty — and would additionally surface a spurious “Finished syncing with WhatsApp on <device>” notification on the phone for no benefit. The client short-circuits these three: it skips the PDO entirely and acks the stanza directly so the offline queue still drains. UnavailableType::is_unrecoverable_fanout() reports true for all three. Only a plain (Unknown) fanout is recovered via PDO. The flow for that case is:
  1. The client detects the <unavailable> node and classifies it as Unknown
  2. An UndecryptableMessage event is dispatched immediately with is_unavailable: true
  3. A PDO request (PlaceholderMessageResend) is sent to your own bare JID (server routes to all devices including device 0)
  4. The phone responds with the full WebMessageInfo containing the decrypted message
  5. The client validates the response came from device 0 (primary phone) and dispatches the recovered message as a normal Event::Messages — event-only, bypassing the durability hook and the offline-drain batcher (delivered immediately, BatchOrigin::Live)
For ViewOnce, Hosted, and Bot, only steps 1–2 happen: the client dispatches UndecryptableMessage and acks immediately. There is no PDO round-trip and no follow-up Event::Messages to wait for. The recovered MessageInfo (for the PDO-recovered Unknown case) includes unavailable_request_id — the PDO request message ID — so you can correlate recovered messages with the original UndecryptableMessage event.
PDO is also used alongside retry receipts for normal decryption failures. On the first retry attempt, a parallel PDO request is sent with a 500ms delay to give the retry receipt time to resolve first. If all 5 retry attempts are exhausted, an immediate PDO request is sent as a last resort.
PDO requests are deduplicated — if a request is already pending for a given message, subsequent requests are skipped. Pending requests expire after 30 seconds. The deduplication cache uses phone-number JIDs as keys (not LID JIDs) to ensure the cache key matches the JID format in the phone’s response.

Sent message retry (outbound)

When a recipient’s device cannot decrypt your message, it sends a retry receipt. The client handles this automatically using DB-backed sent message storage:
  1. Every send_message() persists the serialized message payload to the sent_messages database table
  2. On retry receipt, the client retrieves the original payload, re-encrypts it for the requesting device, and resends
  3. The payload is consumed (deleted) on retrieval to prevent double-retry
  4. Expired entries are periodically cleaned up based on sent_message_ttl_secs (default: 5 minutes)
This matches WhatsApp Web’s getMessageTable pattern of reading from persistent storage on retry receipt.
An optional in-memory L1 cache (recent_messages in CacheConfig) can be enabled for faster retry lookups. When disabled (default, capacity 0), all retry lookups go directly to the database. See Bot - Cache Configuration Reference for details.

Receipts

Automatic delivery receipts

The client automatically sends delivery receipts for successfully decrypted messages:
See Receipt API reference for full details.

Sending read receipts

Receipt Events

Handle receipt updates from other participants:

Advanced Usage

At-Least-Once Delivery

By default, the client acknowledges a message to the server as soon as it is decrypted. If your process crashes before you persist the message, it is lost — the server will not redeliver it. Register an InboundDurabilityHook to defer the ack until your consumer durably commits the message(s). Live traffic calls the hook with a batch of one; an offline drain hands over an accumulated batch (WhatsApp Web’s MessageProcessorCache granularity), so the durability cost amortizes over the batch instead of paying a round-trip per message:
The hook must be idempotent — deduplicate by (info.source.chat, info.source.sender, info.id) since a crash after the consumer commits but before the ack lands will replay the message, and a failed batch is redelivered whole. See Inbound Durability Hook for the full contract, batching triggers, caveats, and a worked example.

Custom encryption handlers

For custom encryption types (e.g., pkmsg, msg, skmsg):
See Client API reference for handler registration details.

Filtering Messages

Use the type-safe JID methods (is_group(), is_broadcast_list(), is_status_broadcast()) to classify messages by chat type. With on_message, the MessageContext is already available:

Session and key management

The library automatically manages Signal Protocol sessions:
For advanced cases (identity changes, session cleanup):
When a contact reinstalls WhatsApp, you’ll receive an IdentityChange event after the client has completed all session cleanup. The client also re-issues TC tokens in the background to maintain privacy token continuity. See Signal Protocol for more on session management.

Error Handling

Best Practices

Next Steps