Skip to main content

send_message

Send a message to a user, group, or newsletter. Newsletter messages are sent as plaintext (no E2E encryption) automatically when the recipient is a newsletter JID.
pub async fn send_message(
    &self,
    to: impl Into<Jid>,
    message: wa::Message,
) -> Result<SendResult, anyhow::Error>
to
impl Into<Jid>
required
Recipient JID. Can be:
  • Direct message: 15551234567@s.whatsapp.net
  • Group: 120363040237990503@g.us
  • Newsletter: 120363999999999999@newsletter
When the recipient is a newsletter JID, the message is sent as plaintext with the correct type and mediatype stanza attributes inferred automatically.
message
wa::Message
required
Protobuf message to send. Set one of the message fields:
  • conversation - Plain text message
  • extended_text_message - Text with formatting/links
  • image_message - Image with caption
  • video_message - Video with caption
  • document_message - Document/file
  • audio_message - Audio/voice note
  • sticker_message - Sticker
  • sticker_pack_message - Sticker pack (grouped sticker collection)
  • location_message - GPS location
  • contact_message - Contact card
  • album_message - Album (grouped media) parent message
SendResult
SendResult
Contains the message_id (unique ID for tracking receipts, edits, revokes) and to (resolved recipient JID). Use send_result.message_key() to get a wa::MessageKey for album child linking, pinning, or other operations that reference this message.

SendResult

Result of a successfully sent message. Provides the message ID and a convenience method to construct a MessageKey for follow-up operations like album child linking.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SendResult {
    pub message_id: String,
    pub to: Jid,
}

impl SendResult {
    /// Returns a MessageKey for this sent message.
    /// Useful for album child linking, pinning, and other operations.
    pub fn message_key(&self) -> wa::MessageKey;
}
SendResult is #[non_exhaustive]: struct-literal construction and exhaustive struct destructuring from outside the crate are both disallowed. Field reads are unaffected; add .. to any exhaustive destructuring patterns.

ChatMessageId

Identifies a specific message within a chat. Useful for operations that need both the chat and message ID together.
pub struct ChatMessageId {
    pub chat: Jid,
    pub id: MessageId,
}

Example: text message

use waproto::whatsapp as wa;

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

let result = client.send_message(
    "15551234567@s.whatsapp.net".parse()?,
    message
).await?;

println!("Message sent with ID: {}", result.message_id);

Example: Newsletter message

use waproto::whatsapp as wa;

let newsletter_jid: Jid = "120363999999999999@newsletter".parse()?;

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

// Newsletter messages are sent as plaintext automatically
let result = client.send_message(newsletter_jid, message).await?;
Newsletter reactions use a different stanza format and are still sent through client.newsletter().send_reaction(). See the Newsletter API.

Example: image with caption

use waproto::whatsapp as wa;

// First upload the image
let upload_result = client.upload(image_bytes, MediaType::Image, Default::default()).await?;

let message = wa::Message {
    image_message: Some(Box::new(wa::message::ImageMessage {
        url: Some(upload_result.url),
        direct_path: Some(upload_result.direct_path),
        media_key: Some(upload_result.media_key_vec()),
        file_enc_sha256: Some(upload_result.file_enc_sha256_vec()),
        file_sha256: Some(upload_result.file_sha256_vec()),
        file_length: Some(upload_result.file_length),
        media_key_timestamp: Some(upload_result.media_key_timestamp),
        caption: Some("Check out this image!".to_string()),
        mimetype: Some("image/jpeg".to_string()),
        ..Default::default()
    })),
    ..Default::default()
};

let result = client.send_message(chat_jid, message).await?;

Example: Album (grouped media)

Send multiple images and/or videos as a single grouped album. First send the parent AlbumMessage with expected counts, then send each child media wrapped with wrap_as_album_child:
use waproto::whatsapp as wa;
use whatsapp_rust::proto_helpers::wrap_as_album_child;

// 1. Send the parent album message with expected media counts
let album_parent = wa::Message {
    album_message: Some(Box::new(wa::message::AlbumMessage {
        expected_image_count: Some(2),
        expected_video_count: Some(1),
        ..Default::default()
    })),
    ..Default::default()
};

let parent_result = client.send_message(&chat_jid, album_parent).await?;
let parent_key = parent_result.message_key();

