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: Jid,
    message: wa::Message,
) -> Result<SendResult, anyhow::Error>
to
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)]
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;
}

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.clone(), 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.clone(), wrapped).await?;

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

send_message_with_options

Send a message with additional customization options.
pub async fn send_message_with_options(
    &self,
    to: Jid,
    message: wa::Message,
    options: SendOptions,
) -> Result<SendResult, anyhow::Error>
to
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>,
}
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.

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: Jid,
    original_id: impl Into<String>,
    new_content: wa::Message,
) -> Result<String, anyhow::Error>
to
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.clone(),
    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 wraps your new content in an edited_message (FutureProofMessage) envelope automatically. In group chats, the correct participant JID (LID or PN) is resolved for you.

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: Jid,
    message_id: impl Into<String>,
    revoke_type: RevokeType,
) -> Result<(), anyhow::Error>
to
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.

pin_message

Pin a message in a chat for all participants.
pub async fn pin_message(
    &self,
    chat: Jid,
    key: wa::MessageKey,
    duration: PinDuration,
) -> Result<(), anyhow::Error>
chat
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: Jid,
    key: wa::MessageKey,
) -> Result<(), anyhow::Error>
chat
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?;

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
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.
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
secret_encrypted_messageOnly when SecretEncType is EventEdit or PollEdit
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 and AdminRevoke — the server rejects admin revokes with 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, extended_text_message (without matched_text), secret_encrypted_message with SecretEncType::MessageEdit, poll_result_snapshot_message, poll_result_snapshot_message_v3
"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
"event"event_message, enc_event_response_message, secret_encrypted_message with SecretEncType::EventEdit

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 (ephemeral_message, view_once_message, etc.) are unwrapped first to determine the underlying content type.

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 remote_jid field set for cross-platform compatibility:
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_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()
};
The remote_jid is set to the chat JID (group, DM, or newsletter) and is required by iOS clients to display quoted messages correctly. For newsletter chats, the participant field is automatically set to the newsletter JID instead of the sender.