Skip to main content
As of PR #893, all feature-domain APIs return a typed, domain-specific error instead of anyhow::Error. Use ? to propagate errors into any anyhow context — all types implement std::error::Error and are #[non_exhaustive], so your existing error-handling code compiles unchanged. Lower-level APIs such as media upload/download are not covered by this page and may still surface anyhow::Error directly. As of PR #1090, the connect/lifecycle surface (Client::connect, wait_for_socket, wait_for_connected), the background Signal maintenance surface (Client::rotate_signed_pre_key, Client::flush_pending_signal_state), and the message-edit target-key resolvers (EncryptedEdit::original_sender_jid, SecretEncrypted::original_sender_jid/original_sender_for_dispatch) also return typed errors — see ConnectError, SignalMaintenanceError, and MessageEditError below. Client::logout() is now infallible ((), not Result<(), _>): the deregistration IQ it sends is best-effort, so there was nothing for a caller to branch on. As of PR #1100, every wrapping variant that used to be #[error(transparent)] is #[error("{0}")] instead — same Display output, but the wrapped error is now reachable via std::error::Error::source() instead of being erased. ErrorChainExt is a new extension trait, implemented for every std::error::Error, that walks that chain for you: server_rejection(), is_timeout(), is_transport_unavailable(), and store_failure() answer type-agnostically across every error on this page, without downcasting or string-matching. See Error chain recovery below. As of PR #1195, ErrorChainExt also answers http_status() — the HTTP status code behind a refused download, upload, sticker-pack fetch, or app-version fetch, recovered from a HttpStatusError node the same way server_rejection() recovers an IQ rejection. This is the one place the “lower-level APIs … may still surface anyhow::Error directly” caveat above gets a typed escape hatch: download/upload still return anyhow::Error, but the status inside that error is now recoverable by type instead of by parsing Display text. See Error chain recovery below. As of PR #1257, IqError::ServerError also carries response: RejectionStanza — the type="error" stanza itself, handed over whole the same way a type="result" response already was, alongside the four fields this crate parses off it (code, text, error_type, backoff). See RejectionStanza below. As of PR #1261, wacore::bot_message::decrypt_bot_message (and its private decryption helper) return Result<T, BotMessageError> instead of anyhow::Result<T>. Unlike the entries above, this is a breaking change to wacore’s public API, not an additive one — a caller matching on the previous anyhow::Error needs to switch to the typed enum. See BotMessageError below. As of PR #1299, a DM send that reaches no device of its recipient now fails instead of silently succeeding. A DM’s recipient devices and the sender’s own companion devices used to share one participant list, and the old guard only checked whether that list was empty. That missed one shape: every recipient device fails to encrypt (no session, a refused pre-key bundle), but a sender’s own companion still succeeds. The stanza then went out carrying only the sender’s own devices. The server acked it, and send_message returned Ok even though the recipient received nothing. Two conditions now surface as SendError::NoRecipientDevice(wacore::send::NoRecipientDeviceError) instead of Ok or the Internal catch-all: every resolved recipient device failing to encrypt, and the fan-out resolving no recipient device at all for a destination that isn’t one of the sender’s own identities. See NoRecipientDeviceError below. As of PR #1360, ConnectError::Version no longer follows from every version-source failure on the wasm32 target. That target fetches the version from the Facebook JS SDK bundle at connect.facebook.net, which sits on common tracker blocklists. A client blocked from reaching it now connects anyway, on the version the device already holds, and reports the fallback on Event::Connected via app_version_fallback instead of failing connect(). The native target’s source (sw.js) is unchanged — a client that can’t reach it still fails with ConnectError::Version, since that host serves WhatsApp Web itself and an unreachable sw.js is a real break. See Connected for the fallback payload. This is also a breaking change for a direct caller of whatsapp_rust::version::resolve_and_update_version (most callers only go through Client::connect, which absorbs it): its return type changed from Result<()> to Result<Option<wacore::types::events::AppVersionFallback>>. A caller discarding the result (resolve_and_update_version(...).await?;) keeps compiling unchanged; one that named the Ok type as () needs to switch to Option<AppVersionFallback>.