// 2. Send each child media wrapped as an album child
let image1 = wa::Message {
    image_message: Some(Box::new(wa::message::ImageMessage {
        url: Some(upload1.url),
        direct_path: Some(upload1.direct_path),
        media_key: Some(upload1.media_key_vec()),
        file_sha256: Some(upload1.file_sha256_vec()),
        file_enc_sha256: Some(upload1.file_enc_sha256_vec()),
        file_length: Some(upload1.file_length),
        media_key_timestamp: Some(upload1.media_key_timestamp),
        mimetype: Some("image/jpeg".to_string()),
        ..Default::default()
    })),
    ..Default::default()
};

let wrapped = wrap_as_album_child(image1, parent_key.clone());
client.send_message(&chat_jid, wrapped).await?;

// Repeat for each additional image/video in the album
See the Sending Messages guide for a complete walkthrough.

forward_message

Forward an existing message to a chat. Builds a forward-ready copy of message and sends it via send_message.
pub async fn forward_message(
    &self,
    to: impl Into<Jid>,
    message: &wa::Message,
) -> Result<SendResult, anyhow::Error>
to
impl Into<Jid>
required
Recipient JID (DM, group, or newsletter).
message
&wa::Message
required
Source message to forward. May be a received body or a wrapper (ephemeral / view-once); the inner content is unwrapped automatically before sending.
SendResult
SendResult
Same shape as send_message: contains the new message_id and the resolved recipient JID.
The helper applies WhatsApp’s standard forwarding rules:
  • Sets context_info.is_forwarded = true so the recipient sees the Forwarded label.
  • Bumps forwarding_score. At 5 it jumps to the 127 sentinel that clients render as Forwarded many times.
  • Strips the reply/quote chain and mentions from the source message.
  • Drops the source message_context_info so the send path mints a fresh message_secret.
  • Promotes a bare conversation to extended_text_message so the forward marker can attach.
  • Relays existing media from the same CDN blob (media_key, url, and friends are carried over) — no re-download or re-upload.

Example: forward a received message

// `received` is a `wa::Message` from an incoming event.
let result = client.forward_message(destination_jid, &received).await?;
println!("Forwarded as {:?}", result.message_id);
For lower-level access, see MessageExt::prepare_for_forward, which returns the prepared wa::Message without sending it.

send_message_with_options

Send a message with additional customization options.
pub async fn send_message_with_options(
    &self,
    to: impl Into<Jid>,
    message: wa::Message,
    options: SendOptions,
) -> Result<SendResult, anyhow::Error>
to
impl Into<Jid>
required
Recipient JID
message
wa::Message
required
Protobuf message to send
options
SendOptions
required
Additional send options (see below)
SendResult
SendResult
Contains the message ID and recipient JID

SendOptions

Options for customizing message sending behavior.
pub struct SendOptions {
    /// Override the auto-generated message ID.
    /// Useful for resending a failed message with the same ID or idempotency.
    pub message_id: Option<String>,
    /// Extra XML child nodes on the message stanza.
    pub extra_stanza_nodes: Vec<Node>,
    /// Ephemeral duration in seconds. Sets `contextInfo.expiration` on the
    /// message for disappearing messages support.
    /// Common values: 86400 (24h), 604800 (7d), 7776000 (90d).
    pub ephemeral_expiration: Option<u32>,
    /// Force the `<message type="...">` attribute instead of deriving it from
    /// content. Escape hatch for a type the classifier can't infer.
    pub stanza_type_override: Option<StanzaType>,
}
message_id
Option<String>
default:"None"
Override the auto-generated message ID. When set, the provided ID is used instead of generating a new one. Useful for resending a failed message with the same ID or ensuring idempotency.
extra_stanza_nodes
Vec<Node>
default:"[]"
Additional XML nodes to include in the message stanza. Used for advanced protocol features like quoted replies, mentions, or custom metadata.
ephemeral_expiration
Option<u32>
default:"None"
Sets the ephemeral (disappearing) message duration in seconds by injecting contextInfo.expiration on the protobuf message. When the recipient’s chat has disappearing messages enabled, set this to match the chat’s ephemeral timer. Common values: 86400 (24 hours), 604800 (7 days), 7776000 (90 days). Pass 0 or None to send a non-ephemeral message.
stanza_type_override
Option<StanzaType>
default:"None"
Forces the <message type="..."> attribute on the outgoing stanza instead of letting the content classifier pick one. Leave as None for normal sends — the library infers the correct type from the protobuf payload. Set this only when you’re sending a message variant the classifier can’t recognize and the server requires a specific wire type. See Stanza types for the available values.
The override is applied when the stanza is first sent. The retry path reclassifies from content, so an override doesn’t follow a message through resends.

Example: Send with a custom message ID

use whatsapp_rust::send::SendOptions;

let options = SendOptions {
    message_id: Some("3EB0ABC123".to_string()),
    ..Default::default()
};

