> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# send

> Send, forward, edit, revoke, pin, and unpin messages with advanced options, including album support

## 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.

```rust theme={null}
pub async fn send_message(
    &self,
    to: impl Into<Jid>,
    message: wa::Message,
) -> Result<SendResult, SendError>
```

<ParamField path="to" type="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.
</ParamField>

<ParamField path="message" type="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
</ParamField>

<ResponseField name="SendResult" type="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.
</ResponseField>

<Note>
  The outbound Signal ratchet advance is persisted through a batched counter lease, for DMs and for group/status sends alike. For DMs, `SessionRecord` reserves its sender-chain counter 64 at a time; for group sends and status posts sent via `client.status()`, `SenderKeyRecord` reserves its chain iteration 64 at a time the same way. Most sends are already covered by a durable lease and only schedule a coalesced write-behind — though the pre-wire flush check is global, so a pending flush on an unrelated session or sender key can still force this send to flush synchronously. The send that exhausts the current lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Status *reactions* (`send_reaction` targeting `status@broadcast`) are the exception: they route through the same DM branch as an ordinary 1:1 message, addressed to the status author's device, so they follow the DM counter-lease behavior instead of the group/status one. Reusing an outbound counter or iteration would reuse its message key and IV, so the advance is always durable before it can be reused. If a required persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model.
</Note>

### 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.

```rust theme={null}
#[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.

```rust theme={null}
pub struct ChatMessageId {
    pub chat: Jid,
    pub id: MessageId,
}
```

### Example: text message

```rust theme={null}
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

```rust theme={null}
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?;
```