Error hierarchy

Domain errors that embed IqError or ClientError via #[from] propagate those failures automatically via ?. Some errors (e.g. AppStateError, SignalError) use internal anyhow::Error wrapping instead and do not have Iq or Client variants.
ConnectError and SignalMaintenanceError are not variants of ClientError — they are separate top-level error types returned directly by their respective methods (previously those methods returned bare anyhow::Error). ClientError::AlreadyConnected was removed in PR #1090; the equivalent case now lives on ConnectError::AlreadyConnected.

Error chain recovery

Added in PR #1100. Before this, #[error(transparent)] on a wrapping variant made Display forward to the inner error but also made source() forward to that error’s own source — so the wrapped error itself was never reachable, and a consumer walking source() to find (say) a 403 from the server lost the typed node and was left parsing Display text. Every transparent in the crate (46 occurrences) is now #[error("{0}")]: byte-identical Display output, but source() now returns the wrapped error itself, so it can be downcast. ErrorChainExt is a blanket-implemented trait (impl<E: std::error::Error> ErrorChainExt for E) that turns that walk into a few type-agnostic questions, so a domain error added later answers them without implementing anything:
  • sources() — an iterator over the error and everything reachable from it via source(), nearest first. Use this to recover a domain type the other methods don’t model.
  • server_rejection() — the ServerRejection behind this error, if any of the three types that can carry one (wacore::request::IqError, crate::request::IqError, or the crate-boundary ServerErrorCode) appear anywhere in the chain. Reports IQ-level rejections only — MexError::ExtensionError’s code is a GraphQL extension code, a different space from the IQ code attribute, so it is deliberately not reported here.
  • http_status() — added in PR #1195. Recovers the HTTP status code behind this error, if any, from a HttpStatusError node anywhere in the chain. Populated by Client::download/download_to_writer/download_from_params*, Client::upload/upload_stream, Client::fetch_sticker_pack, and the internal app-version fetch behind Client::connect (sw.js, or the Facebook JS SDK bundle on wasm32; see HTTP Client). Each of those attaches the status only when the client refused a completed HTTP exchange. None does not mean no HTTP exchange happened — a body that downloaded fine and then failed decryption or hash validation also reports None, since no refused status was ever attached. Treat None as “no typed refusal is on the chain,” not as “nothing came back from the CDN,” and inspect the underlying cause instead of assuming it is always the caller’s own bug. Kept separate from server_rejection() — a CDN refusing a byte range and the chat server refusing a stanza are different layers with different remedies, so one accessor answering for both would report a number while hiding which thing to retry.
  • is_timeout() — whether the operation ran out of time: a request that got no answer, or a connect/handshake step that never completed.
  • is_transport_unavailable() — whether the failure was the transport being gone (disconnected, socket/channel closed) rather than the operation being refused. Mirrors the judgement the send and receive paths already make internally when deciding whether a failure is worth retrying.
  • store_failure() — the StoreError behind this error, if a persistence backend failed anywhere in the chain.
From a caller holding anyhow::Error rather than a typed error, annotate the cast — anyhow::Error has two AsRef<dyn Error> impls, and both are covered:
http_status() follows the same pattern — useful for download/upload, which still return bare anyhow::Error:

ServerRejection

Borrowed from whichever error in the chain carried it, so recovering one costs no allocation.

RejectionStanza

