Skip to main content

Overview

This guide covers sending messages, including text, reactions, quotes, album messages (grouped media), sticker packs, and message editing operations using the whatsapp-rust library.

Sending Text Messages

Simple Text Message

Use the conversation field for plain text messages:
use waproto::whatsapp as wa;
use wacore_binary::jid::Jid;

let to: Jid = "1234567890@s.whatsapp.net".parse()?;

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

let result = client.send_message(to, message).await?;
println!("Message sent with ID: {}", result.message_id);
send_message returns a SendResult containing the message_id and recipient to JID. Use result.message_key() to get a wa::MessageKey for follow-up operations like album child linking.

Extended Text Message

For messages with formatting, links, or context (replies/quotes):
use waproto::whatsapp::message::ExtendedTextMessage;

let message = wa::Message {
    extended_text_message: Some(Box::new(ExtendedTextMessage {
        text: Some("Check out this link: https://example.com".to_string()),
        matched_text: Some("https://example.com".to_string()),
        title: Some("Example Site".to_string()),
        description: Some("A sample website".to_string()),
        ..Default::default()
    })),
    ..Default::default()
};

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

Quoted Replies

Replying to a message

Use build_quote_context to create a basic reply:
use wacore::proto_helpers::{build_quote_context, MessageExt};

// Assume you received the original message
let original_message: wa::Message = /* ... */;
let original_message_id = "3EB0ABC123";
let original_sender = "1234567890@s.whatsapp.net";

// Build quote context
let context = build_quote_context(
    original_message_id,
    original_sender,
    &original_message,
);

// Create reply message
let reply = wa::Message {
    extended_text_message: Some(Box::new(ExtendedTextMessage {
        text: Some("This is my reply".to_string()),
        context_info: Some(Box::new(context)),
        ..Default::default()
    })),
    ..Default::default()
};

let result = client.send_message(to, reply).await?;

Cross-platform quoted replies with remoteJid

For quoted replies that display correctly on all platforms (including iOS), use build_quote_context_with_info. This sets the remote_jid field on the ContextInfo to the chat JID, which iOS requires to scope the quote correctly:
use wacore::proto_helpers::{build_quote_context_with_info, MessageExt};
use wacore_binary::jid::Jid;

let original_message: wa::Message = /* ... */;
let original_message_id = "3EB0ABC123";
let original_sender: Jid = "1234567890@s.whatsapp.net".parse()?;
let chat_jid: Jid = "120363040237990503@g.us".parse()?; // group or DM JID

// Build quote context with remote_jid (recommended for cross-platform compatibility)
let context = build_quote_context_with_info(
    original_message_id,
    &original_sender,
    &chat_jid,
    &original_message,
);

let reply = wa::Message {
    extended_text_message: Some(Box::new(ExtendedTextMessage {
        text: Some("This is my reply".to_string()),
        context_info: Some(Box::new(context)),
        ..Default::default()
    })),
    ..Default::default()
};

let result = client.send_message(chat_jid, reply).await?;
The build_quote_context_with_info function handles two important details:
  • remote_jid is always set to the chat JID (matching WhatsApp Web’s behavior)
  • participant is set to the sender JID for normal chats, or the newsletter JID for newsletter quotes
Use build_quote_context_with_info instead of build_quote_context when sending replies in groups or newsletters. The remote_jid field is required by iOS clients to display the quoted message correctly.

Setting context on media messages

You can add quote context to any message type using set_context_info:
use waproto::whatsapp::message::ImageMessage;

let mut reply = wa::Message {
    image_message: Some(Box::new(ImageMessage {
        url: Some("https://mmg.whatsapp.net/...".to_string()),
        mimetype: Some("image/jpeg".to_string()),
        caption: Some("Here's an image reply".to_string()),
        // ... other image fields
        ..Default::default()
    })),
    ..Default::default()
};

// Build and set context (use build_quote_context_with_info for cross-platform support)
let context = build_quote_context_with_info(
    original_message_id,
    &original_sender,
    &chat_jid,
    &original_message,
);

