diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx
index a449788..c8054cc 100644
--- a/advanced/signal-protocol.mdx
+++ b/advanced/signal-protocol.mdx
@@ -342,6 +342,10 @@ Location: `wacore/src/send.rs:675-820`, `src/send.rs`
WhatsApp's multi-device architecture uses two addressing schemes: phone number JIDs (PN, `@s.whatsapp.net`) and Linked Identity JIDs (LID, `@lid`). WhatsApp Web always resolves PN→LID before any session operation via `createSignalAddress()`. whatsapp-rust mirrors this behavior — when a LID mapping is discovered for a phone number, any Signal sessions stored under the PN address are automatically migrated to the corresponding LID address.
+
+ The automatic migration described below is also exposed for manual invocation: [`Signal::migrate_sessions(from, to)`](/api/signal#migrate_sessions) runs the same move for a caller-chosen JID pair, and [`Signal::session_info(jid)`](/api/signal#session_info) inspects a session (migrating a legacy PN-addressed one first if needed) without mutating it further. See the [Signal API reference](/api/signal) for both.
+
+
### Signal address resolution
`Client::resolve_encryption_jid()` mirrors WA Web's `SignalAddress.toString()` (`WAWeb/Signal/Address.js`). It upgrades the JID's `server` to its LID counterpart when a mapping is known, and otherwise returns the input unchanged:
@@ -428,6 +432,10 @@ Identity keys are migrated independently of sessions — they can outlive delete
The migration reads through the cache because the backend may contain stale session data when unflushed cache mutations exist. Reading directly from the backend could skip in-flight ratchet advances, causing the migrated session to decrypt with an outdated chain key.
+
+ `add_lid_pn_mapping` also has a batch form, `Client::add_lid_pn_mappings(mappings, source)`, which durably records many LID↔PN pairs in one call and runs the same per-mapping migration as the single-entry path. It returns how many mappings were actually written, deduplicated against existing records.
+
+
### On-the-fly migration during decryption
If a message arrives from a LID address and decryption fails with `SessionNotFound` or `InvalidPreKeyId`, the client attempts PN→LID migration as a fallback before requesting a retry:
@@ -996,6 +1004,11 @@ client.refresh_pre_keys().await?;
Internally, this acquires `prekey_upload_lock` to prevent races with the count-based and digest-repair upload paths, then calls `upload_pre_keys_with_retry(force: true)` which uses Fibonacci backoff (1s, 2s, 3s, 5s, 8s, ... capped at 610s).
+Two related public methods build on the same `prekey_upload_lock`-guarded path:
+
+- `Client::refresh_pre_keys_with_count(count)` — same force-upload as `refresh_pre_keys()`, but with a caller-chosen batch size instead of the configured [`wanted_pre_key_count`](#configuration).
+- `Client::ensure_pre_keys()` — a non-forced check-and-top-up: uploads only if the server-side pool is below the low-water mark, rather than unconditionally replacing it.
+
Location: `src/prekeys.rs:263-266`
### Digest key validation
@@ -1048,6 +1061,10 @@ After connection, the client validates that the server's copy of the key bundle
Hash mismatches or missing local pre-keys are logged but do **not** trigger a re-upload. Only a 404 response (server has no record) triggers re-upload. This matches WhatsApp Web's behavior where `validateLocalKeyBundle` exceptions are caught without re-uploading — the normal [`RotateKeyJob`](#signed-pre-key-rotation-rotatekeyjob) eventually refreshes the signed pre-key.
+
+ `Client::validate_digest_key()` is a public method — callers can trigger this validation pass on demand instead of only relying on the automatic post-connection check.
+
+
Location: `src/prekeys.rs:218-344`, `wacore/src/iq/prekeys.rs:170-302`
### Signed pre-key rotation (RotateKeyJob)
@@ -1097,6 +1114,10 @@ Before this feature, `Device::load_signed_prekey` (`src/store/signal.rs`) return
**Retry, not NACK, once the id ages past retention:** A sender's `PreKeySignalMessage` can still name a signed pre-key id that has since aged past `SIGNED_PRE_KEY_RETENTION` (3 total: current + 2 rotated-out) — the backend fallback above has nothing left to return, and `InvalidSignedPreKeyId` is the correct, permanent answer. On the 1:1 decrypt path (`src/message/receive.rs`), this now routes to a retry receipt (`RetryReason::InvalidKeyId`) carrying the current bundle, mirroring the sibling `InvalidPreKeyId` arm — instead of falling through to the catch-all `UnhandledError` nack, which would drop the stanza from the offline queue and lose the 1:1 message permanently and silently.
+
+ `Client::rotate_signed_pre_key()` is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the weekly check. It shares `signed_pre_key_rotation_lock` with the automatic path (so a manual call can't race a background rotation) and propagates upload failures to the caller rather than swallowing them.
+
+
Location: `src/features/rotate_key.rs`, `src/store/signal.rs`, `src/message/receive.rs`, `wacore/src/iq/prekeys.rs`, `wacore/src/store/commands.rs`
### Re-pair pre-key healing (v0.6)
diff --git a/api/business.mdx b/api/business.mdx
index 45233e6..c6220ce 100644
--- a/api/business.mdx
+++ b/api/business.mdx
@@ -63,7 +63,7 @@ if let Some(profile) = client.get_business_profile(&jid).await? {
if let Some(configs) = &profile.business_hours.business_config {
for config in configs {
println!(
- " {:?}: {:?} ({}–{})",
+ " {:?}: {:?} ({:?}–{:?})",
config.day_of_week,
config.mode,
config.open_time,
@@ -126,8 +126,8 @@ pub struct BusinessHours {
pub struct BusinessHoursConfig {
pub day_of_week: DayOfWeek,
pub mode: BusinessHourMode,
- pub open_time: u32,
- pub close_time: u32,
+ pub open_time: Option,
+ pub close_time: Option,
}
```
diff --git a/api/community.mdx b/api/community.mdx
index 38c5055..4a680bd 100644
--- a/api/community.mdx
+++ b/api/community.mdx
@@ -51,6 +51,26 @@ if let Some(desc) = &result.metadata.description {
Since v0.6, `community().create()` returns the full `GroupMetadata` instead of just the JID. The library inlines the community description directly into the create stanza (matching WA Web), so the returned metadata already contains it — no separate `set_description` round-trip is needed. If you previously read `result.gid`, switch to `result.metadata.id` (`GroupMetadata` uses `id: Jid`).
+### get_participating
+
+Fetch all parent/community groups the logged-in account currently participates in.
+
+```rust
+pub async fn get_participating(&self) -> Result, CommunityError>
+```
+
+**Returns:**
+- `HashMap` — Map of community JID to metadata
+
+**Example:**
+```rust
+let communities = client.community().get_participating().await?;
+
+for (jid, metadata) in communities {
+ println!("Community: {} ({})", metadata.subject, jid);
+}
+```
+
### deactivate
Deactivate (delete) a community. Subgroups are unlinked but not deleted.
@@ -103,6 +123,44 @@ for (jid, error_code) in &result.failed_groups {
}
```
+### create_subgroup
+
+Create a new group that is already linked as a subgroup of a community, in one call.
+
+```rust
+pub async fn create_subgroup(
+ &self,
+ name: &str,
+ participants: &[Jid],
+ parent_jid: &Jid,
+) -> Result
+```
+
+**Parameters:**
+- `name` — Name of the new subgroup
+- `participants` — Initial participant JIDs to add to the subgroup
+- `parent_jid` — JID of the parent community to link the new subgroup under
+
+**Returns:**
+- `CreateCommunityResult` — Contains the full `metadata: GroupMetadata` for the created subgroup
+
+**Example:**
+```rust
+let participants = vec![
+ "5511999999999@s.whatsapp.net".parse()?,
+];
+
+let result = client.community()
+ .create_subgroup("My Subgroup", &participants, &community_jid)
+ .await?;
+
+println!("Created subgroup: {} ({})", result.metadata.subject, result.metadata.id);
+```
+
+
+Equivalent to creating a group and then calling [`link_subgroups`](#link_subgroups), but done in a single round-trip.
+
+
### unlink_subgroups
Unlink subgroups from a community.
@@ -274,6 +332,38 @@ for p in &participants {
}
```
+### remove_participants
+
+Remove participants from a community.
+
+```rust
+pub async fn remove_participants(
+ &self,
+ community_jid: &Jid,
+ participants: &[Jid],
+) -> Result, CommunityError>
+```
+
+**Parameters:**
+- `community_jid` — JID of the community
+- `participants` — Array of participant JIDs to remove
+
+**Returns:**
+- `Vec` — Result for each participant (see [`ParticipantChangeResponse`](/api/groups#participantchangeresponse))
+
+**Example:**
+```rust
+let to_remove = vec!["15551234567@s.whatsapp.net".parse()?];
+
+let results = client.community()
+ .remove_participants(&community_jid, &to_remove)
+ .await?;
+
+for result in results {
+ println!("{}: status {:?}", result.jid, result.status);
+}
+```
+
## Types
### CreateCommunityOptions
@@ -336,6 +426,8 @@ pub struct CommunitySubgroup {
pub participant_count: Option,
pub is_default_sub_group: bool,
pub is_general_chat: bool,
+ pub creation: Option,
+ pub owner: Option,
}
```
@@ -347,6 +439,8 @@ pub struct CommunitySubgroup {
- `participant_count` — Number of participants (if available)
- `is_default_sub_group` — Whether this is the default announcement subgroup
- `is_general_chat` — Whether this is the general chat subgroup
+- `creation` — Subgroup creation timestamp (Unix seconds), if available
+- `owner` — JID of the subgroup owner, if available
### LinkSubgroupsResult
diff --git a/api/contacts.mdx b/api/contacts.mdx
index 7e62276..fd59283 100644
--- a/api/contacts.mdx
+++ b/api/contacts.mdx
@@ -153,6 +153,10 @@ if let Some(pic) = client.contacts().get_profile_picture(&group_jid, true).await
}
```
+
+To override the default request timeout for a single fetch, use `get_profile_picture_with_timeout(jid, preview, timeout)`, which takes an extra `timeout: Option` argument. Internally the request is built via `ProfilePictureSpec`'s `with_timeout(...)` builder method; pass `None` to fall back to the default timeout.
+
+
### get_user_info
Get user information by JID.
diff --git a/api/groups.mdx b/api/groups.mdx
index e53943d..b502613 100644
--- a/api/groups.mdx
+++ b/api/groups.mdx
@@ -67,25 +67,37 @@ This map is keyed by `Jid`. Call `.to_string()` on the key if you need the strin
**GroupMetadata fields:**
- `id: Jid` - Group JID
- `subject: String` - Group name
+- `notify: Option` - Display notification string reported by the server (from the `notify` attribute)
- `participants: Vec` - List of participants
- `addressing_mode: AddressingMode` - Phone number or LID mode
- `creator: Option` - Group creator JID
+- `creator_pn: Option` - Creator's phone-number JID, when `creator` is a LID
+- `creator_username: Option` - Creator's Meta username, when present
+- `creator_country_code: Option` - Creator's ISO country code, when present
- `creation_time: Option` - Group creation timestamp (Unix seconds)
+- `participant_version_id: Option` - Participant-list version identifier (from `p_v_id`)
+- `admin_version_id: Option` - Admin-list version identifier (from `a_v_id`)
+- `open_thread_id: Option` - Open thread identifier associated with the group
+- `has_missing_participant_identification: bool` - Whether participant identity information was incomplete in this response
- `subject_time: Option` - Subject modification timestamp (Unix seconds)
- `subject_owner: Option` - Subject owner JID
+- `subject_owner_pn: Option` - Subject owner's phone-number JID (from `s_o_pn`)
+- `subject_owner_username: Option` - Subject owner's Meta username (from `s_o_username`)
- `description: Option` - Group description body text
- `description_id: Option` - Description ID (for conflict detection)
- `description_owner: Option` - JID of the participant who set the description
+- `description_owner_pn: Option` - Description owner's phone-number JID
+- `description_owner_username: Option` - Description owner's Meta username
- `description_time: Option` - Timestamp when the description was set (Unix seconds)
- `is_locked: bool` - Whether only admins can edit group info
- `is_announcement: bool` - Whether only admins can send messages
-- `ephemeral_expiration: u32` - Disappearing messages timer in seconds (0 = disabled)
-- `ephemeral_trigger: Option` - Disappearing mode trigger value (from the `trigger` attribute on ``)
+- `ephemeral: Option` - Disappearing-message settings. `None` when the server response has no `` node at all; `Some(GroupEphemeralSettings { expiration, trigger })` when the node is present — `expiration` is `None` if the node omitted the attribute, which is distinct from `Some(0)` (timer explicitly disabled)
- `membership_approval: bool` - Whether admin approval is required to join
- `member_add_mode: Option` - Who can add members
- `member_link_mode: Option` - Who can use invite links
- `size: Option` - Total participant count
- `is_parent_group: bool` - Whether this group is a community parent group
+- `parent_membership_approval_required: bool` - Whether joins to this parent group require approval by default
- `parent_group_jid: Option` - JID of the parent community (for subgroups)
- `is_default_sub_group: bool` - Whether this is the default announcement subgroup of a community
- `is_general_chat: bool` - Whether this is the general chat subgroup of a community
@@ -94,11 +106,25 @@ This map is keyed by `Jid`. Call `.to_string()` on the key if you need the strin
- `member_share_history_mode: Option` - Who can share message history with new members
- `growth_locked: Option` - Growth lock status (invite links temporarily disabled by the system)
- `is_suspended: bool` - Whether the group is suspended
+- `suspension_can_auto_file: bool` - Whether a suspension appeal may be filed automatically
+- `appeal_status: Option` - Current suspension-appeal state
+- `appeal_update_time: Option` - Last suspension-appeal update timestamp (Unix seconds)
+- `is_support_group: bool` - Whether the group is marked as a support group
- `allow_admin_reports: bool` - Whether admin reports are allowed
- `is_hidden_group: bool` - Whether the group is hidden
- `is_incognito: bool` - Whether incognito mode is enabled
- `has_group_history: bool` - Whether group history is enabled
+- `is_auto_add_disabled: bool` - Whether automatic participant addition is disabled
+- `has_capi: bool` - Whether the group carries the CAPI capability marker
+- `evolution_version: Option` - Group schema evolution version
+- `has_group_safety_check: bool` - Whether the group safety-check feature is enabled
+- `participant_label_enabled: bool` - Whether participant labels are enabled
- `is_limit_sharing_enabled: bool` - Whether limit sharing is enabled
+- `limit_sharing_trigger: Option` - Source trigger for limit-sharing enablement
+
+
+`ephemeral_expiration: u32` and `ephemeral_trigger: Option` were replaced by the single `ephemeral: Option` field. Migrate reads like `metadata.ephemeral_expiration` to `metadata.ephemeral.as_ref().and_then(|e| e.expiration).unwrap_or(0)`.
+
See [Community API](/api/community) for community-specific operations.
@@ -107,7 +133,10 @@ See [Community API](/api/community) for community-specific operations.
**GroupParticipant fields:**
- `jid: Jid` - Participant JID
- `phone_number: Option` - Phone number JID (for LID groups)
+- `lid: Option` - Participant's LID JID, when the server includes one
+- `username: Option` - Participant's Meta username, when present
- `participant_type: ParticipantType` - Participant role (member, admin, or super admin)
+- `details: Option>` - Less-common participant metadata (label, join time, display name, etc.); boxed and only populated when at least one field is present
**Example:**
```rust
@@ -200,7 +229,7 @@ for participant in &result.metadata.participants {
```
-Since v0.6, `create_group` returns the full `GroupMetadata` (matching `get_metadata`) instead of just the JID. Inspect `result.metadata` for participants, addressing mode, ephemeral timer, and parent linkage in a single round-trip. `GroupMetadata.participants` is a `Vec` (the IQ-response shape), not the `GroupParticipantInfo` used by group notification events — so masked-number `display_name` labels are only available on the event-side `GroupParticipantInfo`, not here.
+Since v0.6, `create_group` returns the full `GroupMetadata` (matching `get_metadata`) instead of just the JID. Inspect `result.metadata` for participants, addressing mode, ephemeral timer, and parent linkage in a single round-trip. `GroupMetadata.participants` is a `Vec` (the IQ-response shape), not the `GroupParticipantInfo` used by group notification events — but masked-number `display_name` labels are available here too, via `participant.details.as_ref().and_then(|d| d.display_name.as_deref())`, in addition to the event-side `GroupParticipantInfo.display_name` for live group-update events.
### set_subject
@@ -340,6 +369,33 @@ let to_remove = vec!["15551234567@s.whatsapp.net".parse()?];
client.groups().remove_participants(&group_jid, &to_remove).await?;
```
+### remove_participants_including_linked_groups
+
+Remove participants from a group and cascade the removal to its linked/child groups. Used for community-linked groups, where removing someone from the community should also remove them from subgroups.
+
+```rust
+pub async fn remove_participants_including_linked_groups(
+ &self,
+ jid: &Jid,
+ participants: &[Jid],
+) -> Result, GroupError>
+```
+
+**Parameters:**
+- `jid` - Group JID (typically the community parent group)
+- `participants` - Array of participant JIDs to remove
+
+**Returns:**
+- `Vec` - Result for each participant
+
+**Example:**
+```rust
+let group_jid: Jid = "123456789@g.us".parse()?;
+let to_remove = vec!["15551234567@s.whatsapp.net".parse()?];
+
+let results = client.groups().remove_participants_including_linked_groups(&group_jid, &to_remove).await?;
+```
+
### promote_participants
Promote participants to admin.
@@ -349,21 +405,32 @@ pub async fn promote_participants(
&self,
jid: &Jid,
participants: &[Jid],
-) -> Result<(), GroupError>
+) -> Result, GroupError>
```
**Parameters:**
- `jid` - Group JID
- `participants` - Array of participant JIDs to promote
+**Returns:**
+- `Vec` - Result for each participant
+
**Example:**
```rust
let group_jid: Jid = "123456789@g.us".parse()?;
let to_promote = vec!["15551234567@s.whatsapp.net".parse()?];
-client.groups().promote_participants(&group_jid, &to_promote).await?;
+let results = client.groups().promote_participants(&group_jid, &to_promote).await?;
+
+for result in results {
+ println!("Promoted {}: status {:?}", result.jid, result.status);
+}
```
+
+`promote_participants` returns `Vec` (one entry per participant) instead of `()`.
+
+
### demote_participants
Demote admin participants to regular members.
@@ -373,21 +440,32 @@ pub async fn demote_participants(
&self,
jid: &Jid,
participants: &[Jid],
-) -> Result<(), GroupError>
+) -> Result, GroupError>
```
**Parameters:**
- `jid` - Group JID
- `participants` - Array of admin JIDs to demote
+**Returns:**
+- `Vec` - Result for each participant
+
**Example:**
```rust
let group_jid: Jid = "123456789@g.us".parse()?;
let to_demote = vec!["15551234567@s.whatsapp.net".parse()?];
-client.groups().demote_participants(&group_jid, &to_demote).await?;
+let results = client.groups().demote_participants(&group_jid, &to_demote).await?;
+
+for result in results {
+ println!("Demoted {}: status {:?}", result.jid, result.status);
+}
```
+
+`demote_participants` returns `Vec` (one entry per participant) instead of `()`.
+
+
### get_invite_link
Get or reset the group invite link.
@@ -971,6 +1049,36 @@ let group_jid: Jid = "123456789@g.us".parse()?;
client.groups().acknowledge(&group_jid).await?;
```
+### update_member_label_with_id
+
+Set or clear the bot's per-group member label, sent as a `ProtocolMessage` over the normal message path (not an IQ). Returns the sent stanza's message ID.
+
+```rust
+pub async fn update_member_label_with_id(
+ &self,
+ group_jid: &Jid,
+ label: impl Into,
+) -> Result
+```
+
+**Parameters:**
+- `group_jid` - Group JID
+- `label` - New label text, or an empty string to clear the label
+
+**Returns:**
+- `String` - Message ID of the sent stanza
+
+**Example:**
+```rust
+let group_jid: Jid = "123456789@g.us".parse()?;
+let message_id = client.groups().update_member_label_with_id(&group_jid, "VIP").await?;
+println!("Label update sent as message {}", message_id);
+```
+
+
+`update_member_label` is a thin wrapper around `update_member_label_with_id` that discards the message ID and returns `Result<(), GroupError>`, for callers that don't need it.
+
+
### batch_get_info
Batch query group info for multiple groups at once.
@@ -1180,6 +1288,17 @@ impl GroupDescription {
}
```
+### GroupEphemeralSettings
+
+Disappearing-message settings carried by a group's `` node.
+
+```rust
+pub struct GroupEphemeralSettings {
+ pub expiration: Option,
+ pub trigger: Option,
+}
+```
+
### MemberAddMode
```rust
@@ -1204,6 +1323,26 @@ pub enum ParticipantType {
**Methods:**
- `is_admin(&self) -> bool` - Returns `true` if admin or super admin
+### GroupParticipantDetails
+
+Less-common participant metadata. Boxed on `GroupParticipant`/`GroupParticipantResponse` and only populated when at least one field is present.
+
+```rust
+#[non_exhaustive]
+pub struct GroupParticipantDetails {
+ pub participant_label: Option,
+ pub participant_label_mtime: Option,
+ pub join_time: Option,
+ pub group_history_sent: Option,
+ pub display_name: Option,
+ pub is_addressable: bool,
+}
+```
+
+
+`GroupParticipantDetails` is `#[non_exhaustive]`. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding `..`.
+
+
### MemberLinkMode
Controls who can use invite links to join the group.
@@ -1235,6 +1374,19 @@ pub enum MembershipApprovalMode {
}
```
+### GroupAppealStatus
+
+Review state for an appeal on a suspended group.
+
+```rust
+pub enum GroupAppealStatus {
+ Approved,
+ InReview,
+ NoAppeal,
+ Rejected,
+}
+```
+
### GroupParticipantOptions
Options for specifying a participant when creating or modifying a group.
@@ -1391,7 +1543,7 @@ pub struct CreateGroupResult {
`CreateGroupResult` is `#[non_exhaustive]`. Field reads are unaffected; only exhaustive struct destructuring from outside the crate requires adding `..`.
-The `metadata` field carries the full group state returned by the server (same shape as [`get_metadata`](#get_metadata)). `metadata.participants` is `Vec` — note that masked-number `display_name` labels live on `GroupParticipantInfo` (which carries `` children of *notification* events), not on `GroupParticipant` here.
+The `metadata` field carries the full group state returned by the server (same shape as [`get_metadata`](#get_metadata)). `metadata.participants` is `Vec` — masked-number `display_name` labels are reachable via `participant.details.as_ref().and_then(|d| d.display_name.as_deref())`, or from the event-side `GroupParticipantInfo` (which carries `` children of *notification* events) when handling live group-update events.
Prior to v0.6 this struct only exposed a `gid: Jid`. Replace `result.gid` with `result.metadata.id` when upgrading (`GroupMetadata.id`, not `jid`). The same migration applies to `CreateCommunityResult`.
diff --git a/api/send.mdx b/api/send.mdx
index 7572a21..c3d3494 100644
--- a/api/send.mdx
+++ b/api/send.mdx
@@ -343,7 +343,7 @@ let result = client.send_message_with_options(
```
-The `ephemeral_expiration` value should match the chat's disappearing messages timer. You can get this from `GroupMetadata.ephemeral_expiration` for groups, or from `MessageInfo.ephemeral_expiration` on received messages. See the [sending messages guide](/guides/sending-messages#ephemeral-disappearing-messages) for a complete walkthrough.
+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.
### Example: Send with extra stanza nodes
diff --git a/api/signal.mdx b/api/signal.mdx
index 0f4605c..1cd9e81 100644
--- a/api/signal.mdx
+++ b/api/signal.mdx
@@ -109,9 +109,9 @@ pub async fn encrypt_group_message(
**Returns:**
- `(Option>, Vec)` - A tuple of optional SKDM bytes and ciphertext bytes. The SKDM is `Some` only when a new sender key was created (first encrypt for this group or after key rotation). You must distribute the SKDM to all group participants when present.
-
- Not safe to call concurrently with `decrypt_group_message` for the same group — sender key state is not internally locked.
-
+
+ Concurrent calls are serialized on a per-`(group_jid, sender_jid)` chain lock (`sender_key_lock`), keyed here by your own JID as the sender. Two overlapping `encrypt_group_message` calls for the same group safely queue behind one another. A concurrent `decrypt_group_message` call only shares this lock when its `sender_jid` is your own JID (an unusual case) — decrypting messages from other participants uses a different chain and runs fully in parallel. See [sender-key chain locking](/advanced/signal-protocol#parallelized-group-encrypt-fan-out).
+
**Example:**
```rust
@@ -147,9 +147,9 @@ pub async fn decrypt_group_message(
**Returns:**
- `Vec` - Raw padded plaintext. Use `MessageUtils::unpad_message_ref` with the stanza's `v` attribute if WhatsApp message unpadding is needed.
-
- Not safe to call concurrently with `encrypt_group_message` for the same group — sender key state is not internally locked.
-
+
+ Concurrent calls are serialized on a per-`(group_jid, sender_jid)` chain lock (`sender_key_lock`), so two overlapping `decrypt_group_message` calls for the same sender safely queue behind one another. Calls for different senders in the same group — or a concurrent `encrypt_group_message` call, which uses your own JID as the chain identity — touch a different chain and run in parallel. See [sender-key chain lock (group receive)](/concepts/architecture#sender-key-chain-lock-group-receive).
+
**Example:**
```rust
@@ -160,6 +160,112 @@ let plaintext = client.signal().decrypt_group_message(
).await?;
```
+### sender_key_distribution
+
+Create (or lazily initialize) and serialize the current outgoing sender-key distribution message for a group.
+
+```rust
+pub async fn sender_key_distribution(
+ &self,
+ group_jid: &Jid,
+ sender_jid: &Jid,
+) -> Result, SignalError>
+```
+
+**Parameters:**
+- `group_jid` - Group JID
+- `sender_jid` - Your own JID as it should appear to other group members (the sender key chain owner)
+
+**Returns:**
+- `Vec` - Serialized `SenderKeyDistributionMessage` bytes, ready to send to a new or existing group member (e.g. when adding a participant who needs to decrypt future messages)
+
+This is the same distribution payload [`encrypt_group_message`](#encrypt_group_message) returns automatically on first use — call it directly when you need to (re)distribute a sender key out of band, such as when a new member joins and needs the current chain without waiting for the next group message. The distribution is durably persisted before this method returns.
+
+**Example:**
+```rust
+let distribution = client.signal().sender_key_distribution(&group_jid, &my_jid).await?;
+// Send `distribution` to the new participant, e.g. wrapped in a SenderKeyDistributionMessage protocol node
+```
+
+### process_sender_key_distribution
+
+Process an incoming sender-key distribution message for a group, installing the sender's chain so future `skmsg` stanzas from them can be decrypted.
+
+```rust
+pub async fn process_sender_key_distribution(
+ &self,
+ group_jid: &Jid,
+ sender_jid: &Jid,
+ distribution: &[u8],
+) -> Result<(), SignalError>
+```
+
+**Parameters:**
+- `group_jid` - Group JID
+- `sender_jid` - JID of the participant who distributed the sender key
+- `distribution` - Serialized `SenderKeyDistributionMessage` bytes received from the sender (typically extracted from an incoming SKDM node)
+
+The sender-key chain is durably persisted before this method returns.
+
+**Example:**
+```rust
+client.signal().process_sender_key_distribution(
+ &group_jid,
+ &sender_jid,
+ &distribution_bytes,
+).await?;
+```
+
+### has_sender_key
+
+Check whether sender-key state already exists for a group and sender.
+
+```rust
+pub async fn has_sender_key(
+ &self,
+ group_jid: &Jid,
+ sender_jid: &Jid,
+) -> Result
+```
+
+**Parameters:**
+- `group_jid` - Group JID
+- `sender_jid` - Sender's JID within the group
+
+**Returns:**
+- `bool` - `true` if a sender-key chain is already stored for this `(group_jid, sender_jid)` pair
+
+**Example:**
+```rust
+if !client.signal().has_sender_key(&group_jid, &author_jid).await? {
+ // No chain yet — process the SKDM before decrypting skmsg from this sender
+}
+```
+
+### delete_sender_key
+
+Durably delete a sender-key chain for a group and sender, e.g. on group exit or key rotation.
+
+```rust
+pub async fn delete_sender_key(
+ &self,
+ group_jid: &Jid,
+ sender_jid: &Jid,
+) -> Result<(), SignalError>
+```
+
+**Parameters:**
+- `group_jid` - Group JID
+- `sender_jid` - Sender's JID within the group
+
+The deletion waits for any in-flight chain mutation (e.g. a concurrent `encrypt_group_message` ratchet advance) to finish before removing the chain, and is flushed to the persistent backend before returning.
+
+**Example:**
+```rust
+// On leaving a group, drop the local copy of your own sender key chain
+client.signal().delete_sender_key(&group_jid, &my_jid).await?;
+```
+
### validate_session
Check whether a Signal session exists for a JID.
@@ -183,6 +289,31 @@ if client.signal().validate_session(&jid).await? {
}
```
+### session_info
+
+Inspect an existing pairwise Signal session, migrating legacy PN-addressed state to its resolved LID namespace when needed.
+
+```rust
+pub async fn session_info(&self, jid: &Jid) -> Result