Groups
The two group APIs in the SDK — sender-key messaging and encrypted GroupsV2 state — what each one solves, and why membership removal must rotate.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 12 min
The SDK exposes two group APIs. They are not alternatives or versions of each other. One moves messages. The other moves group state. Most group products need both, so choose the correct API for each task.
| Sender-key messaging | GroupsV2 encrypted state | |
|---|---|---|
| Answers | "How do I encrypt one message for 40 people?" | "Who is in this group, what is it called, who may edit it?" |
| Surface | createGroupSenderKey, encryptGroupMessage, decryptGroupMessage, rotateGroupSenderKey | createGroupV2, addGroupMemberV2, syncGroupV2, GroupsV2Manager |
| Backend stores | envelopes | opaque encrypted group state, sequenced by version |
| You can skip it if | your groups are small enough that pairwise fanout is fine | your app already has a membership system it trusts |
A. Sender-key messaging
Pairwise fanout encrypts the message body separately for every recipient device. For a group of 40 users at two devices each, one message requires roughly 80 encryptions and 80 distinct ciphertexts. The sender's device repeats that linear work for every message.
Sender keys break the coupling. Create one symmetric chain for the group. Distribute it once to each member over the existing pairwise sessions. The sender then encrypts each message once:
await signal.createGroupSenderKey(groupId);
await signal.distributeGroupSenderKey(groupId, memberUserIds);
const encrypted = await signal.encryptGroupMessage(groupId, 'ship it');
for (const member of groupMembers) {
await sendToMember(member, encrypted); // same bytes to everyone
}distributeGroupSenderKey(groupId, memberUserIds) is a convenience over
distributeSenderKeyToUser(groupId, recipientUserId). Both use pairwise sessions.
The sender cannot encrypt the distribution message with the key that the message
delivers. On the receiving side:
await signal.processGroupSenderKeyDistribution(
groupId,
senderId,
senderDeviceId,
distributionMessage,
);
const plaintext = await signal.decryptGroupMessage(
groupId,
senderId,
senderDeviceId,
framedMessage,
);Distribution costs O(n) per membership change. Message encryption cost stays
constant as group size changes.
getGroupSenderKeyDistribution(...) returns the distribution payload for custom routing.
hasGroupSenderKey(groupId) reports whether a local chain exists.
getGroupSenderKeyStats(...) reports its state, and deleteGroupSenderKey(groupId) removes it.
Delivery does not change: the relay still carries one envelope per recipient device. Sender keys reduce encryption work and ciphertext distinctness, not envelope count.
Rotation is the security property
A sender key is a symmetric chain. Every holder can read all messages that use it. After the group removes someone, their chain still grants access to future messages.
const result = await signal.handleGroupMembershipChange(groupId, 'member_removed');
if (result.rotated) {
for (const member of remainingMembers) {
await sendToMember(member, JSON.stringify(result.distributionMessage));
}
}handleGroupMembershipChange(groupId, change) takes 'member_added',
'member_removed', or 'metadata_changed'. It encodes the rule that removal and
metadata changes rotate the key. Addition does not rotate it because the new member
only needs the current key. The method returns { rotated, distributionMessage }.
rotateGroupSenderKey(groupId) is the direct form.
B. GroupsV2 encrypted group state
Sender keys say nothing about group membership. GroupsV2 provides the encrypted state model. It covers the title, description, membership list, access control, and invite links. The backend stores and sequences this ciphertext without reading it.
The application backend stores opaque encrypted group state and sequences changes, while each member device owns group secrets, decrypted state, authorization credentials, and sender-key rotation.
The server must enforce authenticated access and version sequencing without receiving plaintext group attributes or group master keys.
Both halves of that second sentence define requirements. The server must still authenticate each request because anyone can request state. It must also sequence changes. Two devices that edit a group concurrently need a monotonic version for reconciliation. The server completes both tasks on ciphertext.
From the client:
const { groupId, masterKey } = await signal.createGroupV2(
creatorAci,
creatorProfileKey,
members,
'Release engineering',
{ description: 'ship coordination' },
);
await signal.addGroupMemberV2(groupId, editorAci, newMemberAci, newMemberProfileKey);
await signal.removeGroupMemberV2(groupId, editorAci, targetAci);
const state = await signal.syncGroupV2(groupId);Also on the client: getGroupStateV2(groupId), leaveGroupV2(groupId, userAci),
updateGroupTitleV2(...), updateGroupDescriptionV2(...),
updateGroupAccessControlV2(...), createGroupInviteLinkV2(groupId, editorAci),
and joinGroupViaInviteLinkV2(url, userAci, userProfileKey).
If your application manages group state outside the messaging client, such as in a workspace service or existing org chart, use the standalone manager. Give it your own server and store implementations:
import {
GroupsV2Manager,
createGroupId,
type IGroupServer,
type IGroupStateStore,
} from '@open-e2ee/signal-protocol-sdk/groups';
const groups = new GroupsV2Manager({
store: appGroupStore as IGroupStateStore,
server: appGroupServer as IGroupServer,
issueCredential: () => appGroupCredentials.issue(),
credentialPublicKey,
aci: localAccountServiceId,
});
const state = await groups.syncGroup(createGroupId(rawGroupId));The /zk/groups subpath exports the zero-knowledge-proof primitives:
deriveGroupSecretParams, getGroupPublicParams, and computeProfileKeyVersion.
These primitives let a server verify group access without learning the requester's
member identity. You will not usually call them directly. Use them when your backend
must share the same parameters.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
| Group sender key chain | yes | no | no | no |
SenderKeyDistributionMessage | yes | encrypted pairwise | no | that a distribution envelope moved |
encryptGroupMessage() output | yes | yes | no | size, recipient device count |
masterKey from createGroupV2() | yes | no | no | no |
groupId / createGroupId() value | yes | yes | no | yes — the server routes on it |
| Group title and description | yes, decrypted | encrypted | no | that they changed, and the version number |
| Membership list | yes, decrypted | encrypted | no | member count and change frequency |
creatorProfileKey / member profile keys | yes | encrypted in group state | no | no |
| Group state version | yes | yes | no | yes — the server sequences on it |
Invite link from createGroupInviteLinkV2() | yes | yes | no | that a link exists |
The metadata column shows a relay limit. A relay that cannot read a group's name can still observe roughly forty devices exchange two hundred envelopes on Tuesday afternoon. It can also observe three state-version increments. See Limits and metadata.
0.1.x; public APIs and persisted formats may change before 1.0.
Next
- Identity changes and safety numbers: what happens when a group member's identity key changes
- Multi-device: why group fanout counts devices, not users
- Key rotation: rotation as an operational schedule
- Limits and metadata: what a relay still learns about a group
Multi-device
Linking a second device, the difference between provisioning and transfer, and why a new device cannot read messages that predate it.
Identity changes and safety numbers
Generating safety numbers, handling identity-key change errors, and designing a verification experience that users can actually complete.