reply.set_context_info(context);

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

Reactions

Sending a Reaction

Reactions are sent using ReactionMessage:
use waproto::whatsapp::message::ReactionMessage;

let reaction = wa::Message {
    reaction_message: Some(ReactionMessage {
        key: Some(wa::MessageKey {
            remote_jid: Some(chat_jid.to_string()),
            from_me: Some(false),
            id: Some(message_id_to_react_to.to_string()),
            participant: Some(sender_jid.to_string()),
        }),
        text: Some("👍".to_string()),
        sender_timestamp_ms: Some(wacore::time::now_millis()),
        ..Default::default()
    }),
    ..Default::default()
};

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

Removing a Reaction

Send an empty reaction text to remove:
let remove_reaction = wa::Message {
    reaction_message: Some(ReactionMessage {
        key: Some(wa::MessageKey {
            remote_jid: Some(chat_jid.to_string()),
            from_me: Some(false),
            id: Some(message_id_to_react_to.to_string()),
            participant: Some(sender_jid.to_string()),
        }),
        text: Some("".to_string()),  // Empty to remove
        sender_timestamp_ms: Some(wacore::time::now_millis()),
        ..Default::default()
    }),
    ..Default::default()
};

Editing Messages

You can edit text messages using EditedMessage:
use waproto::whatsapp::message::FutureProofMessage;

let edited = wa::Message {
    edited_message: Some(Box::new(FutureProofMessage {
        message: Some(Box::new(wa::Message {
            protocol_message: Some(Box::new(wa::message::ProtocolMessage {
                key: Some(wa::MessageKey {
                    remote_jid: Some(chat_jid.to_string()),
                    from_me: Some(true),
                    id: Some(original_message_id.to_string()),
                    participant: None,
                }),
                r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32),
                edited_message: Some(Box::new(wa::Message {
                    conversation: Some("This is the edited text".to_string()),
                    ..Default::default()
                })),
                timestamp_ms: Some(wacore::time::now_millis()),
                ..Default::default()
            })),
            ..Default::default()
        })),
    })),
    ..Default::default()
};

let result = client.send_message(chat_jid, edited).await?;
Message editing only works for text messages (conversation or extended_text_message) sent by you within the last 15 minutes.

Deleting Messages (Revoke)

Delete Your Own Message

use whatsapp_rust::send::RevokeType;

client.revoke_message(
    chat_jid,
    message_id,
    RevokeType::Sender,
).await?;
See Send API reference for full details.

Admin Delete (Group Only)

Group admins can delete messages from other participants:
let original_sender: Jid = "1234567890@s.whatsapp.net".parse()?;

client.revoke_message(
    group_jid,
    message_id,
    RevokeType::Admin { original_sender },
).await?;
Admin revoke only works in group chats. The original_sender must match the JID format (LID or phone number) of the message being deleted.

Sending to newsletters

Newsletter messages are sent through the same client.send_message() method. The library automatically detects newsletter JIDs and sends messages as plaintext (no Signal encryption):
use waproto::whatsapp as wa;
use wacore_binary::jid::Jid;

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

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

let result = client.send_message(newsletter_jid, message).await?;
The correct stanza type (text, media, reaction, poll), mediatype attributes, and <meta> nodes (for polls, events, etc.) are inferred automatically from the message content. See the Newsletters guide for more details.
Newsletter reactions use a different protocol format. Use client.newsletter().send_reaction() for reactions instead of send_message().

Album messages

Album messages let you send multiple images and/or videos as a grouped media album — the collapsed album bubble that WhatsApp displays when someone sends several photos at once. An album consists of:
  1. A parent AlbumMessage declaring the expected image and video counts
  2. Multiple child messages (individual media messages) wrapped with wrap_as_album_child and linked to the parent

Sending an album

use waproto::whatsapp as wa;
use whatsapp_rust::proto_helpers::wrap_as_album_child;

