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

# receipt

> Send read receipts, delivery receipts, and played receipts

## mark\_as\_read

Send read receipts for one or more messages.

Read receipts inform the sender that you've read their message(s). For group messages, you must pass the original sender's JID as the `sender` parameter.

```rust theme={null}
pub async fn mark_as_read(
    &self,
    chat: &Jid,
    sender: Option<&Jid>,
    message_ids: &[&str],
) -> Result<(), anyhow::Error>
```

<Note>
  `message_ids` is a borrowed slice `&[&str]` to avoid per-call allocations — pass `&["ID"]` or `&["ID_1", "ID_2"]`.
</Note>

<ParamField path="chat" type="&Jid" required>
  Chat JID where the messages were received. Can be:

  * Direct message: `15551234567@s.whatsapp.net`
  * Group: `120363040237990503@g.us`
</ParamField>

<ParamField path="sender" type="Option<&Jid>">
  Message sender JID. Required for group messages, `None` for direct messages.

  * For DMs: Pass `None`
  * For groups: Pass the JID of the user who sent the message(s)
</ParamField>

<ParamField path="message_ids" type="&[&str]" required>
  List of message IDs to mark as read. Can be a single ID or multiple IDs.

  If empty, this function returns immediately without sending anything.
</ParamField>

### Example: mark DM as read

```rust theme={null}
use wacore_binary::jid::Jid;

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

client.mark_as_read(
    &chat_jid,
    None, // No sender for DMs
    &["MESSAGE_ID_123"],
).await?;
```

### Example: mark group message as read

```rust theme={null}
let group_jid: Jid = "120363040237990503@g.us".parse()?;
let sender_jid: Jid = "15551234567@s.whatsapp.net".parse()?;

client.mark_as_read(
    &group_jid,
    Some(&sender_jid), // Must specify sender in groups
    &["MESSAGE_ID_456"],
).await?;
```

### Example: mark multiple messages as read

```rust theme={null}
let message_ids = ["MSG_1", "MSG_2", "MSG_3"];

client.mark_as_read(&chat_jid, None, &message_ids).await?;
```

<Note>
  Read receipts are **not sent automatically** by the library. You must explicitly call `mark_as_read()` when you want to notify the sender that messages have been read.
</Note>