<Note>
  Newsletter reactions use a different stanza format and are still sent through `client.newsletter().send_reaction()`. See the [Newsletter API](/api/newsletter#send_reaction).
</Note>

### Example: image with caption

```rust theme={null}
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: buffa::MessageField::some(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`:

```rust theme={null}
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: buffa::MessageField::some(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: buffa::MessageField::some(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](/guides/sending-messages#album-messages) 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`.

```rust theme={null}
pub async fn forward_message(
    &self,
    to: impl Into<Jid>,
    message: &wa::Message,
) -> Result<SendResult, SendError>
```

<ParamField path="to" type="impl Into<Jid>" required>
  Recipient JID (DM, group, or newsletter).
</ParamField>

<ParamField path="message" type="&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.
</ParamField>

<ResponseField name="SendResult" type="SendResult">
  Same shape as [`send_message`](#send_message): contains the new `message_id` and the resolved recipient JID.
</ResponseField>

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

```rust theme={null}
// `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`](/guides/sending-messages#preparing-messages-for-forwarding), which returns the prepared `wa::Message` without sending it.

***

## send\_message\_with\_options

Send a message with additional customization options.

```rust theme={null}
pub async fn send_message_with_options(
    &self,
    to: impl Into<Jid>,
    message: wa::Message,
    options: SendOptions,
) -> Result<SendResult, SendError>
```

<ParamField path="to" type="impl Into<Jid>" required>
  Recipient JID
</ParamField>

<ParamField path="message" type="wa::Message" required>
  Protobuf message to send
</ParamField>

<ParamField path="options" type="SendOptions" required>
  Additional send options (see below)
</ParamField>

<ResponseField name="SendResult" type="SendResult">
  Contains the message ID and recipient JID
</ResponseField>

### SendOptions

Options for customizing message sending behavior.

```rust theme={null}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
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>,
}
```

<Note>
  `SendOptions` is `#[non_exhaustive]`, so it can no longer be constructed as a struct literal (even with `..Default::default()`) from outside the crate. Build it by chaining the `with_*` setters off `SendOptions::default()` instead: `SendOptions::default().with_message_id(id)`. Each field above has a matching `with_*` method (`with_message_id`, `with_extra_stanza_nodes`, `with_ephemeral_expiration`, `with_stanza_type_override`).
</Note>

<ParamField path="message_id" type="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.
</ParamField>

<ParamField path="extra_stanza_nodes" type="Vec<Node>" default="[]">
  Additional XML nodes to include in the message stanza. Used for advanced protocol features like quoted replies, mentions, or custom metadata.
</ParamField>

<ParamField path="ephemeral_expiration" type="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.
</ParamField>

<ParamField path="stanza_type_override" type="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](#stanza-types) for the available values.

  <Note>
    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.
  </Note>
</ParamField>

### Example: Send with a custom message ID

```rust theme={null}
use whatsapp_rust::send::SendOptions;

let options = SendOptions::default().with_message_id("3EB0ABC123");

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

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

### Example: Send an ephemeral (disappearing) message

```rust theme={null}
use whatsapp_rust::send::SendOptions;

let options = SendOptions::default().with_ephemeral_expiration(604800); // 7 days

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

<Note>
  The `ephemeral_expiration` value should match the chat's disappearing messages timer. You can get this from `GroupMetadata.ephemeral` (via `metadata.ephemeral.as_ref().and_then(|e| e.expiration)`) for groups, or from `MessageInfo.ephemeral_expiration` on received messages. See the [sending messages guide](/guides/sending-messages#ephemeral-disappearing-messages) for a complete walkthrough.
</Note>

### Example: Send with extra stanza nodes

```rust theme={null}
use whatsapp_rust::send::SendOptions;
use wacore_binary::builder::NodeBuilder;

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

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

***

## edit\_message

Edit a previously sent message.

```rust theme={null}
pub async fn edit_message(
    &self,
    to: impl Into<Jid>,
    original_id: impl Into<String>,
    new_content: wa::Message,
) -> Result<String, SendError>
```

<ParamField path="to" type="impl Into<Jid>" required>
  Chat JID where the original message was sent
</ParamField>

<ParamField path="original_id" type="String" required>
  ID of the message to edit (from `send_message` return value)
</ParamField>

<ParamField path="new_content" type="wa::Message" required>
  New message content to replace the original
</ParamField>

<ResponseField name="message_id" type="String">
  Message ID of the edit message
</ResponseField>

### Example: Edit a message

```rust theme={null}
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?;
```

<Note>
  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.
</Note>

***

## edit\_message\_with\_options

Edit-path counterpart to [`send_message_with_options`](#send_message_with_options). Builds the same `protocolMessage` edit as [`edit_message`](#edit_message), but accepts an `EditOptions` struct for cases where the default fresh-stanza-id behavior isn't what you want.

```rust theme={null}
pub async fn edit_message_with_options(
    &self,
    to: impl Into<Jid>,
    original_id: impl Into<String>,
    new_content: wa::Message,
    options: EditOptions,
) -> Result<String, SendError>
```

<ParamField path="to" type="impl Into<Jid>" required>
  Chat JID where the original message was sent
</ParamField>

<ParamField path="original_id" type="String" required>
  ID of the message to edit (from `send_message` return value)
</ParamField>

<ParamField path="new_content" type="wa::Message" required>
  New message content to replace the original
</ParamField>

<ParamField path="options" type="EditOptions" required>
  Edit options (see below)
</ParamField>

<ResponseField name="message_id" type="String">
  Message ID of the edit message
</ResponseField>

### EditOptions

```rust theme={null}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct EditOptions {
    /// Override the outer stanza id (default: a fresh id, like `edit_message`).
    pub stanza_id: Option<String>,
}
```

<Note>
  `EditOptions` is `#[non_exhaustive]`, so it can no longer be constructed as a struct literal from outside the crate. Build it with `EditOptions::default().with_stanza_id(id)` instead.
</Note>

<ParamField path="stanza_id" type="Option<String>" default="None">
  Overrides the outer stanza id normally auto-generated by `edit_message`. The edit-path counterpart of [`SendOptions::message_id`](#sendoptions) — pin the outer id to an existing message's id so clients re-render that slot instead of appending a new one.

  Pinning `stanza_id` to a **borrowed** id (one that already belongs to another message) is a best-effort, side-effect-aware operation:

  * The edit does **not** persist a retry-cache entry or an outbound message secret under the borrowed id, so the original message's retry content and secret are left intact.
  * The edit does not register a phash ack-waiter under the borrowed id either, since the waiter map is keyed by outer stanza id and registering one would risk resolving the wrong send.
  * Whether the server and recipient clients actually honor the collision (dedupe/re-render onto the borrowed id) is server- and client-dependent — treat the visible outcome as non-guaranteed, not a reliable primitive.

  An empty string is rejected with `SendError::InvalidRequest`, same as an empty `SendOptions::message_id`.
</ParamField>

### Example: edit with a pinned stanza id

```rust theme={null}
use waproto::whatsapp as wa;
use whatsapp_rust::send::EditOptions;

let edit_id = client.edit_message_with_options(
    &chat_jid,
    &original_id,
    wa::Message {
        conversation: Some("Hello, edited!".to_string()),
        ..Default::default()
    },
    EditOptions::default().with_stanza_id(existing_message_id.clone()),
).await?;

assert_eq!(edit_id, existing_message_id);
```

<Note>
  Leave `stanza_id` as `None` (or use plain [`edit_message`](#edit_message)) for ordinary edits — a fresh id is what prevents the server from deduplicating the edit against the original message. Only set `stanza_id` when you specifically need to collide the outer stanza id with an existing message.
</Note>

***

## 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`](#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.

```rust theme={null}
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, SendError>
```

<ParamField path="to" type="impl Into<Jid>" required>
  Chat JID where the original message was sent. Newsletter/channel JIDs are rejected — use [`Newsletter::edit_message`](/api/newsletter#edit_message) for channels.
</ParamField>

<ParamField path="original_id" type="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.
</ParamField>

<ParamField path="message_secret" type="&[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.
</ParamField>

<ParamField path="new_content" type="wa::Message" required>
  Replacement message content.
</ParamField>

<ResponseField name="message_id" type="String">
  Message ID of the edit message.
</ResponseField>

### Example: encrypted edit

```rust theme={null}
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?;
```

<Note>
  Use [`edit_message`](#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](/api/polls#decrypting-secret-encrypted-envelopes).
</Note>

***

## 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.

```rust theme={null}
pub async fn revoke_message(
    &self,
    to: impl Into<Jid>,
    message_id: impl Into<String>,
    revoke_type: RevokeType,
) -> Result<(), SendError>
```

<ParamField path="to" type="impl Into<Jid>" required>
  Chat JID (direct message or group)
</ParamField>

<ParamField path="message_id" type="String" required>
  ID of the message to delete (from `send_message` return value)
</ParamField>

<ParamField path="revoke_type" 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
</ParamField>

### RevokeType

Specifies who is revoking (deleting) the message.

```rust theme={null}
#[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.

<ParamField path="Sender" type="variant">
  Default variant. Use when deleting your own message. Works in both DMs and groups.
</ParamField>

<ParamField path="Admin" type="variant">
  Use when a group admin is deleting another user's message. Only valid in groups. Requires `original_sender` JID.
</ParamField>

### Example: revoke own message

```rust theme={null}
// 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

```rust theme={null}
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?;
```

<Note>
  Admin revoke is only valid for group chats. Attempting to use it in a direct message will return an error.
</Note>

<Note>
  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.
</Note>

***

## pin\_message

Pin a message in a chat for all participants.

```rust theme={null}
pub async fn pin_message(
    &self,
    chat: impl Into<Jid>,
    key: wa::MessageKey,
    duration: PinDuration,
) -> Result<(), SendError>
```

<ParamField path="chat" type="impl Into<Jid>" required>
  Chat JID where the message to pin is located
</ParamField>

<ParamField path="key" type="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).
</ParamField>

<ParamField path="duration" type="PinDuration" required>
  How long the message should remain pinned (see below)
</ParamField>

### PinDuration

Specifies how long a message stays pinned. Defaults to 7 days (matches WhatsApp Web behavior).

```rust theme={null}
#[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.

<ParamField path="Hours24" type="variant">
  Pin for 24 hours
</ParamField>

<ParamField path="Days7" type="variant">
  Pin for 7 days (default)
</ParamField>

<ParamField path="Days30" type="variant">
  Pin for 30 days
</ParamField>

### Example: Pin a message for 7 days

```rust theme={null}
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

```rust theme={null}
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.

```rust theme={null}
pub async fn unpin_message(
    &self,
    chat: impl Into<Jid>,
    key: wa::MessageKey,
) -> Result<(), SendError>
```

<ParamField path="chat" type="impl Into<Jid>" required>
  Chat JID where the pinned message is located
</ParamField>

<ParamField path="key" type="wa::MessageKey" required>
  The message key identifying which message to unpin
</ParamField>

### Example: Unpin a message

```rust theme={null}
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.

```rust theme={null}
pub async fn keep_message(
    &self,
    chat: impl Into<Jid>,
    key: wa::MessageKey,
    keep: bool,
) -> Result<SendResult, SendError>
```

<ParamField path="chat" type="impl Into<Jid>" required>
  Chat JID where the target message lives.
</ParamField>

<ParamField path="key" type="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).
</ParamField>

<ParamField path="keep" type="bool" required>
  `true` requests `KEEP_FOR_ALL` (keep the message past the disappearing timer). `false` requests `UNDO_KEEP_FOR_ALL` (reverse a previous keep).
</ParamField>

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

```rust theme={null}
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

```rust theme={null}
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.

```rust theme={null}
pub async fn set_chat_disappearing_timer(
    &self,
    chat: Jid,
    duration: u32,
) -> Result<SendResult, SendError>
```

<ParamField path="chat" type="Jid" required>
  The 1:1 chat (PN or LID). Group, status, and newsletter JIDs are rejected — for groups use [`Groups::set_ephemeral`](/api/groups#set_ephemeral); for the account default use `Client::set_default_disappearing_mode`.
</ParamField>

<ParamField path="duration" type="u32" required>
  Timer in **seconds**. Common values: `86400` (24h), `604800` (7 days), `7776000` (90 days). Pass `0` to turn disappearing messages off.
</ParamField>

<ResponseField name="SendResult" type="SendResult">
  Result of the setting message send. See [SendResult](#sendresult).
</ResponseField>

### Example: enable and disable

```rust theme={null}
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?;
```

<Note>
  This sets the chat-wide timer. To keep an individual message past the timer, use [`keep_message`](#keep_message).
</Note>

***

## 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.

```rust theme={null}
pub async fn send_reaction(
    &self,
    chat: impl Into<Jid>,
    target_key: wa::MessageKey,
    emoji: &str,
) -> Result<SendResult, SendError>
```

<ParamField path="chat" type="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`.
</ParamField>

<ParamField path="target_key" type="wa::MessageKey" required>
  Identifies the message being reacted to.

  * `remote_jid` — chat JID of the target message
  * `from_me` — `true` 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.
</ParamField>

<ParamField path="emoji" type="&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.
</ParamField>

<ResponseField name="SendResult" type="SendResult">
  Contains the reaction's `message_id` and resolved recipient `to` JID. See [`SendResult`](#sendresult).
</ResponseField>

### Example: react to a DM

```rust theme={null}
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

```rust theme={null}
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

```rust theme={null}
client.send_reaction(&chat_jid, target_key, "").await?;
```

<Note>
  Inside an event handler, prefer [`MessageContext::react`](/api/bot#react) — it derives `chat`, `target_key`, and `participant` from the incoming message for you.
</Note>

<Note>
  Newsletter (channel) reactions use a different plaintext stanza format and are not handled here. Use [`client.newsletter().send_reaction()`](/api/newsletter#send_reaction) for newsletters.
</Note>

***

## 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](/advanced/signal-protocol#phash-validation-for-stale-device-list-detection) 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 type                                               | Auto-injected metadata                                                                 |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `pin_in_chat_message`                                      | Sets 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_message`                                            | Adds a `<meta event_type="creation"/>` child node                                      |
| `enc_event_response_message`                               | Adds a `<meta event_type="response"/>` child node                                      |
| `secret_encrypted_message` with `SecretEncType::EventEdit` | Adds 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.

<Note>
  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`.
</Note>

***

## 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:

```xml theme={null}
<biz>
  <interactive type="native_flow" v="1">
    <native_flow name="order_details"/>
  </interactive>
</biz>
```

### Supported button-to-flow mappings

| Button name                    | Flow name                  |
| ------------------------------ | -------------------------- |
| `review_and_pay`               | `order_details`            |
| `payment_info`                 | `payment_info`             |
| `review_order`, `order_status` | `order_status`             |
| `payment_status`               | `payment_status`           |
| `payment_method`               | `payment_method`           |
| `payment_reminder`             | `payment_reminder`         |
| `open_webview`                 | `message_with_link`        |
| `message_with_link_status`     | `message_with_link_status` |
| `cta_url`                      | `cta_url`                  |
| `cta_call`                     | `cta_call`                 |
| `cta_copy`                     | `cta_copy`                 |
| `cta_catalog`                  | `cta_catalog`              |
| `catalog_message`              | `catalog_message`          |
| `quick_reply`                  | `quick_reply`              |
| `galaxy_message`               | `galaxy_message`           |
| `booking_confirmation`         | `booking_confirmation`     |
| `call_permission_request`      | `call_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.

<Note>
  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.
</Note>

***

## 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 type                 | Condition                                                                                                                              |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `reaction_message`           | Always                                                                                                                                 |
| `enc_reaction_message`       | Always                                                                                                                                 |
| `pin_in_chat_message`        | Always                                                                                                                                 |
| `edited_message`             | Always                                                                                                                                 |
| `keep_in_chat_message`       | Always                                                                                                                                 |
| `enc_event_response_message` | Always                                                                                                                                 |
| `poll_update_message`        | Only when `.vote` is present                                                                                                           |
| `message_history_notice`     | Always                                                                                                                                 |
| `conditional_reveal_message` | Always                                                                                                                                 |
| `secret_encrypted_message`   | Only when `SecretEncType` is `EVENT_EDIT`, `POLL_EDIT`, or `POLL_ADD_OPTION`                                                           |
| `bot_invoke_message`         | Only when the inner `protocol_message` has type `REQUEST_WELCOME_MESSAGE`                                                              |
| `protocol_message`           | When type is `EPHEMERAL_SYNC_RESPONSE`, `REQUEST_WELCOME_MESSAGE`, or `GROUP_MEMBER_LABEL_CHANGE`, 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

<Note>
  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.
</Note>

***

## 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:

| Priority | Token type  | Condition                                                                | Stanza node   |
| -------- | ----------- | ------------------------------------------------------------------------ | ------------- |
| 1        | **tctoken** | Stored TC token exists and hasn't expired (within 28-day rolling window) | `<tctoken>`   |
| 2        | **cstoken** | No valid TC token, but NCT salt and recipient LID are available          | `<cstoken>`   |
| 3        | None        | Neither token nor salt available                                         | No 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.

<Note>
  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](/api/tctoken) for details on the token lifecycle and NCT salt provisioning.
</Note>

***

## 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 type  | Message 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::MESSAGE_EDIT`, `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::POLL_EDIT` or `SecretEncType::POLL_ADD_OPTION`                                                                                                                                                                                                                         |
| `"event"`    | `event_message`, `enc_event_response_message`, `secret_encrypted_message` with `SecretEncType::EVENT_EDIT`                                                                                                                                                                                                                                                                                                                                                 |

<Note>
  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.
</Note>

#### 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:

```rust theme={null}
use whatsapp_rust::{StanzaType, send::SendOptions};

let options = SendOptions::default().with_stanza_type_override(StanzaType::Text);

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

`StanzaType` variants map to the wire values listed above plus `"pay"`:

| Variant                | Wire 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 type        | Condition                                                                        |
| ----------------- | -------------------------------------------------------------------------------- |
| `"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` |

<Note>
  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).
</Note>

***

## Message types

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

### Text Messages

<ParamField path="conversation" type="String">
  Simple text message without formatting
</ParamField>

<ParamField path="extended_text_message" type="ExtendedTextMessage">
  Text with formatting, links, quoted replies, or mentions

  Key fields:

  * `text` - Message text
  * `contextInfo` - Quoted message, mentions
  * `previewType` - Link preview behavior
</ParamField>

### Media Messages

<ParamField path="image_message" type="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"`
</ParamField>

<ParamField path="video_message" type="VideoMessage">
  Video with optional caption. Same upload pattern as images.
</ParamField>

<ParamField path="audio_message" type="AudioMessage">
  Audio file or voice note:

  * `ptt` - Set to `true` for voice notes (Push-To-Talk)
  * `mimetype` - e.g., `"audio/ogg; codecs=opus"`
</ParamField>

<ParamField path="document_message" type="DocumentMessage">
  Document/file with metadata:

  * `file_name` - Original filename
  * `mimetype` - File MIME type
  * `caption` - Optional description
</ParamField>

<ParamField path="sticker_message" type="StickerMessage">
  Sticker image (WebP format)
</ParamField>

<ParamField path="sticker_pack_message" type="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](/guides/sending-messages#sticker-packs).
</ParamField>

### Other Messages

<ParamField path="location_message" type="LocationMessage">
  GPS location with latitude, longitude, and optional name/address
</ParamField>

<ParamField path="contact_message" type="ContactMessage">
  Contact card with vCard data
</ParamField>

<ParamField path="contacts_array_message" type="ContactsArrayMessage">
  Multiple contact cards
</ParamField>

<ParamField path="live_location_message" type="LiveLocationMessage">
  Real-time location sharing
</ParamField>

<ParamField path="reaction_message" type="ReactionMessage">
  Emoji reaction to another message
</ParamField>

<ParamField path="poll_creation_message" type="PollCreationMessage">
  Poll with multiple options. Use `client.polls().create()` for a higher-level API that handles message secret generation automatically. See [Polls API](/api/polls).
</ParamField>

<ParamField path="event_message" type="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`.
</ParamField>

<ParamField path="album_message" type="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](/guides/sending-messages#album-messages).
</ParamField>

### 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`):

```rust theme={null}
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: buffa::MessageField::some(wa::message::ExtendedTextMessage {
        text: Some("Reply to your message".to_string()),
        context_info: buffa::MessageField::some(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.

***

## Error types

### `SendError`

All send-path methods return `Result<T, SendError>`:

```rust theme={null}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SendError {
    #[error("{0}")]
    Client(ClientError),
    #[error("client is not logged in")]
    NotLoggedIn,
    #[error("IQ request failed: {0}")]
    Iq(#[from] IqError),
    #[error("invalid send request: {0}")]
    InvalidRequest(String),
    #[error("{0}")]
    Internal(#[from] anyhow::Error),
}
```

**Variants:**

* `NotLoggedIn` — client is not authenticated; check connection state before sending
* `Iq` — IQ request required by the send path failed
* `InvalidRequest` — the send request was malformed (e.g., invalid JID, bad message shape)
* `Client` — underlying transport/connection error
* `Internal` — catch-all for errors not yet assigned a typed variant. Includes a failure to durably persist the outbound Signal ratchet advance before the stanza was sent — see the durability note under [`send_message`](#send_message).

**Example:**

```rust theme={null}
use whatsapp_rust::SendError;

match client.send_message(jid.clone(), message).await {
    Ok(result) => println!("Sent: {}", result.message_id),
    Err(SendError::NotLoggedIn) => eprintln!("Not authenticated"),
    Err(SendError::Iq(e)) => eprintln!("IQ failed: {}", e),
    Err(SendError::InvalidRequest(msg)) => eprintln!("Bad request: {}", msg),
    Err(e) => eprintln!("Send failed: {}", e),
}
```