let chat_jid: Jid = "1234567890@s.whatsapp.net".parse()?;

// Step 1: Send the parent album message
let album_parent = wa::Message {
    album_message: Some(Box::new(wa::message::AlbumMessage {
        expected_image_count: Some(3),
        expected_video_count: Some(0),
        ..Default::default()
    })),
    ..Default::default()
};

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

// Step 2: Upload and send each child image
for image_data in [image1_bytes, image2_bytes, image3_bytes] {
    let upload = client.upload(image_data, MediaType::Image, Default::default()).await?;

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

    // Wrap as album child and send
    let wrapped = wrap_as_album_child(image_msg, parent_key.clone());
    client.send_message(chat_jid.clone(), wrapped).await?;
}

How it works

The wrap_as_album_child function (from whatsapp_rust::proto_helpers) takes a media wa::Message and a parent wa::MessageKey, then:
  1. Wraps the inner message in an associated_child_message (FutureProofMessage envelope)
  2. Attaches a MessageAssociation with type MediaAlbum pointing to the parent
  3. Lifts any existing message_context_info from the inner message to the outer wrapper
The parent AlbumMessage declares the total expected counts so WhatsApp clients know how many media items to group together. Each child is sent as a separate message linked back to the parent via MessageAssociation.

Mixed albums (images and videos)

You can mix images and videos in the same album:
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();

// Send image children
let image_wrapped = wrap_as_album_child(image_msg, parent_key.clone());
client.send_message(chat_jid.clone(), image_wrapped).await?;

// Send video children
let video_wrapped = wrap_as_album_child(video_msg, parent_key.clone());
client.send_message(chat_jid.clone(), video_wrapped).await?;

Sticker packs

Sticker packs let you send a collection of stickers as a single message — the inline sticker pack bubble that WhatsApp displays with a tray icon, pack name, and publisher info. A sticker pack requires:
  1. Sticker images — 512x512 WebP files
  2. Cover image — WebP file used as the tray icon
  3. Thumbnail — JPEG uploaded separately with the same media_key as the ZIP
  4. Metadata — pack ID, name, and publisher

Sending a sticker pack

use wacore::sticker_pack::{
    StickerInput, StickerPackMetadata,
    create_sticker_pack_zip, build_sticker_pack_message,
};
use wacore::download::MediaType;
use whatsapp_rust::upload::UploadOptions;

let chat_jid: Jid = "1234567890@s.whatsapp.net".parse()?;

// Step 1: Create sticker inputs with optional emojis
let stickers = vec![
    StickerInput::new(&webp_bytes_1).with_emojis(vec!["😀".into()]),
    StickerInput::new(&webp_bytes_2).with_emojis(vec!["🎉".into()]),
    StickerInput::new(&webp_bytes_3),
];

// Step 2: Bundle stickers into a ZIP with the cover image
let zip_result = create_sticker_pack_zip("my-pack-id", &stickers, &cover_webp)?;

// Step 3: Upload the ZIP
let zip_upload = client.upload(
    zip_result.zip_bytes.clone(),
    MediaType::StickerPack,
    UploadOptions::default(),
).await?;

// Step 4: Upload the thumbnail JPEG with the same media_key
let thumb_upload = client.upload(
    thumbnail_jpeg,
    MediaType::StickerPackThumbnail,
    UploadOptions::new().with_media_key(zip_upload.media_key),
).await?;

// Step 5: Build and send the message
let metadata = StickerPackMetadata::new(
    "my-pack-id".into(),
    "My Sticker Pack".into(),
    "My Name".into(),
);

let msg = build_sticker_pack_message(
    &zip_result,
    &zip_upload.into(),
    &thumb_upload.into(),
    metadata,
);

client.send_message(chat_jid, msg).await?;

How it works