<Note>
  `mark_as_read` adapts the wire shape to the chat, matching WhatsApp Web. A newsletter read is sent as `read-self`. A `status@broadcast` read carries `context="status"`. When the status author is a LID, it also adds `peer_participant_pn` (the resolved LID→PN). For a direct message, the receipt also downgrades to `read-self` when the account's `readreceipts` privacy setting is `none` — the sender is not notified, matching WhatsApp Web. Groups and broadcast lists always send the notifying `read` type regardless of that setting. The same call handles all these cases — you don't pass anything extra. See [Read receipt privacy gating](#read-receipt-privacy-gating) below.
</Note>

***

## mark\_as\_played

Send played receipts for one or more voice notes or video notes.

Played receipts tell the sender that you've listened to their voice note or watched their video note — they're the equivalent of "read" for playable media. Call this only after the user has actually played the media; if you just want to acknowledge that the message was opened, use [`mark_as_read`](#mark_as_read) instead.

```rust theme={null}
pub async fn mark_as_played(
    &self,
    chat: &Jid,
    sender: Option<&Jid>,
    message_ids: &[&str],
) -> Result<(), anyhow::Error>
```

<Note>
  `message_ids` is a borrowed slice `&[&str]`, matching [`mark_as_read`](#mark_as_read).
</Note>

<ParamField path="chat" type="&Jid" required>
  Chat JID where the media message was received. Can be a direct message, group, broadcast list, or newsletter JID.
</ParamField>

<ParamField path="sender" type="Option<&Jid>">
  Original sender JID.

  * For DMs: pass `None` (the `participant` attribute is dropped on the wire, matching WhatsApp Web).
  * For groups, broadcast lists, and status broadcasts: pass the JID of the user who sent the media.
  * For newsletters: the receipt is sent as `played-self`; `sender` is ignored.
</ParamField>

<ParamField path="message_ids" type="&[&str]" required>
  Message IDs of the voice or video notes to mark as played. The first ID becomes the receipt's `id` attribute; any additional IDs are batched into a `<list><item/></list>` child, the same shape as `mark_as_read`.

  If empty, this function returns immediately without sending anything.
</ParamField>

### Example: mark a DM voice note as played

```rust theme={null}
use wacore_binary::jid::Jid;

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

client.mark_as_played(
    &chat_jid,
    None, // No participant in DMs
    &["VOICE_MSG_ID"],
).await?;
```

### Example: mark a group voice note as played

```rust theme={null}
let group_jid: Jid = "120363040237990503@g.us".parse()?;
let sender_jid: Jid = "15551234567@s.whatsapp.net".parse()?;

client.mark_as_played(
    &group_jid,
    Some(&sender_jid), // Required in groups and broadcasts
    &["VOICE_MSG_ID"],
).await?;
```

### Example: mark multiple voice notes as played

```rust theme={null}
client.mark_as_played(
    &chat_jid,
    None,
    &["MSG_1", "MSG_2", "MSG_3"],
).await?;
```

<Note>
  Played receipts are **not sent automatically** — call `mark_as_played()` from your media player when the user finishes (or starts) playing the audio or video note. Like `mark_as_read`, a DM played receipt downgrades to `played-self` when the account's `readreceipts` privacy setting is `none` (the sender is not notified); groups, broadcast lists, and status broadcasts are unaffected by that setting. See [Read receipt privacy gating](#read-receipt-privacy-gating) below.
</Note>

***

## Read receipt privacy gating

The `readreceipts` privacy category (see [`PrivacyCategory::ReadReceipts`](/api/privacy#privacycategory)) controls whether `mark_as_read` and `mark_as_played` notify the other party for direct messages:

| `readreceipts` value | DM receipt type sent                                                                                          |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `all` (default)      | `read` / `played` — the sender is notified                                                                    |
| `none`               | `read-self` / `played-self` — marks the message read on your own devices only; the sender is **not** notified |

This gate only applies to one-on-one direct messages. Group messages, broadcast lists, and status broadcasts always send the notifying `read`/`played` type — WhatsApp Web does not apply this privacy setting to them. Newsletters are unaffected too; they always use `read-self`/`played-self` regardless of this setting.

The value is populated from a background call to [`fetch_privacy_settings`](/api/privacy#fetch_privacy_settings) that the client runs automatically after connecting, and is persisted on the device. On a reconnect where the setting hasn't changed since the last fetch, the persisted value already reflects it, so the correct receipt type is available before that connect's background fetch completes. If the setting *did* change — from another linked device, or from this client's own [`set_privacy_setting`](/api/privacy#set_privacy_setting) call — the persisted value is stale until that reconnect's background fetch runs and updates it.

<Note>
  Toggle the setting with `client.set_privacy_setting(PrivacyCategory::ReadReceipts, PrivacyValue::None).await?` (or `PrivacyValue::All` to re-enable). See the [privacy API](/api/privacy) for details. This call only updates the setting on the server — the local cache `mark_as_read`/`mark_as_played` read from is refreshed only by the background fetch that runs on connect, so a receipt sent later in the *same* session, before the next connect, can still go out as the old type. Calling `fetch_privacy_settings()` yourself does **not** refresh that cache either — like `set_privacy_setting`, it's a bare passthrough that returns the current server values without touching device state. Reconnecting (or restarting) is currently the only way to guarantee the new gating applies within the running process.
</Note>

***

## send\_delivery\_receipt (Internal)

Sends a delivery receipt to the sender of a message.

This is an internal method called automatically by the library when messages are received. You typically don't need to call this directly.

```rust theme={null}
pub(crate) async fn send_delivery_receipt(
    &self,
    info: &Arc<MessageInfo>
)
```

<ParamField path="info" type="&Arc<MessageInfo>" required>
  Message metadata containing:

  * `id` - Message ID
  * `source.chat` - Chat JID
  * `source.sender` - Sender JID
  * `source.is_from_me` - Whether this is your own message
  * `source.is_group` - Whether this is a group message
</ParamField>

### Behavior

Delivery receipts are automatically sent for all incoming messages **except**:

* Your own messages (`is_from_me = true`)
* Messages without an ID
* Status broadcast messages (`status@broadcast`)
* Newsletter messages

For group messages, the receipt includes a `participant` attribute identifying the sender.

<Note>
  Delivery receipts are sent automatically. Unlike other receipt types (e.g., `type="read"`, `type="played"`), delivery receipts have **no `type` attribute** on the wire — delivery is the implicit default. The library omits the `type` attribute from ack responses to delivery receipts accordingly, since including an explicit `type="delivery"` would cause `<stream:error>` disconnections from the server. This is different from read receipts (type=`"read"`), which you send manually with `mark_as_read()`.
</Note>

### Wire format

Internally, delivery receipts pass JID references directly to the `.attr()` method, avoiding allocations on the hot path:

```rust theme={null}
let is_status = info.source.chat.is_status_broadcast();
// For 1:1 DMs the `to` echoes the sender JID verbatim so the multi-device
// LID device byte (e.g. `…:7@lid`) survives. Group / status receipts stay
// addressed at the chat JID since those never carry a device.
let to = if info.source.is_group || is_status {
    &info.source.chat
} else {
    &info.source.sender
};

let mut builder = NodeBuilder::new("receipt")
    .attr("id", &info.id)
    .attr("to", to);

if info.category == MessageCategory::Peer {
    builder = builder.attr("type", "peer_msg");
}

if info.source.is_group {
    builder = builder.attr("participant", &info.source.sender);
}

let receipt_node = builder.build();
```

<Note>
  v0.6 split the `to` attribute by addressing case. Earlier versions always used `info.source.chat`, which strips the device byte (`to_non_ad`). For multi-device LID senders that arrived with `from="USER:DEV@lid"`, the device-less receipt was rejected by the LID server: it replayed the stanza from the offline queue and eventually closed the stream with `<stream:error><ack class="message"/></stream:error>`. Matching whatsmeow's `buildBaseReceipt` (which echoes `node.Attrs["from"]` verbatim) and WA Web's `sendDeliveryReceiptsAfterDecryption` resolves the issue.
</Note>

Read receipts (`mark_as_read`) batch multiple message IDs using a `<list>` child node with `<item>` elements:

```rust theme={null}
let mut builder = NodeBuilder::new("receipt")
    .attr("to", chat)
    .attr("type", "read")
    .attr("id", &message_ids[0])
    .attr("t", &timestamp);

if let Some(sender) = sender {
    builder = builder.attr("participant", sender);
}

// Additional message IDs beyond the first
if message_ids.len() > 1 {
    let items: Vec<Node> = message_ids[1..]
        .iter()
        .map(|id| NodeBuilder::new("item").attr("id", id).build())
        .collect();
    builder = builder.children(vec![
        NodeBuilder::new("list").children(items).build()
    ]);
}
```

***

## Receipt Types

WhatsApp supports multiple receipt types:

```rust theme={null}
pub enum ReceiptType {
    Delivered,      // Message delivered to device
    Sender,         // Sender receipt
    Retry,          // Decryption retry request
    EncRekeyRetry,  // VoIP call encryption re-keying retry
    Read,           // Message read by recipient
    ReadSelf,       // Message read on another device
    Played,         // Media played by recipient
    PlayedSelf,     // Media played on another device
    ServerError,    // Server error
    Inactive,       // Inactive participant
    PeerMsg,        // Peer message
    HistorySync,    // History sync
    Other(String),  // Unknown receipt type
}
```

<ParamField path="Delivered" type="variant">
  Delivery receipt (type=`""`). Confirms message was delivered to the recipient's device. Sent automatically by the library.
</ParamField>

<ParamField path="Read" type="variant">
  Read receipt (type=`"read"`). Confirms message was read by the recipient. Sent manually via `mark_as_read()`.
</ParamField>

<ParamField path="ReadSelf" type="variant">
  Read receipt from your own device (type=`"read-self"`). Received when you read a message on another device, or sent for a DM when your own `readreceipts` privacy setting is `none` (see [Read receipt privacy gating](#read-receipt-privacy-gating)).
</ParamField>

<ParamField path="Played" type="variant">
  Played receipt (type=`"played"`). Confirms media (audio/video) was played by the recipient.
</ParamField>

<ParamField path="PlayedSelf" type="variant">
  Played receipt from your own device (type=`"played-self"`). Received when you play media on another device, or sent for a DM when your own `readreceipts` privacy setting is `none` (see [Read receipt privacy gating](#read-receipt-privacy-gating)).
</ParamField>

<ParamField path="Retry" type="variant">
  Retry receipt (type=`"retry"`). Recipient failed to decrypt the message and is requesting a retry. Automatically handled by the library.
</ParamField>

<ParamField path="EncRekeyRetry" type="variant">
  VoIP call encryption re-keying retry receipt (type=`"enc_rekey_retry"`). Sent when a peer fails to decrypt VoIP call encryption data and needs the sender to re-key. Uses an `<enc_rekey>` child element (with `call-creator`, `call-id`, `count` attributes) instead of the standard `<retry>` child. Automatically handled by the library.
</ParamField>

<ParamField path="Sender" type="variant">
  Sender receipt (type=`"sender"`). Acknowledges message was sent.
</ParamField>

<ParamField path="ServerError" type="variant">
  Server error receipt (type=`"server-error"`). Message delivery failed on server.
</ParamField>

***

## Receipt Events

You can listen for receipt events to track message delivery and read status using the Bot event handler:

```rust theme={null}
use wacore::types::events::Event;

let mut bot = Bot::builder()
    .with_backend(backend)
    .with_transport_factory(transport_factory)
    .with_http_client(http_client)
    .on_event(|event, client| async move {
        if let Event::Receipt(receipt) = &*event {
            println!("Receipt type: {:?}", receipt.r#type);
            println!("From: {}", receipt.source.sender);
            println!("Message IDs: {:?}", receipt.message_ids);
            
            match receipt.r#type {
                ReceiptType::Delivered => {
                    println!("Message delivered");
                }
                ReceiptType::Read => {
                    println!("Message read");
                }
                ReceiptType::Played => {
                    println!("Media played");
                }
                _ => {}
            }
        }
    })
    .build()
    .await?;
```

### Receipt event structure

```rust theme={null}
#[non_exhaustive]
pub struct Receipt {
    pub source: MessageSource,
    pub message_ids: Vec<String>,
    pub timestamp: DateTime<Utc>,
    pub r#type: ReceiptType,
    pub offline: bool,
}
```

<Note>
  `Receipt` is `#[non_exhaustive]` and constructed internally via a `bon` builder (`Receipt::builder()…build()`), so a struct-pattern destructure needs a `..` rest — e.g. `Receipt { source, r#type, .. }`. See [Payload stability](/concepts/events#event-enum) for the full policy.
</Note>

<ParamField path="source" type="MessageSource">
  Source information:

  * `chat` - Chat JID where the receipt originated
  * `sender` - JID of the user who sent the receipt
  * `is_group` - Whether this is from a group
</ParamField>

<ParamField path="offline" type="bool">
  `true` when the receipt carried the `offline` attribute. That means it was drained from the server's offline queue on reconnect rather than delivered live. Use it to tell a backlog of receipts received at login apart from real-time ones.
</ParamField>

<ParamField path="message_ids" type="Vec<String>">
  List of message IDs this receipt applies to. Usually contains a single ID, but can have multiple.
</ParamField>

<ParamField path="timestamp" type="DateTime<Utc>">
  When the receipt was received (local time)
</ParamField>

<ParamField path="r#type" type="ReceiptType">
  Type of receipt (Delivered, Read, Played, etc.)
</ParamField>

***

## Message tracking example

Track message delivery and read status:

```rust theme={null}
use std::collections::HashMap;
use wacore::types::events::{Event, ReceiptType};

#[derive(Default)]
struct MessageTracker {
    delivered: HashMap<String, bool>,
    read: HashMap<String, bool>,
}

impl MessageTracker {
    fn track_receipt(&mut self, receipt: &Receipt) {
        for msg_id in &receipt.message_ids {
            match receipt.r#type {
                ReceiptType::Delivered => {
                    self.delivered.insert(msg_id.clone(), true);
                }
                ReceiptType::Read => {
                    self.read.insert(msg_id.clone(), true);
                }
                _ => {}
            }
        }
    }
    
    fn is_delivered(&self, msg_id: &str) -> bool {
        self.delivered.get(msg_id).copied().unwrap_or(false)
    }
    
    fn is_read(&self, msg_id: &str) -> bool {
        self.read.get(msg_id).copied().unwrap_or(false)
    }
}

let tracker = Arc::new(Mutex::new(MessageTracker::default()));

// Use within Bot event handler
let tracker_clone = tracker.clone();
let mut bot = Bot::builder()
    // ... configure backend, transport, http_client ...
    .on_event(move |event, _client| {
        let tracker = tracker_clone.clone();
        async move {
            if let Event::Receipt(receipt) = &*event {
                tracker.lock().await.track_receipt(receipt);
            }
        }
    })
    .build()
    .await?;
```

***

## Played receipts (media)

For voice notes and video notes, send played receipts with [`mark_as_played`](#mark_as_played) once the user has played the media. Newsletters send `played-self`; everything else sends `played`, unless the DM privacy gate (see [above](#read-receipt-privacy-gating)) downgrades it to `played-self`. The wire shape mirrors read receipts:

```xml theme={null}
<receipt id="MESSAGE_ID" to="CHAT_JID" type="played" participant="SENDER_JID" />
```

You can also listen for incoming played receipts via the `Event::Receipt` event with `ReceiptType::Played` or `ReceiptType::PlayedSelf`.

***

## Best Practices

### Read receipt privacy

<Tip>
  The library already respects the account's `readreceipts` privacy setting for direct messages (see [Read receipt privacy gating](#read-receipt-privacy-gating)) — a DM `mark_as_read`/`mark_as_played` call automatically goes out as `read-self`/`played-self` when that setting is `none`. The pattern below is for an additional **local** opt-out, e.g. a per-app toggle that skips sending receipts entirely rather than sending the silent `-self` variant.
</Tip>

```rust theme={null}
struct Settings {
    send_read_receipts: bool,
}

if settings.send_read_receipts {
    client.mark_as_read(&chat_jid, sender, &message_ids).await?;
}
```

### Batching multiple receipts

Send read receipts for multiple messages at once to reduce network overhead:

```rust theme={null}
let mut pending_receipts: Vec<&str> = Vec::new();

// Collect message IDs
pending_receipts.push(msg_id_1);
pending_receipts.push(msg_id_2);
pending_receipts.push(msg_id_3);

// Send batch
if !pending_receipts.is_empty() {
    client.mark_as_read(&chat_jid, None, &pending_receipts).await?;
}
```

### Group message receipts

Always include the sender JID for group messages:

```rust theme={null}
if message_info.source.is_group {
    client.mark_as_read(
        &message_info.source.chat,
        Some(&message_info.source.sender), // Required for groups
        &[message_info.id.as_str()],
    ).await?;
} else {
    client.mark_as_read(
        &message_info.source.chat,
        None, // No sender for DMs
        &[message_info.id.as_str()],
    ).await?;
}
```