let result = client.send_message_with_options(
    chat_jid,
    message,
    options
).await?;

assert_eq!(result.message_id, "3EB0ABC123");

Example: Send an ephemeral (disappearing) message

use whatsapp_rust::send::SendOptions;

let options = SendOptions {
    ephemeral_expiration: Some(604800), // 7 days
    ..Default::default()
};

let result = client.send_message_with_options(
    chat_jid,
    message,
    options
).await?;
The ephemeral_expiration value should match the chat’s disappearing messages timer. You can get this from GroupMetadata.ephemeral_expiration for groups, or from MessageInfo.ephemeral_expiration on received messages. See the sending messages guide for a complete walkthrough.

Example: Send with extra stanza nodes

use whatsapp_rust::send::SendOptions;
use wacore_binary::builder::NodeBuilder;

let options = SendOptions {
    extra_stanza_nodes: vec![
        NodeBuilder::new("custom-tag")
            .attr("key", "value")
            .build()
    ],
    ..Default::default()
};

let result = client.send_message_with_options(
    chat_jid,
    message,
    options
).await?;

edit_message

Edit a previously sent message.
pub async fn edit_message(
    &self,
    to: impl Into<Jid>,
    original_id: impl Into<String>,
    new_content: wa::Message,
) -> Result<String, anyhow::Error>
to
impl Into<Jid>
required
Chat JID where the original message was sent
original_id
String
required
ID of the message to edit (from send_message return value)
new_content
wa::Message
required
New message content to replace the original
message_id
String
Message ID of the edit message

Example: Edit a message

use waproto::whatsapp as wa;

// Send original message
let result = client.send_message(
    &chat_jid,
    wa::Message {
        conversation: Some("Hello!".to_string()),
        ..Default::default()
    }
).await?;

// Edit it
let edit_id = client.edit_message(
    &chat_jid,
    &result.message_id,
    wa::Message {
        conversation: Some("Hello, edited!".to_string()),
        ..Default::default()
    }
).await?;
The edit is sent as a top-level protocolMessage with type = MESSAGE_EDIT, matching the WhatsApp Web wire shape. In group chats, the correct participant JID (LID or PN) is resolved for you, and a fresh stanza ID is used so the server does not deduplicate the edit against the original message.

edit_message_encrypted

Edit a message via the message-secret encrypted path (a secret_encrypted_message with secret_enc_type = MESSAGE_EDIT) instead of the plaintext protocolMessage edit produced by edit_message. This is the form Community Announcement Groups require, and the shape WhatsApp Web sends when its message_edit_to_message_secret_sender_enabled flag is on. The new content is encrypted under the original message’s secret.
pub async fn edit_message_encrypted(
    &self,
    to: impl Into<Jid>,
    original_id: impl Into<String>,
    message_secret: &[u8],
    new_content: wa::Message,
) -> Result<String, anyhow::Error>
to
impl Into<Jid>
required
Chat JID where the original message was sent. Newsletter/channel JIDs are rejected — use Newsletter::edit_message for channels.
original_id
String
required
ID of the message to edit. You can only edit your own messages, so the original sender and the editor are both you.
message_secret
&[u8]
required
The 32-byte secret of the original message. Must be exactly 32 bytes. Persist it from MessageContextInfo.message_secret on the sent message and retrieve it by message ID from your store.
new_content
wa::Message
required
Replacement message content.
message_id
String
Message ID of the edit message.

Example: encrypted edit

use waproto::whatsapp as wa;

let edit_id = client.edit_message_encrypted(
    &chat_jid,
    &original_id,
    &message_secret,
    wa::Message {
        conversation: Some("edited (encrypted)".to_string()),
        ..Default::default()
    },
).await?;
Use edit_message for ordinary DM and group edits. Reach for edit_message_encrypted only when the chat requires the message-secret edit form (e.g. Community Announcement Groups). Inbound encrypted edits are decrypted automatically on receive — see decrypting secret-encrypted envelopes.

revoke_message

Delete a message for everyone in the chat (revoke). This sends a revoke protocol message that removes the message for all participants. The message will show as “This message was deleted” for recipients.
pub async fn revoke_message(
    &self,
    to: impl Into<Jid>,
    message_id: impl Into<String>,
    revoke_type: RevokeType,
) -> Result<(), anyhow::Error>
to
impl Into<Jid>
required
Chat JID (direct message or group)
message_id
String
required
ID of the message to delete (from send_message return value)
revoke_type
RevokeType
required
Who is revoking the message:
  • RevokeType::Sender - Delete your own message
  • RevokeType::Admin { original_sender } - Admin deleting another user’s message in a group