The sticker pack flow uses two helper functions from wacore::sticker_pack:
  • create_sticker_pack_zip — bundles stickers and a cover image into a ZIP file. Filenames use base64url(sha256).webp, and identical stickers are deduplicated. Returns a StickerPackZipResult containing the ZIP bytes and proto metadata.
  • build_sticker_pack_message — constructs a wa::Message with a StickerPackMessage from the ZIP result and upload responses.
The thumbnail must be uploaded with MediaType::StickerPackThumbnail and the same media_key as the ZIP upload. The UploadResponse implements Into<MediaUploadInfo> for convenience.

Sticker format requirements

FieldRequirement
Sticker images512x512 WebP
Cover imageWebP, stored in ZIP as {pack_id}.webp
ThumbnailJPEG, uploaded separately with same media_key
Pack IDNon-empty, max 128 bytes, no path separators or control chars
Sticker count1 to 60 per pack

Sticker metadata

Each sticker supports optional metadata:
StickerInput::new(&webp_bytes)
    .with_emojis(vec!["😀".into(), "🎉".into()])
    .with_accessibility_label("happy face".into())
Pack-level metadata supports optional description and caption:
let metadata = StickerPackMetadata::new(
    "pack-id".into(),
    "Pack Name".into(),
    "Publisher".into(),
)
.with_description("A fun sticker pack".into())
.with_caption("Check out my stickers!".into());
Animated stickers are automatically detected from the WebP data. The is_animated field on each sticker proto entry is set based on whether the WebP file contains animation frames. You can also use whatsapp_rust::webp::is_animated() directly to check WebP files before processing.

Forwarding media messages

You can forward media by reusing the original CDN fields from a received message. This avoids downloading and re-uploading the media entirely — it’s instant regardless of file size:
use waproto::whatsapp as wa;

// Clone the image message, only change the caption
if let Some(img) = &received_message.image_message {
    let forwarded = wa::Message {
        image_message: Some(Box::new(wa::message::ImageMessage {
            caption: Some("Forwarded!".to_string()),
            ..*img.clone()
        })),
        ..Default::default()
    };

    client.send_message(destination_jid, forwarded).await?;
}
This works because send_message accepts any wa::Message. If the message already contains CDN fields (url, direct_path, media_key, etc.) from a received message, the server accepts it as-is. See Media handling - CDN reuse for details on all supported media types and when to fall back to download + re-upload.

Ephemeral (disappearing) messages

WhatsApp supports disappearing messages that automatically delete after a set duration. When a chat has disappearing messages enabled, you should set the ephemeral expiration on outgoing messages so recipients see the correct countdown timer.

Sending a disappearing message

Use send_message_with_options with ephemeral_expiration set to the chat’s timer value:
use whatsapp_rust::send::SendOptions;
use waproto::whatsapp as wa;

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

let options = SendOptions {
    ephemeral_expiration: Some(604800), // 7 days, matching the chat's timer
    ..Default::default()
};

let result = client.send_message_with_options(chat_jid, message, options).await?;
This sets contextInfo.expiration on the protobuf message, which tells WhatsApp clients to display the disappearing countdown.

Common timer values

DurationValue (seconds)
24 hours86400
7 days604800
90 days7776000
Disabled0

Getting the chat’s ephemeral timer

For groups, read the timer from group metadata:
let metadata = client.groups().get_metadata(&group_jid).await?;
let expiration = metadata.ephemeral_expiration; // 0 if disabled
For incoming messages, read it from MessageInfo:
Event::Message(message, info) => {
    if let Some(expiration) = info.ephemeral_expiration {
        println!("Chat has {}s disappearing timer", expiration);
    }
}

Configuring disappearing messages

Per-group: Use set_ephemeral to enable or disable disappearing messages on a group:
// Enable 7-day disappearing messages
client.groups().set_ephemeral(&group_jid, 604800).await?;

// Disable disappearing messages
client.groups().set_ephemeral(&group_jid, 0).await?;
Account-level default: Use set_default_disappearing_mode to set the default for all new 1-on-1 chats:
// Enable 24-hour default for new chats
client.set_default_disappearing_mode(86400).await?;