Added in PR #1257. IqError::ServerError’s response field: the <iq type="error"> stanza the receive path decoded, kept as-is rather than reduced to the four fields ServerRejection exposes above. It wraps the same Arc<OwnedNodeRef> the success path already hands back, so attaching it to the error costs one refcount bump, not a copy.
ServerRejection’s four fields cover what WA Web’s own parseIqResponse reads off an error; RejectionStanza is the escape hatch for everything that parser (and this crate’s) leaves unread — further <iq>/<error> attributes, <error> children such as XMPP application-condition elements, and the raw bytes, which are the only faithful material for logging or replaying a rejection. Deref<Target = OwnedNodeRef> (see OwnedNodeRef) keeps every node accessor reachable directly on the wrapper — response.tag(), response.attrs(), response.get_optional_child(...), or response.get() for the underlying NodeRef. Debug is overridden to print only the tag (<iq>), not the stanza’s contents. This matters because background IQ failures on the connect path (the post-connect active IQ, props, blocklist, privacy settings) are logged with {e:?} at warn level, and an error stanza’s attributes or children can carry a JID — a straight derive would have written that into production logs where before only the four summarized fields went. Read the node explicitly (response.get(), response.attrs(), …) when you need its contents. Re-exported from the crate root as whatsapp_rust::RejectionStanza, and from whatsapp_rust::prelude.

HttpStatusError

Added in PR #1195, in whatsapp_rust::http. Carries the status of an HTTP exchange the client refused — the source node http_status() looks for.
The download and upload paths attach this as the source of the anyhow::Error they return — anyhow::Error::new(HttpStatusError { status }).context("Download failed with status: 403") — so the message a caller logs is unchanged while the status becomes reachable by type via http_status() instead of only by parsing that message. Re-exported as whatsapp_rust::http::HttpStatusError.
Rendering changed alongside the source() fix: a wrapping variant’s Display still prints exactly what it wraps, so code that concatenates every node in a chain (a logging layer, a display_chain helper) now sees the same sentence repeated once per wrapping variant — e.g. CommunityError::Group(GroupError::Iq(..)) is three nodes rendering one sentence three times. That repetition is the cost of keeping the wrapped error downcastable. Print the innermost cause, or collapse equal neighbours, rather than joining every node in the chain.
ErrorChainExt, ServerRejection, and Sources are re-exported from the crate root (whatsapp_rust::ErrorChainExt, whatsapp_rust::ServerRejection, whatsapp_rust::Sources). HttpStatusError lives at whatsapp_rust::http::HttpStatusError — it is not re-exported from the crate root, since most callers only need http_status() and never need to name the type itself.

Domain error types

Type definitions

SendError

NoRecipientDevice — added in PR #1299. A DM had no recipient device available: every resolved recipient device failed encryption, or no recipient device resolved for a non-self destination, so nothing was sent. Distinct from a transport failure: the connection is fine and the message id was never on the wire. The useful retry is SendOptions::default().with_device_freshness(Freshness::Refresh) passed to send_message_with_options, which forces the recipient’s device list to re-resolve instead of reading the cached one. An immediate resend with the same (cached) options would hit the same empty result. Wraps NoRecipientDeviceError, the typed cause from wacore, reachable via source(). PrimaryDeviceRejected — added in PR #1362. The pre-key fetch that establishes sessions ahead of a DM send got back a 406 naming a primary device (device 0), either the recipient’s or the sender’s own, so nothing was built or sent. This mirrors WA Web’s ensureE2ESessions, which throws on a named rejection unless every rejected device is a companion (device != null && device !== DEFAULT_DEVICE_ID). A companion’s 406, and any non-406 rejection code even on a primary, behave as before: refreshed and skipped, not fatal. The device lists the fetch named are already refreshed by the time this returns, so retry immediately — the retry re-resolves them instead of repeating the same question. This is distinct from NoRecipientDevice: that variant means encryption was attempted and failed, or had nothing to attempt, while PrimaryDeviceRejected means the fetch that would have supplied key material was refused outright, before encryption started.

NoRecipientDeviceError