RevokeType

Specifies who is revoking (deleting) the message.
#[non_exhaustive]
pub enum RevokeType {
    /// The message sender deleting their own message
    Sender,
    /// A group admin deleting another user's message
    /// `original_sender` is the JID of the user who sent the message
    Admin { original_sender: Jid },
}
RevokeType is #[non_exhaustive], so match statements should include a wildcard arm to handle future variants.
Sender
variant
Default variant. Use when deleting your own message. Works in both DMs and groups.
Admin
variant
Use when a group admin is deleting another user’s message. Only valid in groups. Requires original_sender JID.

Example: revoke own message

// Send a message
let result = client.send_message(
    &chat_jid,
    message
).await?;

// Delete it (sender revoke)
client.revoke_message(
    &chat_jid,
    &result.message_id,
    RevokeType::Sender
).await?;

Example: admin revoke in group

use wacore_binary::jid::Jid;

// Admin deleting another user's message
let original_sender: Jid = "15551234567@s.whatsapp.net".parse()?;

client.revoke_message(
    group_jid,
    &message_id,
    RevokeType::Admin { original_sender }
).await?;
Admin revoke is only valid for group chats. Attempting to use it in a direct message will return an error.
Since v0.6 admin revoke fan-outs are propagated correctly to every recipient device on retry: the original stanza carries edit="8" (EditAttribute::AdminRevoke), and prepare_dm_retry_stanza now accepts that attribute and re-emits it on each retry stanza. Previously the retry path stripped the edit attribute, so a single dropped device fan-out left the message un-deleted on that device. The library infers the right attribute from the protocol-message type via EditAttribute::infer_from_message(...), so applications calling revoke_message need no code changes.

pin_message

Pin a message in a chat for all participants.
pub async fn pin_message(
    &self,
    chat: impl Into<Jid>,
    key: wa::MessageKey,
    duration: PinDuration,
) -> Result<(), anyhow::Error>
chat
impl Into<Jid>
required
Chat JID where the message to pin is located
key
wa::MessageKey
required
The message key identifying which message to pin. Construct this from the message’s chat JID, message ID, sender info, and participant (for groups).
duration
PinDuration
required
How long the message should remain pinned (see below)

PinDuration

Specifies how long a message stays pinned. Defaults to 7 days (matches WhatsApp Web behavior).
#[non_exhaustive]
pub enum PinDuration {
    Hours24,
    Days7,   // default
    Days30,
}
PinDuration is #[non_exhaustive], so match statements should include a wildcard arm to handle future variants.
Hours24
variant
Pin for 24 hours
Days7
variant
Pin for 7 days (default)
Days30
variant
Pin for 30 days

Example: Pin a message for 7 days

use waproto::whatsapp as wa;
use whatsapp_rust::send::PinDuration;

let key = wa::MessageKey {
    remote_jid: Some(chat_jid.to_string()),
    id: Some(message_id.clone()),
    from_me: Some(false),
    participant: None,
};

client.pin_message(chat_jid, key, PinDuration::Days7).await?;

Example: Pin a group message for 30 days

use waproto::whatsapp as wa;
use whatsapp_rust::send::PinDuration;

let key = wa::MessageKey {
    remote_jid: Some(group_jid.to_string()),
    id: Some(message_id.clone()),
    from_me: Some(false),
    participant: Some(sender_jid.to_string()),
};

client.pin_message(group_jid, key, PinDuration::Days30).await?;

unpin_message

Unpin a previously pinned message.
pub async fn unpin_message(
    &self,
    chat: impl Into<Jid>,
    key: wa::MessageKey,
) -> Result<(), anyhow::Error>
chat
impl Into<Jid>
required
Chat JID where the pinned message is located
key
wa::MessageKey
required
The message key identifying which message to unpin

Example: Unpin a message

use waproto::whatsapp as wa;

let key = wa::MessageKey {
    remote_jid: Some(chat_jid.to_string()),
    id: Some(message_id.clone()),
    from_me: Some(false),
    participant: None,
};

client.unpin_message(chat_jid, key).await?;

keep_message