// Disable default disappearing messages
client.set_default_disappearing_mode(0).await?;
The account-level default only applies to new chats. Existing chats keep their current setting. To change a specific group’s timer, use set_ephemeral.

Listening for timer changes

When a contact changes their default disappearing messages setting, you receive a DisappearingModeChanged event:
Event::DisappearingModeChanged(change) => {
    println!("Contact {} set disappearing to {}s", change.from, change.duration);
}
When a group’s ephemeral setting changes, you receive a GroupUpdate event with a GroupNotificationAction::Ephemeral action containing the new expiration value. See Events reference for details.

Creating groups with disappearing messages

You can enable disappearing messages at group creation time:
use whatsapp_rust::features::groups::{GroupCreateOptions, GroupParticipantOptions};

let options = GroupCreateOptions::builder()
    .subject("Ephemeral Group")
    .participants(vec![
        GroupParticipantOptions::new(participant_jid),
    ])
    .ephemeral_expiration(604800) // 7-day timer
    .build();

let result = client.groups().create_group(options).await?;
See Send API reference for the full SendOptions type.

Send Options

Specifying a custom message ID

You can override the auto-generated message ID by setting message_id on SendOptions. This is useful for resending a failed message with the same ID or ensuring idempotency:
use whatsapp_rust::send::SendOptions;

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

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

// result.message_id will be "3EB0ABC123"

Adding extra stanza nodes

For advanced use cases, you can include custom XML nodes:
use whatsapp_rust::send::SendOptions;
use wacore_binary::builder::NodeBuilder;

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

let result = client.send_message_with_options(
    to,
    message,
    options,
).await?;
See Send API reference for full details.

Message Preparation Helpers

Preparing Messages for Quoting

The prepare_for_quote method strips nested context info:
use wacore::proto_helpers::MessageExt;

let quoted_message = original_message.prepare_for_quote();

let context = wa::ContextInfo {
    stanza_id: Some(message_id.clone()),
    participant: Some(sender_jid.to_string()),
    quoted_message: Some(quoted_message),
    ..Default::default()
};
This ensures:
  • Nested mentions are stripped
  • Quote chains are broken (except for bot messages)
  • Content fields (text, caption, media) are preserved
See WAProto API reference for message type details.

Error Handling

use anyhow::Result;

async fn send_safe_message(
    client: &Client,
    to: Jid,
    text: &str,
) -> Result<String> {
    let message = wa::Message {
        conversation: Some(text.to_string()),
        ..Default::default()
    };

    match client.send_message(to.clone(), message).await {
        Ok(result) => {
            println!("✅ Message sent: {}", result.message_id);
            Ok(result.message_id)
        }
        Err(e) => {
            eprintln!("❌ Failed to send: {:?}", e);
            Err(e)
        }
    }
}

Best Practices

1
Use the Right Message Type
2
  • Simple text: Use conversation
  • Links/formatting: Use extended_text_message
  • Replies: Use extended_text_message with context_info
  • Media: Use specific media message types with optional captions
  • Albums: Use album_message parent + wrap_as_album_child for grouped media
  • Sticker packs: Use create_sticker_pack_zip + build_sticker_pack_message with two uploads (ZIP + thumbnail)
  • Disappearing chats: Use send_message_with_options with ephemeral_expiration matching the chat’s timer
  • 3
    Handle Message IDs
    4
    // Store the result for later operations
    let result = client.send_message(to, message).await?;
    
    // Use the message ID for reactions, edits, or deletes
    client.revoke_message(to, &result.message_id, RevokeType::Sender).await?;
    
    // Use message_key() for album child linking or pinning
    let key = result.message_key();
    
    5
    Quote context best practices
    6
  • Always use prepare_for_quote() to avoid nested quote chains
  • Use build_quote_context_with_info instead of build_quote_context for cross-platform compatibility — it sets remote_jid (required by iOS) and correctly resolves the participant field for newsletters
  • Preserve original media fields when quoting media messages
  • Next Steps