Added in PR #1299, in wacore::send. The typed cause carried by SendError::NoRecipientDevice.
Variants:
  • EncryptionFailed — every device resolved for the recipient was attempted and none produced an <enc> node. attempted is the device count; source is the first per-device failure (a missing session, a refused pre-key bundle), reachable via source().
  • Unresolved — the fan-out held no device for the recipient to begin with, so nothing was attempted and there is no per-device cause. Only returned when the destination is not one of the sender’s own identities — an empty recipient half for a self chat (note to self) is the normal shape, since every resolved device is the sender’s own, and that case still returns Ok.
Not returned when every one of the sender’s own devices fails to encrypt in a self chat — that stays on the pre-existing Internal catch-all, since it isn’t about a recipient at all.

GroupError

DescriptionConflict (added in PR #1097) is returned by Groups::set_description when the prev token no longer matches the group’s current description — see GroupError in the groups reference.

BlockingError

BusinessError

get_catalog, get_collections, and get_order go over MEX; update_profile, set_cover_photo, and remove_cover_photo go over IQ. InvalidUpdate is client-side validation on update_profile — see BusinessProfileUpdateError in the business reference for every rejection reason.

AppStateError

Shared by ChatActions and Labels.

ChatStateError

CommunityError

ContactError

Username is returned by Contacts::find_by_username when the given handle can’t be turned into a valid username lookup (wrong length, or a username key usync’s own validation rejects) — see UsernameLookupError in the contacts reference.

NewsletterError

PollError

ProfileError

SignalError

TcTokenError

MediaReuploadError

PresenceError

ConnectError

Returned by Client::connect, Client::wait_for_socket, and Client::wait_for_connected. Added in PR #1090, replacing bare anyhow::Error on all three methods. As of PR #1258, connect()’s success case changed too — it resolves to Result<Connection<'_>, ConnectError> rather than Result<(), ConnectError>; see Connection.
Variants:
  • AlreadyConnected — a connection is already up, or another connect() attempt is already in flight. This is the old ClientError::AlreadyConnected case, moved here.
  • NotActivated — construction never completed (only reachable with the client-lifecycle feature), so the attempt was rejected before any I/O.
  • Shutdown — added in PR #1258. The client was already shut down (disconnect(), logout(), or signal_shutdown_sync()) before or during this connect() attempt. Shutdown is final and non-reversible, so this is refused rather than reviving a client the application was already told is gone — build a new client instead of reconnecting this one.
  • Paused — added in PR #1265. Client::pause is in effect. Unlike Shutdown this is not final: Client::resume lifts it and connect() works again. Re-checked at every step of the connect graph (version fetch, transport open, handshake, publish), so an attempt already in flight when pause() lands is retracted rather than published, not just refused for attempts that start after.
  • Timeout — a step of the connect flow ran out of time. stage says which one (see ConnectStage below); wait_for_socket/wait_for_connected always report Socket/Ready respectively.
  • Version / Transport — the app-version resolution or transport factory failed outright (not a timeout). As of PR #1195, if Version was caused by a non-2xx response fetching the app version (sw.js, or the Facebook JS SDK bundle on wasm32), the status is recoverable via err.http_status() (ErrorChainExt) instead of only appearing in the message. As of PR #1360, Version is no longer necessarily the outcome of a failed fetch on the wasm32 target: that target’s source is survivable, so a client blocked from reaching it connects on a fallback version instead — see Connected. The native target’s source stays fatal, so Version there is unchanged.
  • Handshake — the Noise handshake failed after the transport was up. Wraps HandshakeError (see WebSocket & Noise Protocol) via #[from], so ? still works and matches!(err, ConnectError::Handshake(e) if e.is_transient()) replaces the old err.downcast_ref::<HandshakeError>() pattern for deciding whether a failed reconnect attempt is worth retrying.
Added in PR #1100: ConnectError::is_timeout() is an exhaustively-matched method that reports true for Timeout and for a Handshake(e) where e.is_timeout() (HandshakeError gained the same method). Prefer ErrorChainExt::is_timeout() unless you specifically hold a ConnectError and want to skip the chain walk.

ConnectStage

The step of the connect flow a ConnectError::Timeout refers to:
Both ConnectError and ConnectStage are re-exported from the crate root (whatsapp_rust::ConnectError, whatsapp_rust::ConnectStage) and from whatsapp_rust::prelude.

SignalMaintenanceError

Returned by Client::rotate_signed_pre_key and Client::flush_pending_signal_state. Added in PR #1090, replacing bare anyhow::Error on both methods. The split that matters to a caller is corruption versus everything else: CorruptKey will keep failing until the stored material is replaced, while Storage, Iq, Signal, and DrainCommitFailed are worth retrying on the same client — none of them mean the local key material itself is bad. DrainShuttingDown is different again — it fires because the client itself is being torn down, so retrying the same call on that instance fails the same way; the only recovery is a fresh client.
Variants:
  • CorruptKey — key material is unusable: bad encoding, or a missing/wrong-sized field on a staged signed pre-key. Almost always means a retry would read back the same bytes, so this is not worth retrying without intervention.
  • Storage — the storage backend failed a read, write, or flush. The typed backend error stays reachable via std::error::Error::source().
  • Iq — the rotation IQ was rejected by the server or never reached it (embeds IqError via #[from]).
  • Signal — a Signal primitive failed (e.g. signing the new signed pre-key).
  • DrainCommitFailed — the inbound drain batch could not be committed, so the Signal cache was deliberately left unflushed and the server will redeliver.
  • DrainShuttingDown — the client is going away while an inbound drain is active; flushing would persist ratchet advances whose messages have no durable row. Not worth retrying on this client — it will report the same error until the client is dropped and replaced.
SignalMaintenanceError is re-exported from the crate root as whatsapp_rust::SignalMaintenanceError. Signal::* methods on the Signal struct are unaffected — SignalError already had Internal/Protocol variants and now converts SignalMaintenanceError via From (mapping Signal(e) to SignalError::Protocol(e) and everything else to SignalError::Internal).

MessageEditError

Returned by EncryptedEdit::original_sender_jid, SecretEncrypted::original_sender_jid, and SecretEncrypted::original_sender_for_dispatch — the target-key sender resolvers used when decrypting secret_encrypted_message envelopes (message edits, poll edits/add-option, event edits). Added in PR #1090, replacing bare anyhow::Error. See Decrypting secret-encrypted envelopes for how these methods are used.
Variants:
  • InvalidTargetJid — a JID carried by the target message key did not parse. field names the offending wire field ("participant" or "remoteJid"); the underlying JidError is available via source().
  • MissingTargetSender — the target key carried neither participant nor remote_jid, and from_me was not Some(true), so no author can be derived from it.
Both variants mean the peer sent a target message key that cannot be attributed — retrying the same envelope yields the same result. MessageEditError is re-exported from the crate root as whatsapp_rust::MessageEditError.

BotMessageError

Returned by wacore::bot_message::decrypt_bot_message and its private decryption helper. Added in PR #1261, replacing bare anyhow::Result.
Unlike every other error type on this page, BotMessageError lives in the wacore crate, not whatsapp_rust — and its introduction is a breaking change to wacore’s public API: decrypt_bot_message’s return type changed from anyhow::Result<T> to Result<T, BotMessageError>, so a caller matching on the previous anyhow::Error needs to switch to this typed enum.
Variants:
  • InvalidSecretLength — the bot message secret is not the expected size.
  • InvalidIvLength — the IV carried by the payload is not the expected size.
  • PayloadTooShort — the payload is too short to contain what it claims to.
  • KeyDerivation — deriving the decryption key from the secret failed.
  • AuthenticationFailed — the ciphertext did not verify (AES-GCM tag mismatch).
BotMessageError::stage(&self) -> BotMessageFailure classifies which stage of decryption a failure belongs to, without matching every variant by name:
Use stage() when you only care whether the envelope, the secret, or the authentication step failed — for metrics or a coarse retry policy — rather than the specific BotMessageError variant.

ClientError (base type)

AlreadyConnected was removed from ClientError in PR #1090 — it had no remaining constructor once Client::connect() moved to ConnectError. If you matched on ClientError::AlreadyConnected, switch to ConnectError::AlreadyConnected.

IqError (base type)

IqError::ClientState holds a Box<ClientError> (not ClientError directly) to break the mutual-size cycle between ClientError and IqError. Pattern matching needs dereferencing: IqError::ClientState(e) => { /* *e is a ClientError */ }.
Added in PR #1100: wacore::request::IqError gained public is_timeout() (true only for Timeout) and is_transport_unavailable() (true for NotConnected, Disconnected, and InternalChannelClosed) methods, each an exhaustive match so a future variant has to be classified rather than silently defaulting to false. whatsapp_rust::request::IqError (the crate-level type shown above, with the extra Socket/EncryptSend/ClientState/EncodeError/ParseError variants) makes the same judgement internally but does not expose it publicly — go through ErrorChainExt instead, which handles both types. Added in PR #1257: ServerError carries response: RejectionStanza — see RejectionStanza above and the migration note below. Matching with .. is unaffected by this field. A match that already names all four former fields without .. needs .. added (or response bound too) — Rust rejects a struct pattern missing a field with E0027. Constructing the variant by hand (mainly test fixtures) needs updating the same way.

Migration guide

From anyhow::Error

If your handler used ? into anyhow:
If you were matching on anyhow::Error downcasts, switch to typed matching:

IqError::ClientState boxing

From PR #1090: connect()/logout()/Signal maintenance

As of PR #1258, connect()’s Ok case changed too — this snippet only shows the error-matching migration, so it still discards a successful connection. See the connect() migration for the full picture:

From PR #1100: error chain recovery

If you were walking source() by hand to recover a server rejection or classify a failure, switch to ErrorChainExt. This is the shape the crate’s own internal helper had before PR #1100 (ClientError::is_transport_unavailable is public; the IqError half required a call this crate could make on its own type but a downstream consumer could not, since it wasn’t exposed):
If you concatenated an error’s full chain (e.g. a logging layer joining every source() node into one string), be aware the text changed: a wrapping variant still renders exactly what it wraps, so #[error(transparent)] becoming #[error("{0}")] means a chain like CommunityError::Group(GroupError::Iq(..)) now repeats the same sentence once per wrapping node instead of once. Each error’s own Display output — what you get from {e} on a single error value — is unchanged. Print the innermost cause, or collapse equal neighbours, rather than joining every node. #[non_exhaustive] was added to four error enums that were missing it: wacore::pair_code::PairCodeError, wacore::shortcake::ShortcakeError, wacore::iq::chatstate::ChatstateParseError, and wacore::iq::dirty::DirtyBitParseError. An exhaustive match on any of these from outside their defining crate now fails with E0004; add a _ => {} arm.

From PR #1195: recovering an HTTP status by type

If you were matching on the text of a download/upload error to classify it (e.g. e.to_string().contains("403")), switch to ErrorChainExt::http_status():
Messages are unchanged either way — http_status() is purely additive, recovering a fact that was already in the text but previously reachable only by parsing it.

From PR #1257: ServerError carries the rejection stanza

IqError::ServerError gained a response: RejectionStanza field carrying the type="error" stanza verbatim, alongside the four fields it already parsed off it. Matching with .. is unaffected — this is the common case, and nearly every match in the codebase already used it:
If you matched all four former fields by name without .., the pattern now fails to compile with E0027 (“pattern does not mention field response”) — add .., or bind response too:
Constructing the variant by hand — mainly test fixtures — now needs the stanza it was rejected with:
A fixture with no real wire response can build one directly, but OwnedNodeRef::new expects node bytes with the format byte already stripped, not the raw output of marshal — the same unpack step the receive path runs ahead of every OwnedNodeRef::new call:
From<wacore::request::IqError> for whatsapp_rust::request::IqError is also removed — a bare From conversion has no response available to attach, which is exactly the material this change stops discarding. Replace it with IqError::from_response:

From PR #1261: decrypt_bot_message returns a typed error

If you were matching on anyhow::Error from wacore::bot_message::decrypt_bot_message, switch to BotMessageError:

From PR #1299: a DM with no recipient device now returns a typed error

Previously, a DM where every recipient device failed to encrypt could still return Ok if one of the sender’s own companion devices succeeded — the stanza went out carrying only the sender’s own devices, the server acked it, and the caller had no way to tell the message never reached its recipient. That case, and the case where the fan-out resolved no recipient device at all (for a non-self destination), now return Err(SendError::NoRecipientDevice(..)) instead:
SendError is #[non_exhaustive], so this new variant does not break a match that already ends in a catch-all arm. See NoRecipientDeviceError for the two variants it can carry.

From PR #1362: a DM’s partial fan-out is now visible, and a primary’s 406 now fails the send

Three related changes, all in #1362: SendResult gains recipient_fanout: Option<RecipientFanout> (see RecipientFanout), populated for a DM. A caller that treated Ok as full delivery can now check it instead of assuming:
SendError gains PrimaryDeviceRejected (see above). A pre-key fetch that gets a 406 naming a primary device now fails the send instead of silently continuing with zero established sessions for it:
Separately, a DM phash mismatch (see Phash validation) now re-resolves the peer’s device list and resends the message, under the original message id, to any device that list holds and the original stanza did not cover. Previously a mismatch only invalidated caches. This has no SendError/SendResult shape to match on — it runs after the original send_message call has already returned. Each newly discovered device receives the message for the first time; a device the original stanza already covered is left untouched, so this never delivers a duplicate to the same device. Both new variants are additive at the type level: SendError and SendResult are #[non_exhaustive], so a match or destructuring pattern written against the old shape still compiles. recipient_fanout is purely additive at runtime too — ignoring it changes nothing. PrimaryDeviceRejected is not: a caller whose match ends in a catch-all Err arm still compiles and still runs, but a named-primary 406 now takes that arm instead of the Ok path it took before, so this is a real behavior change for that one case, just not a breaking one.

From PR #1406: edit, revoke, and pin now return the SendResult they built

#1406 is a real breaking change, not an additive one — it changes the Ok type of six methods so a caller that shares one Client between several consumers can see what a self-built send (an edit, a revoke, a pin, a poll) actually put in the chat. WhatsApp echoes a send to every device on the account except the one that sent it, so without this a consumer other than the sender had nothing to show for those sends but an id. SendResult gains message: Arc<wa::Message> (see SendResult) — the message exactly as the send pipeline encoded it. edit_message, edit_message_with_options, and edit_message_encrypted now return SendResult instead of String; revoke_message, pin_message, and unpin_message now return SendResult instead of (). MessageContext::edit_message and MessageContext::revoke_message follow the same change.
A call site that used ? without binding the Ok value (client.revoke_message(...).await?;) compiles unchanged — only code that named the previous String or () result needs to change. SendResult losing its Eq derive (wa::Message carries floats; PartialEq and Clone are unaffected) only matters to code that put a SendResult in a HashSet/BTreeSet key position or otherwise required Eq. Separately, wacore::proto_helpers::MessageExt::prepare_for_forward now returns wa::Message by value instead of Box<wa::Message> — see the forwarding-preparation note. prepare_for_quote is unchanged. Newsletter edit_message/revoke_message (client.newsletter().edit_message(...)) are untouched and still return (): the plaintext channel path sends a node under the target’s own id with the caller’s body, so there is neither a fresh id nor a built message to report.