Keep (or un-keep) a message in a disappearing chat for everyone. This is the keepInChatMessage add-on used by WhatsApp Web’s “Keep in chat” action: when a chat has disappearing messages enabled, keeping a message prevents it from being deleted when the ephemeral timer expires.
pub async fn keep_message(
    &self,
    chat: impl Into<Jid>,
    key: wa::MessageKey,
    keep: bool,
) -> Result<SendResult, anyhow::Error>
chat
impl Into<Jid>
required
Chat JID where the target message lives.
key
wa::MessageKey
required
The message key identifying the message to keep or un-keep. Construct this from the target message’s chat JID, message ID, sender info, and participant (for groups).
keep
bool
required
true requests KEEP_FOR_ALL (keep the message past the disappearing timer). false requests UNDO_KEEP_FOR_ALL (reverse a previous keep).
The keep stanza itself is sent with a fresh message id; only the target message’s key is carried in the body, and the body’s timestamp_ms records the send time (not the kept message’s timestamp). The send path classifies this as a text add-on and maps the undo case to a sender-revoke edit attribute automatically, so no extra wiring is required.

Example: Keep a message for everyone

use waproto::whatsapp as wa;

let key = wa::MessageKey {
    remote_jid: Some(chat_jid.to_string()),
    id: Some(message_id.clone()),
    from_me: Some(false),
    participant: None,
};

client.keep_message(chat_jid, key, true).await?;

Example: Undo a previous keep

use waproto::whatsapp as wa;

let key = wa::MessageKey {
    remote_jid: Some(group_jid.to_string()),
    id: Some(message_id.clone()),
    from_me: Some(false),
    participant: Some(sender_jid.to_string()),
};

client.keep_message(group_jid, key, false).await?;

set_chat_disappearing_timer

Turn disappearing messages on or off for a 1:1 chat. Sends an EPHEMERAL_SETTING protocol message, mirroring WhatsApp Web’s chat-action.
pub async fn set_chat_disappearing_timer(
    &self,
    chat: Jid,
    duration: u32,
) -> Result<SendResult, anyhow::Error>
chat
Jid
required
The 1:1 chat (PN or LID). Group, status, and newsletter JIDs are rejected — for groups use Groups::set_ephemeral; for the account default use Client::set_default_disappearing_mode.
duration
u32
required
Timer in seconds. Common values: 86400 (24h), 604800 (7 days), 7776000 (90 days). Pass 0 to turn disappearing messages off.
SendResult
SendResult
Result of the setting message send. See SendResult.

Example: enable and disable

let chat: Jid = "15551234567@s.whatsapp.net".parse()?;

// Enable 7-day disappearing messages
client.set_chat_disappearing_timer(chat.clone(), 604_800).await?;

// Turn it off
client.set_chat_disappearing_timer(chat, 0).await?;
This sets the chat-wide timer. To keep an individual message past the timer, use keep_message.

send_reaction

React to a DM, group, or status@broadcast message with an emoji. The helper builds the ReactionMessage payload (including sender_timestamp_ms) and routes it through the standard send path, so the same retry, fan-out, and phash logic that applies to other messages applies here.
pub async fn send_reaction(
    &self,
    chat: impl Into<Jid>,
    target_key: wa::MessageKey,
    emoji: &str,
) -> Result<SendResult, anyhow::Error>
chat
impl Into<Jid>
required
Chat JID where the reaction is delivered. For status@broadcast, this is the broadcast JID; the reaction fans out to the status author’s devices using target_key.participant.
target_key
wa::MessageKey
required
Identifies the message being reacted to.
  • remote_jid — chat JID of the target message
  • from_metrue if you sent the original message, otherwise false
  • id — message ID of the target message
  • participant — original sender JID. Required for groups and status@broadcast; leave as None for DMs.
emoji
&str
required
Emoji to send (e.g. "👍", "❤️"). Pass an empty string ("") to remove a previous reaction — this matches WhatsApp Web’s empty-text-as-revoke behavior.
SendResult
SendResult
Contains the reaction’s message_id and resolved recipient to JID. See SendResult.

Example: react to a DM

use waproto::whatsapp as wa;

let target_key = wa::MessageKey {
    remote_jid: Some(chat_jid.to_string()),
    from_me: Some(false),
    id: Some(target_message_id.clone()),
    participant: None, // DMs omit participant
};

client.send_reaction(&chat_jid, target_key, "👍").await?;

Example: react to a group message

use waproto::whatsapp as wa;

let target_key = wa::MessageKey {
    remote_jid: Some(group_jid.to_string()),
    from_me: Some(false),
    id: Some(target_message_id.clone()),
    // The original sender — required for groups so the receipt can be attributed.
    participant: Some(sender_jid.to_string()),
};

client.send_reaction(&group_jid, target_key, "🎉").await?;

Example: remove a reaction

client.send_reaction(&chat_jid, target_key, "").await?;
Inside an event handler, prefer MessageContext::react — it derives chat, target_key, and participant from the incoming message for you.
Newsletter (channel) reactions use a different plaintext stanza format and are not handled here. Use client.newsletter().send_reaction() for newsletters.

Phash validation (stale device list detection)

When sending group, status, or DM messages, the library automatically validates the participant hash (phash) from the server’s acknowledgment against the locally computed value. If the hashes differ, the appropriate caches are invalidated so the next send re-fetches current participant devices from the server:
  • Group messages: sender key device cache and group info cache are invalidated
  • Status messages: sender key device cache is invalidated
  • DM messages: the device registry cache is invalidated for both the recipient and your own phone number (PN), matching WA Web’s syncDeviceListJob([recipient, me]) behavior
For DMs, the phash is computed locally from the sent device set but is not sent on the wire (WA Web only sends phash for groups). The phash is returned via the PreparedDmStanza.phash field and compared against the server’s ACK phash. This runs asynchronously in the background and does not block the send path. See Signal Protocol — Phash validation for implementation details.

Automatic stanza metadata

When you call send_message or send_message_with_options, the library automatically infers and injects stanza-level metadata that WhatsApp servers expect for certain message types. This applies to all recipient types — direct messages, groups, and newsletters. You never need to set these manually — the library handles it for you.
Message typeAuto-injected metadata
pin_in_chat_messageSets the edit="2" attribute on the message stanza
poll_creation_message (v1, v2, v3)Adds a <meta polltype="creation"/> child node
poll_update_message (with vote)Adds a <meta polltype="vote"/> child node
event_messageAdds a <meta event_type="creation"/> child node
enc_event_response_messageAdds a <meta event_type="response"/> child node
secret_encrypted_message with SecretEncType::EventEditAdds a <meta event_type="edit"/> child node
view-once media (image/video/voice wrapped as view-once)Adds the <meta view_once="true"/> attribute so recipients render the one-time bubble
group send to tagged members (member labels)Adds the appdata / tag_reason meta attributes
This means you can construct a raw wa::Message with any of these fields set and pass it directly to send_message_with_options — the correct protocol metadata is derived automatically. Any nodes you provide via extra_stanza_nodes in SendOptions are merged with the auto-inferred nodes.
The pin_message() and unpin_message() convenience methods already handle this internally. Auto-detection is most useful when you build a wa::Message manually and send it through send_message or send_message_with_options.

Automatic business node detection

When you send an InteractiveMessage with a NativeFlowMessage (used for business features like payments, CTAs, and catalogs), the library automatically injects a <biz> stanza child node. You don’t need to construct this manually. The detection works by:
  1. Inspecting the outgoing message for an InteractiveMessage with a NativeFlowMessage
  2. Extracting the first button’s name field
  3. Mapping the button name to a WhatsApp flow name
  4. Building the <biz> XML node with the correct structure
The resulting stanza child looks like:
<biz>
  <interactive type="native_flow" v="1">
    <native_flow name="order_details"/>
  </interactive>
</biz>

Supported button-to-flow mappings

Button nameFlow name
review_and_payorder_details
payment_infopayment_info
review_order, order_statusorder_status
payment_statuspayment_status
payment_methodpayment_method
payment_reminderpayment_reminder
open_webviewmessage_with_link
message_with_link_statusmessage_with_link_status
cta_urlcta_url
cta_callcta_call
cta_copycta_copy
cta_catalogcta_catalog
catalog_messagecatalog_message
quick_replyquick_reply
galaxy_messagegalaxy_message
booking_confirmationbooking_confirmation
call_permission_requestcall_permission_request
Unrecognized button names pass through as-is.

Payment vs nested-form vs fallback shapes

The <biz> node is emitted in one of three shapes depending on the button content, matching WA Web’s reproducer for native-flow stanzas:
  1. Payment buttons (review_and_pay, payment_info, payment_status, …) — emitted as a flat <native_flow name="…"> with a privacy_mode_ts attribute. privacy_mode_ts is the current Unix timestamp from the new wacore::time::now_secs_u64() helper, which safely handles clocks set before 1970 by returning 0 instead of panicking.
  2. Nested-form buttons (cta_*, quick_reply, galaxy_message, …) — emitted with the <interactive type="native_flow" v="1"> wrapper shown above.
  3. Mixed / unrecognized — falls back to the wrapper form for forward compatibility.
bot_invoke_message continues to emit a <bot> stanza child instead of <biz> and is unaffected by the above shapes.
The <biz> node is merged with any other auto-inferred metadata (like <meta> nodes for polls or events) and any extra_stanza_nodes you provide in SendOptions. The library also checks inside document_with_caption_message wrappers for interactive messages.

Decrypt-fail suppression

Certain infrastructure messages set decrypt-fail="hide" on their <enc> nodes so recipients don’t see “waiting for this message” placeholders when decryption fails. The library applies this automatically based on message content — you don’t need to handle it manually. The following message types are marked with decrypt-fail="hide":
Message typeCondition
reaction_messageAlways
enc_reaction_messageAlways
pin_in_chat_messageAlways
edited_messageAlways
keep_in_chat_messageAlways
enc_event_response_messageAlways
poll_update_messageOnly when .vote is present
message_history_noticeAlways
conditional_reveal_messageAlways
secret_encrypted_messageOnly when SecretEncType is EventEdit, PollEdit, or PollAddOption
bot_invoke_messageOnly when the inner protocol_message has type RequestWelcomeMessage
protocol_messageWhen type is EphemeralSyncResponse, RequestWelcomeMessage, or GroupMemberLabelChange, or when edited_message is present
Additionally, decrypt-fail="hide" is applied for:
  • Messages with an edit attribute (except Empty, AdminRevoke, and SenderRevoke — WA Web never hides revokes and the server rejects revoke stanzas carrying this attribute)
  • Sender Key Distribution Message (SKDM) stanzas — always hidden since they are infrastructure-only
Wrapper messages (ephemeral_message, view_once_message, etc.) are unwrapped before checking. The decrypt-fail attribute is set on the inner <enc> node, not the outer <message> stanza.

Privacy token attachment

When you send a 1:1 message (not to groups, newsletters, or yourself), the library automatically attaches a privacy token to the outgoing stanza. This follows WhatsApp Web’s MsgCreateFanoutStanza.js fallback chain:
PriorityToken typeConditionStanza node
1tctokenStored TC token exists and hasn’t expired (within 28-day rolling window)<tctoken>
2cstokenNo valid TC token, but NCT salt and recipient LID are available<cstoken>
3NoneNeither token nor salt availableNo token node
After sending, if the TC token bucket boundary has been crossed (7-day buckets), the library automatically issues a new TC token to the recipient in the background.
Privacy token selection is fully automatic. The library resolves the recipient’s LID (using the LID-PN cache), looks up stored TC tokens, and falls back to cstoken computation when needed. See the TC Token API for details on the token lifecycle and NCT salt provisioning.

Stanza types

When you send a message, the library automatically determines two protocol-level type attributes based on the protobuf message content. You don’t need to set these manually, but understanding them can help with debugging.

Message stanza type

The type attribute on the outer <message> XML node is determined by stanza_type_from_message:
Stanza typeMessage types
"text"conversation, protocol_message, keep_in_chat_message, edited_message, pin_in_chat_message, album_message, extended_text_message (without matched_text), secret_encrypted_message with SecretEncType::MessageEdit, poll_result_snapshot_message, poll_result_snapshot_message_v3, request_payment_message, send_payment_message, payment_invite_message, decline_payment_request_message, cancel_payment_request_message
"media"image_message, video_message, audio_message, document_message, sticker_message, sticker_pack_message, location_message, contact_message, extended_text_message (with matched_text), and all other media types
"reaction"reaction_message, enc_reaction_message
"poll"poll_creation_message, poll_creation_message_v2, poll_creation_message_v3, poll_creation_message_v5, poll_update_message, secret_encrypted_message with SecretEncType::PollEdit or SecretEncType::PollAddOption
"event"event_message, enc_event_response_message, secret_encrypted_message with SecretEncType::EventEdit
The payment-family messages (request_payment_message, send_payment_message, payment_invite_message, decline_payment_request_message, cancel_payment_request_message) classify as "text". These message types only exist on the Android client and are silently dropped by the server when sent as "media" (no mediatype) or as "pay"; "text" is the wire type that actually delivers.

Overriding the stanza type

When the classifier can’t recognize a message variant, set SendOptions.stanza_type_override to force the <message type="..."> attribute. Use the StanzaType enum:
use whatsapp_rust::{StanzaType, send::SendOptions};

let options = SendOptions {
    stanza_type_override: Some(StanzaType::Text),
    ..Default::default()
};

let result = client
    .send_message_with_options(to, message, options)
    .await?;
StanzaType variants map to the wire values listed above plus "pay":
VariantWire value
StanzaType::Text"text"
StanzaType::Media"media"
StanzaType::Reaction"reaction"
StanzaType::Poll"poll"
StanzaType::Event"event"
StanzaType::Pay"pay"
Leave stanza_type_override as None for normal sends — the classifier already handles every supported message type.

Encrypted media type

The mediatype attribute on the inner <enc> XML node provides a more specific media classification. This is set by media_type_from_message and is omitted for text-only messages:
Media typeCondition
"image"image_message present
"video"video_message with gif_playback not set or false
"gif"video_message with gif_playback = true
"ptv"ptv_message present
"ptt"audio_message with ptt = true
"audio"audio_message with ptt not set or false
"document"document_message present
"sticker"sticker_message present
"sticker_pack"sticker_pack_message present
"location"location_message with is_live not set or false
"livelocation"location_message with is_live = true, or live_location_message
"vcard"contact_message present
"contact_array"contacts_array_message present
"url"extended_text_message with non-empty matched_text, or group_invite_message
Both stanza type and encrypted media type are resolved automatically by the library before encryption. Wrapper messages are unwrapped first to determine the underlying content type. Since v0.6 unwrap_message peels a much wider set of FutureProofMessage wrappers — group_status_mention_message / groupStatusV2, spoiler, question, newsletter, lottie, and ~15 others — so classification follows the inner content rather than defaulting the wrapper to "text" (aligning with WA Web).

Message types

The wa::Message protobuf supports various message types. Set exactly one of these fields:

Text Messages

conversation
String
Simple text message without formatting
extended_text_message
ExtendedTextMessage
Text with formatting, links, quoted replies, or mentionsKey fields:
  • text - Message text
  • contextInfo - Quoted message, mentions
  • previewType - Link preview behavior

Media Messages

image_message
ImageMessage
Image with optional caption. Upload the image first using client.upload(), then populate:
  • url, direct_path, media_key, file_enc_sha256, file_sha256, file_length, media_key_timestamp
  • caption - Image caption
  • mimetype - e.g., "image/jpeg"
video_message
VideoMessage
Video with optional caption. Same upload pattern as images.
audio_message
AudioMessage
Audio file or voice note:
  • ptt - Set to true for voice notes (Push-To-Talk)
  • mimetype - e.g., "audio/ogg; codecs=opus"
document_message
DocumentMessage
Document/file with metadata:
  • file_name - Original filename
  • mimetype - File MIME type
  • caption - Optional description
sticker_message
StickerMessage
Sticker image (WebP format)
sticker_pack_message
StickerPackMessage
Sticker pack containing multiple stickers as a ZIP. Build using create_sticker_pack_zip and build_sticker_pack_message from wacore::sticker_pack. Requires two uploads: the sticker pack ZIP (MediaType::StickerPack) and a JPEG thumbnail (MediaType::StickerPackThumbnail) sharing the same media_key. See sticker packs.

Other Messages

location_message
LocationMessage
GPS location with latitude, longitude, and optional name/address
contact_message
ContactMessage
Contact card with vCard data
contacts_array_message
ContactsArrayMessage
Multiple contact cards
live_location_message
LiveLocationMessage
Real-time location sharing
reaction_message
ReactionMessage
Emoji reaction to another message
poll_creation_message
PollCreationMessage
Poll with multiple options. Use client.polls().create() for a higher-level API that handles message secret generation automatically. See Polls API.
event_message
EventMessage
Calendar event message. The required <meta event_type="creation"/> stanza node is injected automatically when sent through send_message or send_message_with_options.
album_message
AlbumMessage
Album parent message declaring the expected number of grouped media items. Set expected_image_count and/or expected_video_count. After sending the parent, use wrap_as_album_child to wrap each child media message and link it to the parent via SendResult::message_key(). See album messages.

Example: Extended text with quote

Use build_quote_context_with_info to create a reply with the correct participant and remote_jid fields. It takes both the quoted message’s chat (quoted_chat_jid) and the chat you’re sending into (target_chat_jid):
use waproto::whatsapp as wa;
use wacore::proto_helpers::build_quote_context_with_info;

let context = build_quote_context_with_info(
    quoted_message_id,
    &quoted_sender_jid,
    &chat_jid, // quoted_chat_jid
    &chat_jid, // target_chat_jid (same as quoted for in-place replies)
    &quoted_message,
);

let message = wa::Message {
    extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage {
        text: Some("Reply to your message".to_string()),
        context_info: Some(Box::new(context)),
        ..Default::default()
    })),
    ..Default::default()
};
remote_jid is emitted only when quoted_chat_jid and target_chat_jid refer to different chats (a cross-chat quote, such as quoting a status into a DM). Same-chat replies omit it, matching WhatsApp Web. For newsletter chats, the participant field is automatically set to the newsletter JID instead of the sender.