Skip to content

Latest commit

 

History

History
2959 lines (2489 loc) · 154 KB

API.md

File metadata and controls

2959 lines (2489 loc) · 154 KB

X Chat SDK API Reference

This document covers the full API across Rust, Python, JavaScript/WASM, Go (github.com/xdevplatform/chat-xdk/go/chatxdk), .NET (ChatXdk — crates/dotnet/README.md), and JVM (com.x.chatxdk — crates/jvm/README.md; JNA to the same native library as .NET).

The JVM binding mirrors the .NET surface (JSON across the native boundary, same method names in camelCase). Tables below list Rust, JS, Python, Go, JVM, and .NET. Go consumers: go/chatxdk/README.md.

Key design principle: Base64 is only at the edges (data from/to the X API). Between SDK methods, everything is raw bytes.

FFI note (Go / .NET / JVM): these bindings marshal arguments to the native library as a JSON params document, so the raw conversation key transits the FFI boundary base64-encoded inside an immutable string. Those intermediate string copies live in the host runtime (Go string / .NET string / Java String) and cannot be zeroized by the caller. The decoded key copy on the Rust side is zeroized on drop.


Unified API Reference

Constructor & Config

# Method Rust JS Python Go JVM .NET
1 new Chat::new(config: JuiceboxConfig), Chat::default(), ChatCore::new() createChat({ juiceboxConfig?, getAuthToken? }) → Promise<ChatWithJuicebox> (free with free()) Chat(config_json?: str) New() *Chat, NewWithJuiceboxConfig(configJSON string) (*Chat, error) (free with Close()) new Chat(), new Chat(String juiceboxConfigJson) (free with close() / try-with-resources) new Chat(), new Chat(string juiceboxConfigJson) (free with Dispose() / using)
2 update_config update_config(&self, config) updateConfig(config: string) update_config(config_json: str) UpdateConfig(configJSON string) error updateConfig(String) UpdateConfig(string)
3 set_reject_unverified set_reject_unverified(&self, reject: bool) setRejectUnverified(reject: boolean) set_reject_unverified(reject: bool) SetRejectUnverified(reject bool) setRejectUnverified(boolean) SetRejectUnverified(bool)
3a set_feature_switches set_feature_switches(&self, status: u16, body: &[u8]) — — — — —

A chat takes its Juicebox config when it's created, in every binding, and update_config sets or replaces it: on first boot, when the account has no config yet, or to refresh its tokens. A Juicebox call already under way keeps the config it started with. The C ABI's chat_xdk_new(juicebox_config_json, &chat) takes it the same way, NULL for none. A chat created without one (Chat::default(), createChat({ getAuthToken }), Chat(), New(), new Chat(), chat_xdk_new(NULL, &chat)) runs every crypto call, and until it has one setup, unlock, change_pin and delete fail with an invalid-state error worded the same in every binding: Invalid state: no Juicebox config: pass the juicebox_config the X API returns when you create the chat, or call update_config() first. An empty or blank config string is no config: creating a chat with one, or passing one to update_config, fails with that error in every binding, and so does Rust's JuiceboxConfig::from_x_api_json, which reads the config the X API returns (a malformed config fails with an invalid-input error naming the problem).

Rust's set_feature_switches, and the C ABI's chat_xdk_set_feature_switches(chat, status, body, body_len), hand a chat the answer to GET /2/chat/features (FEATURES_ENDPOINT, CHAT_XDK_FEATURES_ENDPOINT in chat_xdk.h), which the bindings read themselves when a chat is created (see ARCHITECTURE.md): make the request once, when you create the chat, without credentials or query parameters, within FEATURES_TIMEOUT_MS (CHAT_XDK_FEATURES_TIMEOUT_MS), reading at most one byte past FEATURES_MAX_BODY (CHAT_XDK_FEATURES_MAX_BODY), and pass its status, 0 when no response arrived, and body. Sends stay v0 until an answer turns v1_send on; a chat takes one answer for its lifetime, and a status other than 200 or a body that is too long or isn't a features object leaves every switch off.

The config JSON accepted by the constructor and update_config supports three shapes, checked in this order:

  1. Direct SDK config — {"sdk_config": "<json string>", "tokens": {realm_id: token}, "max_guess_count": N}; sdk_config goes straight to the Juicebox SDK. Default guesses: 20.
  2. X API juicebox_config object — the juicebox_config field from GET /2/users/{id}/public_keys (your own user), passed through as-is: {"key_store_token_map_json": "...", "max_guess_count": N, "token_map": [...]}. The key_store_token_map_json string is used verbatim as the SDK config — it carries each realm's public_key and the server's register/recover thresholds, which the realms require — and auth tokens come from token_map. A malformed embedded config is an error. Default guesses: 20. This is the shape to use with the X API.
  3. Bare token_map array — {"token_map": [{key, value: {address, token}}, ...]} with no key_store_token_map_json; the realm list and majority thresholds are derived. This derivation cannot recover realm public keys, so it only works against realms that don't require them. Default guesses: 5.

The guess budget is the config's max_guess_count in every binding. In JS, realm auth tokens always come from the getAuthToken callback instead of the config, so the SDK reads the config without its tokens / token_map token fields, and accepts a raw realms config as a fourth shape.

createChat's juiceboxConfig is optional to support JS first boot: the account's juicebox_config is created by POST /2/users/{id}/public_keys, so a brand-new user has none yet. Created without a config, the instance runs every crypto method (including generateKeypairs) but builds no Juicebox client; setup/unlock/changePin/delete throw the invalid-state error above until updateConfig supplies a real config. The first-boot flow is: createChat({ getAuthToken }) → generateKeypairs() → caller POSTs the payload → updateConfig(configFromGet) → setup(pin). Already-provisioned accounts pass juiceboxConfig at creation. createChat() without getAuthToken creates a chat without Juicebox, which loads no juicebox-sdk and keeps its keys with importKeys / exportKeys.

Juicebox Key Storage

# Method Rust JS Python Go JVM .NET
4 setup async setup(pin: &[u8]) → PublicKeys setup(pin: string | Uint8Array) → Promise<PublicKeys> setup(pin: str | bytes | bytearray) → PublicKeys, async setup_async(pin) Setup(pin []byte) (*PublicKeys, error) setup(byte[] pin), setupAsync(byte[] pin) → CompletableFuture<PublicKeys> Setup(byte[] pin), SetupAsync(byte[] pin) → Task<PublicKeys>
5 unlock async unlock(pin: &[u8]) unlock(pin: string | Uint8Array) → Promise<void> unlock(pin), async unlock_async(pin) Unlock(pin []byte) error unlock(byte[] pin), unlockAsync(byte[] pin) Unlock(byte[] pin), UnlockAsync(byte[] pin)
6 change_pin async change_pin(old: &[u8], new: &[u8]) changePin(old: string | Uint8Array, new: string | Uint8Array) → Promise<void> change_pin(old_pin, new_pin), async change_pin_async(old_pin, new_pin) ChangePin(oldPin, newPin []byte) error changePin(byte[] oldPin, byte[] newPin), changePinAsync(…) ChangePin(byte[] oldPin, byte[] newPin), ChangePinAsync(…)
7 delete async delete() delete() → Promise<void> delete(), async delete_async() Delete() error delete(), deleteAsync() Delete(), DeleteAsync()
8 lock lock() lock() lock() Lock() lock() Lock()
9 is_unlocked is_unlocked() → bool isUnlocked() → boolean is_unlocked() → bool IsUnlocked() bool isUnlocked() IsUnlocked { get; }
10 has_identity_key has_identity_key() → bool hasIdentityKey() → boolean has_identity_key() → bool HasIdentityKey() bool hasIdentityKey() HasIdentityKey { get; }

Key Management

# Method Rust JS Python Go JVM .NET
11 generate_keypairs → PublicKeyRegistrationPayload generateKeypairs() → object generate_keypairs() → PublicKeyRegistrationPayload GenerateKeypairs() (*PublicKeyRegistrationPayload, error) generateKeypairs() GenerateKeypairs()
12 get_public_keys → PublicKeys getPublicKeys() → object get_public_keys() → PublicKeys GetPublicKeys() (*PublicKeys, error) getPublicKeys() GetPublicKeys()
13 get_public_key_fingerprint → String getPublicKeyFingerprint() → string get_public_key_fingerprint() → str GetPublicKeyFingerprint() (string, error) getPublicKeyFingerprint() GetPublicKeyFingerprint()
14 export_keys → Vec<u8> exportKeys() → Uint8Array export_keys() → bytearray ExportKeys() ([]byte, error) exportKeys() → byte[] ExportKeys() → byte[]
15 import_keys import_keys(&ImportKeysParams) importKeys(params: ImportKeysParams) import_keys(ImportKeysParams) ImportKeys(ImportKeysParams) error importKeys(ImportKeysParams) ImportKeys(ImportKeysParams)
16 set_identity set_identity(&SetIdentityParams) → Result<()> setIdentity(params: SetIdentityParams) set_identity(SetIdentityParams) SetIdentity(SetIdentityParams) error setIdentity(SetIdentityParams) SetIdentity(SetIdentityParams)

export_keys returns unencrypted private key material — handle with care, and zero the bytes once stored (Python returns a mutable bytearray so callers can zero it). It requires a loaded identity key (the signing key is optional, giving a 32-byte export) and fails with an invalid-state error when none is loaded, in every binding.

Both calls take a params object, as every call with more than one input does. ImportKeysParams holds the key blob (keys) and an optional signing_key_version; SetIdentityParams holds user_id and signing_key_version.

The SDK needs to know the version the X API reports for your registered public key, so KeyChange participant entries targeting other key versions are skipped. Set it in either of two equivalent ways: pass signing_key_version in ImportKeysParams, or call set_identity, which sets it together with the user id. After registering the keys from generate_keypairs, call set_identity with the public_key_version the registration returns.

set_identity stores the session identity. Every method with optional sender_id / signing_key_version fields — the encrypt_* family and every prepare_* method — resolves omitted values from it. An explicit per-call value always overrides, and an omitted value and its explicit equivalent produce byte-identical signed output for the same logical inputs. Calling a signing method with neither a per-call value nor a session identity is an error.

decrypt_events reports as latest_version (the version to encrypt with) the newest key change of the batch's conversations whose signature verified, and holds it monotonic for the lifetime of the instance, so neither a replayed older key change nor one the SDK could not verify sets what you encrypt with. Only key changes signed at version ≥ 6 (whose signed payload embeds the plaintext key) can verify; until one does, latest_version is null. Keys from key changes that did not verify still decrypt messages.

PINs passed to setup/change_pin must be at least 4 characters and must not be a single repeated character or a sequential digit run; unlock performs no validation so existing registrations stay recoverable. The SDK core applies the rule in every binding. The Rust core takes PINs as &[u8], Go, .NET, the JVM and the C ABI take bytes, Python takes str, bytes or bytearray, and JS a string or a Uint8Array, so callers can zeroize their buffers.

A wrong PIN fails unlock (and change_pin, which unlocks with the old PIN first) with an error of the invalid-PIN kind that carries the attempt budget Juicebox reports after the failure. 0 means the budget is exhausted and the stored keys are locked; it is absent when no count is available: Rust SdkError::guesses_remaining() → Option<u16>, Python InvalidPinError.guesses_remaining (int | None), JS ChatXdkError.guessesRemaining (number | null), Go (*chatxdk.Error).GuessesRemaining (*int), .NET ChatXdkException.GuessesRemaining (int?), JVM ChatXdkException.getGuessesRemaining() (Integer), and the C ABI error document's guesses_remaining. The message reads Juicebox error: Invalid PIN: guesses_remaining=N in every binding.

Conversation Keys

# Method Rust JS Python Go JVM .NET
17 decrypt_conversation_key (encrypted_b64: &str) → XChatConversationKey decryptConversationKey(b64: string) → Uint8Array decrypt_conversation_key(b64: str) → bytes DecryptConversationKey(encryptedKeyB64 string) ([]byte, error) decryptConversationKey(String) → byte[] DecryptConversationKey(string) → byte[]
18 prepare_conversation_key_change prepare_conversation_key_change(&ConversationKeyChangeParams) → PreparedConversationChange prepareConversationKeyChange(params: ConversationKeyChangeParams) → PreparedConversationChange prepare_conversation_key_change(ConversationKeyChangeParams) → dict PrepareConversationKeyChange(ConversationKeyChangeParams) (*PreparedConversationChange, error) prepareConversationKeyChange(ConversationKeyChangeParams) PrepareConversationKeyChange(ConversationKeyChangeParams)
19 prepare_group_members_change prepare_group_members_change(&GroupMembersChangeParams) → PreparedConversationChange prepareGroupMembersChange(params: GroupMembersChangeParams) → PreparedConversationChange prepare_group_members_change(GroupMembersChangeParams) → dict PrepareGroupMembersChange(GroupMembersChangeParams) (*PreparedConversationChange, error) prepareGroupMembersChange(GroupMembersChangeParams) PrepareGroupMembersChange(GroupMembersChangeParams)
20 prepare_group_create prepare_group_create(&GroupCreateParams) → PreparedConversationChange prepareGroupCreate(params: GroupCreateParams) → PreparedConversationChange prepare_group_create(GroupCreateParams) → dict PrepareGroupCreate(GroupCreateParams) (*PreparedConversationChange, error) prepareGroupCreate(GroupCreateParams) PrepareGroupCreate(GroupCreateParams)
49 prepare_group_members_remove prepare_group_members_remove(&GroupMembersRemoveParams) → PreparedConversationChange prepareGroupMembersRemove(params) → PreparedConversationChange prepare_group_members_remove(GroupMembersRemoveParams) → dict PrepareGroupMembersRemove(GroupMembersRemoveParams) (*PreparedConversationChange, error) prepareGroupMembersRemove(GroupMembersRemoveParams) PrepareGroupMembersRemove(GroupMembersRemoveParams)
50 prepare_group_leave prepare_group_leave(&GroupLeaveParams) → PreparedAction prepareGroupLeave(params) → PreparedAction prepare_group_leave(GroupLeaveParams) → dict PrepareGroupLeave(GroupLeaveParams) (*PreparedAction, error) prepareGroupLeave(GroupLeaveParams) → PreparedAction PrepareGroupLeave(GroupLeaveParams) → PreparedAction
51 prepare_group_title_change prepare_group_title_change(&GroupTitleChangeParams) → PreparedGroupMetadataChange prepareGroupTitleChange(params) → PreparedGroupMetadataChange prepare_group_title_change(GroupTitleChangeParams) → dict PrepareGroupTitleChange(GroupTitleChangeParams) (*PreparedGroupMetadataChange, error) prepareGroupTitleChange(GroupTitleChangeParams) → PreparedGroupMetadataChange PrepareGroupTitleChange(GroupTitleChangeParams) → PreparedGroupMetadataChange
52 prepare_group_avatar_change prepare_group_avatar_change(&GroupAvatarChangeParams) → PreparedAction prepareGroupAvatarChange(params) → PreparedAction prepare_group_avatar_change(GroupAvatarChangeParams) → dict PrepareGroupAvatarChange(GroupAvatarChangeParams) (*PreparedAction, error) prepareGroupAvatarChange(GroupAvatarChangeParams) → PreparedAction PrepareGroupAvatarChange(GroupAvatarChangeParams) → PreparedAction
53 prepare_group_description_change prepare_group_description_change(&GroupDescriptionChangeParams) → PreparedGroupMetadataChange prepareGroupDescriptionChange(params) → PreparedGroupMetadataChange prepare_group_description_change(GroupDescriptionChangeParams) → dict PrepareGroupDescriptionChange(GroupDescriptionChangeParams) (*PreparedGroupMetadataChange, error) prepareGroupDescriptionChange(GroupDescriptionChangeParams) → PreparedGroupMetadataChange PrepareGroupDescriptionChange(GroupDescriptionChangeParams) → PreparedGroupMetadataChange
54 prepare_group_admins_add prepare_group_admins_add(&GroupAdminsAddParams) → PreparedAction prepareGroupAdminsAdd(params) → PreparedAction prepare_group_admins_add(GroupAdminsAddParams) → dict PrepareGroupAdminsAdd(GroupAdminsAddParams) (*PreparedAction, error) prepareGroupAdminsAdd(GroupAdminsAddParams) → PreparedAction PrepareGroupAdminsAdd(GroupAdminsAddParams) → PreparedAction
55 prepare_group_admins_remove prepare_group_admins_remove(&GroupAdminsRemoveParams) → PreparedAction prepareGroupAdminsRemove(params) → PreparedAction prepare_group_admins_remove(GroupAdminsRemoveParams) → dict PrepareGroupAdminsRemove(GroupAdminsRemoveParams) (*PreparedAction, error) prepareGroupAdminsRemove(GroupAdminsRemoveParams) → PreparedAction PrepareGroupAdminsRemove(GroupAdminsRemoveParams) → PreparedAction
56 prepare_group_invite_enable prepare_group_invite_enable(&GroupInviteEnableParams) → PreparedAction prepareGroupInviteEnable(params) → PreparedAction prepare_group_invite_enable(GroupInviteEnableParams) → dict PrepareGroupInviteEnable(GroupInviteEnableParams) (*PreparedAction, error) prepareGroupInviteEnable(GroupInviteEnableParams) → PreparedAction PrepareGroupInviteEnable(GroupInviteEnableParams) → PreparedAction
57 prepare_group_invite_disable prepare_group_invite_disable(&GroupInviteDisableParams) → PreparedAction prepareGroupInviteDisable(params) → PreparedAction prepare_group_invite_disable(GroupInviteDisableParams) → dict PrepareGroupInviteDisable(GroupInviteDisableParams) (*PreparedAction, error) prepareGroupInviteDisable(GroupInviteDisableParams) → PreparedAction PrepareGroupInviteDisable(GroupInviteDisableParams) → PreparedAction
58 prepare_group_join_request prepare_group_join_request(&GroupJoinRequestParams) → PreparedAction prepareGroupJoinRequest(params) → PreparedAction prepare_group_join_request(GroupJoinRequestParams) → dict PrepareGroupJoinRequest(GroupJoinRequestParams) (*PreparedAction, error) prepareGroupJoinRequest(GroupJoinRequestParams) → PreparedAction PrepareGroupJoinRequest(GroupJoinRequestParams) → PreparedAction
59 prepare_group_join_reject prepare_group_join_reject(&GroupJoinRejectParams) → PreparedAction prepareGroupJoinReject(params) → PreparedAction prepare_group_join_reject(GroupJoinRejectParams) → dict PrepareGroupJoinReject(GroupJoinRejectParams) (*PreparedAction, error) prepareGroupJoinReject(GroupJoinRejectParams) → PreparedAction PrepareGroupJoinReject(GroupJoinRejectParams) → PreparedAction
60 prepare_message_ttl_change prepare_message_ttl_change(&MessageTtlChangeParams) → PreparedAction prepareMessageTtlChange(params) → PreparedAction prepare_message_ttl_change(MessageTtlChangeParams) → dict PrepareMessageTtlChange(MessageTtlChangeParams) (*PreparedAction, error) prepareMessageTtlChange(MessageTtlChangeParams) → PreparedAction PrepareMessageTtlChange(MessageTtlChangeParams) → PreparedAction
61 prepare_message_ttl_remove prepare_message_ttl_remove(&MessageTtlRemoveParams) → PreparedAction prepareMessageTtlRemove(params) → PreparedAction prepare_message_ttl_remove(MessageTtlRemoveParams) → dict PrepareMessageTtlRemove(MessageTtlRemoveParams) (*PreparedAction, error) prepareMessageTtlRemove(MessageTtlRemoveParams) → PreparedAction PrepareMessageTtlRemove(MessageTtlRemoveParams) → PreparedAction
62 prepare_screen_capture prepare_screen_capture(&ScreenCaptureParams) → PreparedAction prepareScreenCapture(params) → PreparedAction prepare_screen_capture(ScreenCaptureParams) → dict PrepareScreenCapture(ScreenCaptureParams) (*PreparedAction, error) prepareScreenCapture(ScreenCaptureParams) → PreparedAction PrepareScreenCapture(ScreenCaptureParams) → PreparedAction

Each prepare_*, encrypt_*, decrypt_event(s) and file method takes a single params value, named the same in every language: Rust chat_xdk_core::*Params, JS plain objects matching the exported interfaces, and the *Params classes/structs in Python, Go, JVM and .NET. The Python classes take the required fields positionally and the optional ones as keywords, and expose every field as a read/write attribute. Field names follow each language's idiom: snake_case in Rust/Python (sender_id), camelCase in JS/JVM (senderId), PascalCase in Go/.NET (SenderID / SenderId).

On every prepare method, sender_id / signing_key_version are optional overrides: omitted values (unset / None / null / empty string) resolve from the session identity set via set_identity.

Events

# Method Rust JS Python Go JVM .NET
21 extract_conversation_keys (events: &[&str]) → ConversationKeyResult extractConversationKeys(string[]) → ConversationKeyResult extract_conversation_keys(list[str]) → dict ExtractConversationKeys(events []string) (*ConversationKeyBundle, error) extractConversationKeys(List<String>) ExtractConversationKeys(IEnumerable<string>)
22 set_cache_keys set_cache_keys(enabled: bool) setCacheKeys(enabled: boolean) set_cache_keys(enabled: bool) SetCacheKeys(enabled bool) setCacheKeys(boolean) SetCacheKeys(bool)
23 set_signing_keys set_signing_keys(&[SigningKeyEntry]) setSigningKeys(signingKeys: SigningKeyEntry[]) set_signing_keys(list[dict]) SetSigningKeys(keys []SigningKeyEntry) error setSigningKeys(List<SigningKeyEntry>) SetSigningKeys(IReadOnlyList<SigningKeyEntry>)
24 decrypt_events decrypt_events(DecryptEventsParams) → DecryptEventsResult decryptEvents(params: DecryptEventsParams) → DecryptEventsResult decrypt_events(DecryptEventsParams) → DecryptEventsResult DecryptEvents(DecryptEventsParams) (*DecryptEventsResult, error) decryptEvents(DecryptEventsParams) → DecryptEventsResult DecryptEvents(DecryptEventsParams) → DecryptEventsResult
25 decrypt_event decrypt_event(DecryptEventParams) → Event decryptEvent(params: DecryptEventParams) → Event decrypt_event(DecryptEventParams) → dict DecryptEvent(DecryptEventParams) (*Event, error) decryptEvent(DecryptEventParams) → JsonNode DecryptEvent(DecryptEventParams) → JsonElement

Session stores for the decrypt/encrypt paths. Both decrypt methods accept unset or empty key fields and fall back to two opt-in session stores:

  • set_signing_keys(entries) stores signing keys that decrypt_events and decrypt_event use when their params' signing_keys is unset or empty. Each call replaces the previous set. Only this explicit call populates the store — a key carried inside an event is never trusted for verification.

  • set_cache_keys(enabled) enables the conversation-key cache (off by default). While enabled, decrypt_events caches, per conversation, the key whose key change carried a valid signature at the highest version seen; decrypt_event falls back to it when its params' conversation_keys map is omitted or empty; and the encrypt_* methods and the group title and description prepares resolve an omitted conversation_key / conversation_key_version pair from it. Disabling clears the cache; the cached keys zeroize on drop.

    Only decrypt_events populates the cache, and only from KeyChange events in its own batch whose signature verified. extract_conversation_keys never feeds it: that method adopts every decryptable key without signature checks, and the cache holds verified keys only. To use its result, pass the returned key map to decrypt_event explicitly.

An explicit non-empty value always wins over the stores. The stores do not change the two decrypt contracts: decrypt_events collects per-event errors in the result and throws only for invalid params, and decrypt_event throws on failure.


When to use which

Scenario Use
Initial load — fetching all events for a conversation decryptEvents
Polling / paginating through conversation history decryptEvents
Single webhook event arrives and you already have conversation keys cached decryptEvent
Real-time stream where events arrive one at a time decryptEvent with cached keys

Typical flow:

  1. Once per instance: setSigningKeys(allParticipantKeys) and setCacheKeys(true)
  2. Use decryptEvents({ events }) on first load — it extracts conversation keys and returns them (and, with caching on, retains each conversation's latest verified key)
  3. When new events arrive individually (webhooks, polling), use decryptEvent({ eventB64 }) — the omitted key map and signing keys resolve from the session stores. Alternatively, cache result.conversationKeys.keys client-side and pass it as conversationKeys.

Conversation lifecycle — when to use which prepare method

These methods return a PreparedConversationChange (a conversation key, encrypted participant keys, and action signatures). They differ in which lifecycle action they sign:

Scenario Use Then POST to
Start a one-to-one (first contact) prepareConversationKeyChange — omit conversationId /2/chat/conversations/{recipientId}/keys
Rotate an existing conversation's key (one-to-one or group) prepareConversationKeyChange — pass the conversation id /2/chat/conversations/{id}/keys
Create a group prepareGroupCreate /2/chat/conversations/group/initialize (mints the g… id), then /2/chat/conversations/group
Add members to a group prepareGroupMembersChange /2/chat/conversations/{id}/members
Remove members from a group prepareGroupMembersRemove — pass the public keys of every member who stays, yours included /2/chat/conversations/{id}/members/remove

The group methods are separate because they sign different events: a group create must carry a GroupChangeEvent.GroupCreate signature and a member add a GroupChangeEvent.GroupMemberAddChange signature — each in addition to the conversation-key change signature, so both return two actionSignatures. A member removal does the same, since it always rotates the key: GroupChangeEvent.GroupMemberRemoveChange, a title and a description change for each one passed, re-encrypted under the new key, then the conversation-key change. A key rotation signs only the key change, so prepareConversationKeyChange returns one.

A member removal posts its signatures on /members/remove together with group_key_rotation_data: the new conversation_key_version and the conversation_participant_keys for the members who stay.

After any of these, store the returned conversationKey and conversationKeyVersion, and pass both to encryptMessage and the other encrypt and prepare calls. The key cache (setCacheKeys) adopts the new key only once decryptEvents decrypts the key change from the conversation's events and verifies it; until then an encrypt call that omits the key resolves the previous one, which the members a removal removed still hold.

const next = chat.encryptMessage({
  conversationId,
  text: "hello",
  conversationKey: prepared.conversationKey,
  conversationKeyVersion: prepared.conversationKeyVersion,
});

Two hygiene notes:

  • Verify recipient keys before wrapping. The prepare methods encrypt the fresh conversation key to whatever public keys you pass; run verifyKeyBinding on fetched keys first, so a substituted identity key can't receive the conversation key. For stronger assurance, each party can call getPublicKeyFingerprint() on their own device and compare the values out-of-band.
  • Optional group fields normalize in core. An empty-string title, description or avatar URL and a negative ttlMsec are treated as "not set", so every binding signs the same null whether it can express an absent value or only the FFI encodings of one. Titles and descriptions are signed encrypted, as base64, so a comma in them is fine. An avatar URL is signed as is: a member add's currentAvatarUrl or an avatar change's newAvatarUrl containing a comma is rejected at signing time (the signed payload is comma-joined with no escaping, so it could never verify).

prepare_conversation_key_change

Recommended — Generate, encrypt, and sign a conversation-key change in one call.

Use this to create a one-to-one conversation or rotate a conversation's key. The SDK:

  1. Groups public keys by userId and picks the latest version per user
  2. Generates a fresh conversation key (random 32 bytes) and a conversationKeyVersion (millisecond timestamp)
  3. Encrypts the key for each participant (ECIES)
  4. Signs the change so recipients can verify it came from the sender
  5. Derives the conversation id for a one-to-one, or uses the id you pass for a group

Omit conversationId for a one-to-one and the SDK derives the canonical min:max id from the two participants; pass the existing id for a group rotation.

Conversation-id input forms. Everywhere a one-to-one conversation id is accepted for signing (encryptMessage, encryptReply, reactions, prepareConversationKeyChange), the SDK canonicalizes whatever form you hold: the colon form (A:B), the hyphen form from listings and URL paths (A-B, in either order), or just the other participant's bare user id (paired with the sender, exactly as the backend derives it from the request path). All forms sign identical bytes — the numeric-sorted min:max colon form carried in events. Group ids (g…) and non-numeric ids pass through unchanged.

Returns: PreparedConversationChange

// JS
interface PreparedConversationChange {
  conversationId: string;           // derived (one-to-one) or the id you passed
  conversationKey: Uint8Array;      // raw 32-byte key — store locally
  conversationKeyVersion: string;   // timestamp-based version
  conversationParticipantKeys: ConversationParticipantKey[];  // ready to POST
  actionSignatures: ActionSignature[];                      // ready to POST
}
# Python
{
    "conversation_id": "17380288:1491585161162473473",
    "conversation_key": b"...",              # raw 32 bytes
    "conversation_key_version": "171000...", # timestamp string
    "conversation_participant_keys": [ {"user_id": "111", "encrypted_conversation_key": "...", "public_key_version": "1"} ],
    "action_signatures": [ {"message_id": "...", "message_event_signature": {"signature": "...", ...}, ...} ],
}

Example — one-to-one creation / key rotation:

// 1. App fetches public keys (self + recipient) from X API
//    (GET /2/users/{id}/public_keys for each user)
const publicKeys: PublicKeyInput[] = await fetchPublicKeys([myId, recipientId]);

// 2. SDK generates, encrypts, and signs the change (id derived for a 1:1;
//    the sender identity resolves from setIdentity — set senderId /
//    signingKeyVersion on the params to override)
const result = chat.prepareConversationKeyChange({ publicKeys });

// 3. App POSTs to X API (request bodies take snake_case keys; URL paths take
//    the conversation id in its hyphen form)
await api.post(`/2/chat/conversations/${result.conversationId.replaceAll(":", "-")}/keys`, {
  conversation_key_version: result.conversationKeyVersion,
  conversation_participant_keys: result.conversationParticipantKeys,
  action_signatures: result.actionSignatures,
});

// 4. App stores the conversation key locally
cache.set(result.conversationId, result.conversationKey, result.conversationKeyVersion);

For a group key rotation, set conversationId to the existing group's id, then re-encrypt the group's avatar under the new key, as X clients do on every rotation: encrypt the image with encrypt_file_with_key, upload it, and send an avatar change naming the new key version.


prepare_group_create

Generate, encrypt, and sign a group create. Same return shape as prepare_conversation_key_change; additionally takes the initial member_ids and admin_ids lists and an optional title and TTL.

The backend validates a group create as a pair of signed actions, so the returned actionSignatures array carries two entries: a conversation-key change (ConversationKeyChangeEvent) and the group create (GroupChangeEvent.GroupCreate). Each signature's encodedMessageEventDetail carries the corresponding event, ready to POST.

title is plaintext. The call encrypts it under the new conversation key with XSalsa20-Poly1305, as prepare_group_title_change encrypts a title, and the create signs and carries the ciphertext. The result adds it encrypted, base64, as encrypted_title (encryptedTitle in JS), absent when unset: send it as the request's group_name.

A create sets no avatar. Set one afterwards with prepare_group_avatar_change, with the image encrypted by encrypt_file_with_key under the key the create returns.

prepared = chat.prepare_group_create(
    GroupCreateParams(public_keys, group_id, member_ids, admin_ids, title="Team")
)
api.post("/2/chat/conversations/group", {
    "conversation_id": group_id,
    "group_members": member_ids,
    "group_admins": admin_ids,
    "group_name": prepared["encrypted_title"],
    "conversation_key_version": prepared["conversation_key_version"],
    "conversation_participant_keys": prepared["conversation_participant_keys"],
    "action_signatures": prepared["action_signatures"],
})

A recipient's decrypt_event returns the create as Created, its title a GroupText decrypted with the key for the conversation_key_version the create names.


prepare_group_members_change

Generate, encrypt, and sign a group member-add change for the updated roster. Same return shape as prepare_conversation_key_change; additionally takes the new/current/admin/pending member id lists and the group's current title, description, avatar, TTL, and screen-capture-blocking state, each optional.

Optional group-state fields (per binding):

Field Rust JS Python Go JVM .NET
current_title Option<String> currentTitle?: string | null current_title=None CurrentTitle string (empty = unset) String currentTitle (null = unset) string? CurrentTitle
current_avatar_url Option<String> currentAvatarUrl?: string | null current_avatar_url=None CurrentAvatarURL string (empty = unset) String currentAvatarUrl (null = unset) string? CurrentAvatarUrl
current_description Option<String> currentDescription?: string | null current_description=None CurrentDescription string (empty = unset) String currentDescription (null = unset) string? CurrentDescription
current_ttl_msec Option<i64> currentTtlMsec?: number | null current_ttl_msec=None CurrentTTLMsec *int64 (nil = unset) Long currentTtlMsec (null = unset) long? CurrentTtlMsec
current_screen_capture_blocking_enabled Option<bool> currentScreenCaptureBlockingEnabled?: boolean | null current_screen_capture_blocking_enabled=None CurrentScreenCaptureBlockingEnabled *bool (nil = unset) Boolean currentScreenCaptureBlockingEnabled (null = unset) bool? CurrentScreenCaptureBlockingEnabled

Pass the group's current screenCaptureBlockingEnabled state when the group has it set: the backend compares it against the signed value, so a member add on a screen-capture-blocking group only validates when the flag is supplied. Leave it absent/null when the group never enabled it — absent signs the null sentinel, not false.

Like prepare_group_create, this returns two actionSignatures: a conversation-key change (ConversationKeyChangeEvent) and the member add (GroupChangeEvent.GroupMemberAddChange), each with its encodedMessageEventDetail populated.

The add rotates the conversation key, so the group's title and description move to the key it generates: current_title and current_description are plaintext, and the call encrypts each under the new key, signs the ciphertext in the member add, and returns it, base64, as encrypted_title and encrypted_description (encryptedTitle, encryptedDescription in JS), absent when unset. Send encrypted_title as the add-members request's encrypted_title; the description travels in the signed member add. An add that carries a description signs at signature version 8, the first whose member-add payload covers it; any other add signs at version 7.

prepared = chat.prepare_group_members_change(
    GroupMembersChangeParams(
        public_keys, conversation_id, ["333"], member_ids, admin_ids, [],
        current_title=title, current_description=description,
    )
)
api.post(f"/2/chat/conversations/{conversation_id}/members", {
    "user_ids": ["333"],
    "encrypted_title": prepared["encrypted_title"],
    "conversation_key_version": prepared["conversation_key_version"],
    "conversation_participant_keys": prepared["conversation_participant_keys"],
    "action_signatures": prepared["action_signatures"],
})

current_avatar_url is the avatar's media URL, signed as is. The add doesn't re-encrypt the image, which stays encrypted under the key it was uploaded with, a key the new members never receive. X clients re-encrypt the avatar on every key rotation: encrypt the image under the key the add returns with encrypt_file_with_key, upload it, and send an avatar change naming the new key version.


prepare_group_members_remove

Sign a group member removal and rotate the conversation key, so the members removed don't receive the key the group encrypts under next. Required: public_keys, the identity keys of every member who stays, yours included, in the same shape as prepare_group_members_change; conversation_id; and user_ids, the members being removed. The call encrypts the new key to each user's newest key version, and a member left out, you included, can't read what the group sends under it; the SDK doesn't check that the list is complete. A public_keys entry for someone being removed is rejected.

The group's title and description are encrypted under the conversation key, so a rotation re-encrypts them: pass the ones the group has, as plaintext, in the optional title and description (Rust with_title, with_description), and the call encrypts each under the new key. An empty value is unset. The avatar is an image the app encrypts, so the call doesn't re-encrypt it. X clients re-encrypt the avatar on every key rotation: once the removal is sent, encrypt the image under the new key with encrypt_file_with_key, upload it, and send an avatar change naming the new key version.

The call generates a new conversation key, encrypts it for the members who stay, and returns a PreparedConversationChange: the new key and version, the participant keys, and action_signatures in request order — the removal, bound to the new key version; a title and a description change for each one passed, signed as prepare_group_title_change and prepare_group_description_change sign them and marked as key-rotation re-encryptions (for_key_rotation), which recipients' decrypt_event and decrypt_events drop rather than return as changes (see decrypt_events); then the conversation-key change. Store the new key and version, and pass them to the encrypt and prepare calls until the key cache holds them (see Conversation lifecycle).

Send that result as POST /2/chat/conversations/{id}/members/remove. user_ids are the members being removed. action_signatures are the signatures, in the order returned. group_key_rotation_data carries the new conversation_key_version and conversation_participant_keys (JS: groupKeyRotationData, conversationKeyVersion, conversationParticipantKeys).

prepared = chat.prepare_group_members_remove(
    GroupMembersRemoveParams(
        remaining_public_keys, conversation_id, ["222"], title=title, description=description
    )
)
api.post(f"/2/chat/conversations/{conversation_id}/members/remove", {
    "user_ids": ["222"],
    "action_signatures": prepared["action_signatures"],
    "group_key_rotation_data": {
        "conversation_key_version": prepared["conversation_key_version"],
        "conversation_participant_keys": prepared["conversation_participant_keys"],
    },
})
const prepared = chat.prepareGroupMembersRemove({
  publicKeys: remainingPublicKeys,
  conversationId,
  userIds: ["222"],
  title,
  description,
});
await api.post(`/2/chat/conversations/${conversationId}/members/remove`, {
  user_ids: ["222"],
  action_signatures: prepared.actionSignatures,
  group_key_rotation_data: {
    conversation_key_version: prepared.conversationKeyVersion,
    conversation_participant_keys: prepared.conversationParticipantKeys,
  },
});

The removal is signed at signature version 11:

GroupChangeEvent.GroupMemberRemoveChange,{message_id},{sender_id},{conversation_id},{user_ids...},{conversation_key_version},signature_version=11

The key change is the same ConversationKeyChangeEvent payload prepare_conversation_key_change signs, at signature version 7.

The result does not carry a ratchet tree (base64_encoded_key_rotation) or a rewrapped pin-key bundle (rotated_pmk_bundle).


prepare_group_leave

Sign leaving a group. Leave is a removal of the sender and does not rotate the conversation key. promote_admin_id, when set, is the member who becomes an admin — the caller chooses whom to promote.

action_signatures are the removal (version 11, with conversation_key_version or null), then a GroupAdminAddChange when promote_admin_id is set, then a ConversationDeleteEvent with no further fields. Returns a PreparedAction.

GroupChangeEvent.GroupAdminAddChange,{message_id},{sender_id},{conversation_id},{promote_admin_id}
ConversationDeleteEvent,{message_id},{sender_id},{conversation_id}

prepare_group_title_change

Encrypt a new group title and sign the change. Required: conversation_id and new_title, the title as plaintext. The call encrypts it under the conversation key with XSalsa20-Poly1305, the cipher messages use, and resolves the key as encrypt_message does: the optional conversation_key / conversation_key_version pair (Rust with_conversation_key), or the key cache when both are omitted. The sender_id / signing_key_version overrides resolve from the session identity.

Returns: PreparedGroupMetadataChange

Field Meaning
encrypted_value The title encrypted under the conversation key, base64: the value the request sends as the title
conversation_key_version The version of the key it is encrypted under
action_signatures One signature, at version 7, over the key version and the encrypted title
GroupChangeEvent.GroupTitleChange,{message_id},{sender_id},{conversation_id},{conversation_key_version},{encrypted_value}
prepared = chat.prepare_group_title_change(GroupTitleChangeParams(conversation_id, "Team"))
# prepared["encrypted_value"], prepared["action_signatures"]
const prepared = chat.prepareGroupTitleChange({ conversationId, newTitle: "Team" });
// prepared.encryptedValue, prepared.actionSignatures

A recipient's decrypt_event returns the change as TitleChanged, its new_title a GroupText decrypted with the key for the version the change names.


prepare_group_avatar_change

Sign a group avatar change. An avatar URL is not encrypted; the image is. The app encrypts the image bytes under the conversation key with encrypt_file_with_key, as X clients do, uploads them to the group's media, and passes the image's media URL. Required: conversation_id; new_avatar_url, that media URL, signed and sent as is; and conversation_key_version, the version of the key the image is encrypted under. The sender_id / signing_key_version overrides resolve from the session identity. Returns a PreparedAction: one signature, at version 7, over the key version and the URL.

GroupChangeEvent.GroupAvatarUrlChange,{message_id},{sender_id},{conversation_id},{conversation_key_version},{new_avatar_url}
encrypted = chat.encrypt_file_with_key(
    EncryptFileWithKeyParams(image, keys[conversation_key_version])
)
# … upload `encrypted` to the group's media; avatar_url is its media URL …
prepared = chat.prepare_group_avatar_change(
    GroupAvatarChangeParams(conversation_id, avatar_url, conversation_key_version)
)
# prepared["action_signatures"]

A recipient's decrypt_event returns it as AvatarChanged with new_avatar_url and conversation_key_version: the image to download and the version of the key that decrypts it, with decrypt_file_with_key:

change = group_change["change"]
image = chat.decrypt_file_with_key(
    DecryptFileWithKeyParams(downloaded, keys[change["conversation_key_version"]])
)

The image stays under the key it was encrypted with until an avatar change names another. X clients re-encrypt the avatar under the new conversation key on every key rotation (a member add or removal, or a group's key change), so after a rotation encrypt the image again under the new key, upload it, and send an avatar change naming the new key version.


prepare_group_description_change

Encrypt a new group description and sign the change. Required: conversation_id and new_description, the description as plaintext. The key resolves, and the result is shaped, as for prepare_group_title_change:

GroupChangeEvent.GroupDescriptionChange,{message_id},{sender_id},{conversation_id},{conversation_key_version},{encrypted_value}

A recipient's decrypt_event returns it as DescriptionChanged, its new_description a GroupText like a title change's new_title.


prepare_group_admins_add

Sign adding group admins. user_ids are the admins being added, in the order they are signed. Returns one PreparedAction signature at version 7:

GroupChangeEvent.GroupAdminAddChange,{message_id},{sender_id},{conversation_id},{user_ids...}

prepare_group_admins_remove

Sign removing group admins. Same shape as prepare_group_admins_add:

GroupChangeEvent.GroupAdminRemoveChange,{message_id},{sender_id},{conversation_id},{user_ids...}

prepare_group_invite_enable

Sign enabling a group invite. invite_url is required. An omitted expires_at_msec or affiliate_id — and a negative expiry or an empty affiliate id — signs the literal null. Returns one PreparedAction signature at version 7:

GroupChangeEvent.GroupInviteEnable,{message_id},{sender_id},{conversation_id},{invite_url},{expires_at_msec|null},{affiliate_id|null}

prepare_group_invite_disable

Sign disabling a group invite. disabled_by is the member turning it off and defaults to the sender. Returns one PreparedAction signature at version 7:

GroupChangeEvent.GroupInviteDisable,{message_id},{sender_id},{conversation_id},{disabled_by}

prepare_group_join_request

Sign a request to join a group. requesting_user_id defaults to the sender. An invite token the request also carries is not part of the signature. Returns one PreparedAction signature at version 7:

GroupChangeEvent.GroupJoinRequest,{message_id},{sender_id},{conversation_id},{requesting_user_id}

prepare_group_join_reject

Sign rejecting join requests. user_ids are the rejected users, in the order they are signed. Returns one PreparedAction signature at version 7:

GroupChangeEvent.GroupJoinReject,{message_id},{sender_id},{conversation_id},{user_ids...}

prepare_message_ttl_change

Sign a message-TTL change. The signature covers ttl_msec only. A negative TTL is rejected. Returns one PreparedAction signature at version 7:

ConversationMetadataChangeEvent.MessageDurationChange,{message_id},{sender_id},{conversation_id},{ttl_msec}

prepare_message_ttl_remove

Sign clearing a conversation's message TTL. The signature has no fields after the conversation id. Returns one PreparedAction signature at version 7:

ConversationMetadataChangeEvent.MessageDurationRemove,{message_id},{sender_id},{conversation_id}

prepare_screen_capture

Sign one screen-capture setting. change is a ScreenCaptureChange:

Value Signed event
BlockingEnabled (blockingEnabled in JS) ConversationMetadataChangeEvent.EnableScreenCaptureBlocking
BlockingDisabled ConversationMetadataChangeEvent.DisableScreenCaptureBlocking
DetectionEnabled ConversationMetadataChangeEvent.EnableScreenCaptureDetection
DetectionDisabled ConversationMetadataChangeEvent.DisableScreenCaptureDetection

Blocking names are the events X clients sign. Detection names are the event names this SDK's verifier reconstructs. Each signature has no fields after the conversation id and is signed at version 7. Returns one PreparedAction.

ConversationMetadataChangeEvent.EnableScreenCaptureBlocking,{message_id},{sender_id},{conversation_id}

Every new params type takes optional sender_id and signing_key_version, resolved from set_identity when omitted. A one-to-one id is signed in its canonical colon form. Any signed component that contains a comma is rejected.


extract_conversation_keys

Scans an array of raw events for KeyChange events and decrypts the conversation keys from them.

Params:

Param JS Python Rust Go JVM .NET
events string[] list[str] &[&str] []string List<String> IEnumerable<string>

Returns: ConversationKeyResult

// JS
interface ConversationKeyResult {
  keys: { [version: string]: Uint8Array };  // version → raw conversation key
  latestVersion: string | null;             // highest version; signatures are not checked
}
# Python
{
    "keys": { "1": b"...", "2": b"..." },   # version → raw bytes
    "latest_version": "2"                    # or None
}

decrypt_events — batch, self-contained

Decrypts multiple events in one call. Handles everything internally:

  1. First pass: extracts conversation keys from any KeyChange events in the array
  2. Second pass: for each event, extracts the sender ID, filters signing keys by userId, and decrypts

Params: DecryptEventsParams

Field JS Python Rust Go JVM .NET Description
events events: string[] events: list[str] events: Vec<String> Events []string List<String> events IReadOnlyList<string> Events All base64-encoded raw events. Must include KeyChange events — without them, messages depending on those keys will land in errors. The events endpoint returns KeyChange events in meta.conversation_key_events, separate from the data array; concatenate both into this field.
signing_keys signingKeys?: SigningKeyEntry[] | null signing_keys: list[dict] | None (keyword) signing_keys: Option<Vec<SigningKeyEntry>> SigningKeys []SigningKeyEntry (nil = unset) List<SigningKeyEntry> signingKeys (null = unset) IReadOnlyList<SigningKeyEntry>? SigningKeys Signing keys for all participants. The SDK extracts each event's senderId internally and filters to the matching keys. Leaving it unset (or empty) falls back to the keys stored via setSigningKeys; if none are stored either, under the default reject-unverified policy every signed event fails decryption and lands in errors. Only after setRejectUnverified(false) are such events returned with verified: false.

Returns: DecryptEventsResult. An event that fails is collected in errors rather than thrown; the call throws (raises) only for invalid params, such as a malformed signing key entry. events holds the events that decrypted, of every event type. conversation_keys.latest_version, the version to encrypt with, is the newest version whose key change verified, monotonic for the chat's lifetime, and null until a key change verifies; a key change that doesn't verify still adds its key to conversation_keys.keys.

A member removal re-encrypts the group's title and description under the new key, as title and description changes marked for_key_rotation. They aren't changes, so decrypt drops them under either reject-unverified setting, after verifying them, as it drops an unverified event: each is left out of events and recorded in errors, with a message that says it is a key-rotation re-encryption. The removal itself, its key change, avatar changes and every title or description change without the mark decrypt as usual.

// JS
interface DecryptEventsResult {
  events: DecryptedEvent[];                 // the events that decrypted, of every type
  conversationKeys: ConversationKeyResult;  // extracted keys (cache these)
  errors: { [index: string]: string };      // event index (string key) → error message
}

interface DecryptedEvent {
  event: Event;           // the decrypted event (same type as decryptEvent returns)
  originalB64?: string;   // the raw input string, for reference
}
# Python
{
    "events": [
        { "event": { "type": "Message", ... }, "original_b64": "SGVsbG8..." },
        ...
    ],
    "conversation_keys": { "keys": { ... }, "latest_version": "3" },
    "errors": { "2": "Missing conversation key for version 4" }
}

Rule out a missing-key-events batch first. The most common cause of Message encrypted with key version '…' but no matching key found is caller input: the KeyChange events were left out of the batch (they arrive in meta.conversation_key_events, not data — see the events argument above). That case is fixed by re-batching with the key events included.

The remaining per-event errors are permanent. Signatures are immutable and verified by rebuilding the signed payload from the event, so an event whose signature was produced from the wrong input (or never signed at all) fails verification on every future load — it cannot be healed by retrying, refreshing keys, or any API call. Treat these as tombstones, not transient failures:

Error Meaning
…signature missing or no matching signing key on a KeyChange The key change was never signed (or signed with an unpublished key). Its key still decrypts messages, but never becomes the version to encrypt with.
ECDSA mismatch: key_version=… The signer fed different bytes into the signature than the event carries (e.g. a non-canonical conversation id).
Message encrypted with key version '…' but no matching key found (key events included in the batch) No key change in the batch gives this device the message's key: none names that version, or none is encrypted to this device's key version.

New messages are unaffected: rotating the key starts a clean, verifiable history from that point forward. (Verifiability only — rotation does not retroactively protect earlier messages if a key was compromised; see the forward-secrecy note in CRYPTO.md.)

JS example:

// Fetch all events from the API
const rawEvents: string[] = await fetchConversationEvents(conversationId);

// Fetch signing keys for all participants (pass the X API response through directly)
const signingKeys: SigningKeyEntry[] = [
  { userId: "111", publicKeyVersion: "1", publicKey: "BASE64...", identityPublicKey: "BASE64...", identityPublicKeySignature: "BASE64..." },
  { userId: "111", publicKeyVersion: "2", publicKey: "BASE64...", identityPublicKey: "BASE64...", identityPublicKeySignature: "BASE64..." },
  { userId: "222", publicKeyVersion: "1", publicKey: "BASE64...", identityPublicKey: "BASE64...", identityPublicKeySignature: "BASE64..." },
];

const result = chat.decryptEvents({ events: rawEvents, signingKeys });

// Iterate decrypted messages
for (const dm of result.events) {
  if (dm.event.type === "message") {
    console.log(dm.event.senderId, dm.event.content.text);
  }
}

// Keep the conversation keys for later decryptEvent calls
const cachedKeys = result.conversationKeys;
console.log(cachedKeys.latestVersion); // "3": the newest verified key change, null until one verifies

Python example:

from chat_xdk import DecryptEventsParams

result = chat.decrypt_events(DecryptEventsParams(raw_events, signing_keys=signing_keys))

for dm in result["events"]:
    evt = dm["event"]
    if evt["type"] == "Message":
        print(evt["sender_id"], evt["content"]["text"])

# Cache for later
cached_keys = result["conversation_keys"]

Go example:

result, err := chat.DecryptEvents(chatxdk.DecryptEventsParams{
    Events:      rawEvents,
    SigningKeys: signingKeys,
})
if err != nil {
    panic(err)
}

for _, dm := range result.Events {
    if dm.Event.Type == "Message" {
        fmt.Println(dm.Event.AsMessage().Text())
    }
}

// Cache for later
cachedKeys := result.ConversationKeys.Keys

JVM example:

DecryptEventsParams params = new DecryptEventsParams(rawEvents);
params.signingKeys = signingKeys;
DecryptEventsResult result = chat.decryptEvents(params);

for (DecryptedEvent dm : result.events) {
    JsonNode event = dm.event;
    if ("Message".equals(event.path("type").asText())) {
        System.out.println(event.path("content").path("text").asText());
    }
}

// Cache for later
Map<String, byte[]> cachedKeys = result.conversationKeys.keys;

.NET example:

var result = chat.DecryptEvents(new DecryptEventsParams(rawEvents) { SigningKeys = signingKeys });

foreach (var dm in result.Events)
{
    if (dm.Event.GetProperty("type").GetString() == "Message")
        Console.WriteLine(dm.Event.GetProperty("content").GetProperty("text").GetString());
}

// Cache for later
var cachedKeys = result.ConversationKeys.Keys;

In Python, result["errors"] maps string indices (e.g. "2") to error messages — JSON object keys, consistent with other bindings.


decrypt_event — single event, with pre-cached keys

Decrypts a single event using conversation keys you already have — cached from a prior decryptEvents or extractConversationKeys call, or resolved from the opt-in key cache (setCacheKeys) when the key map is empty.

Params: DecryptEventParams

Field JS Python Rust Go JVM .NET Description
event_b64 eventB64: string event_b64: str event_b64: String EventB64 string String eventB64 string EventB64 One base64-encoded raw event.
conversation_keys conversationKeys?: ConversationKeyMap | null conversation_keys: dict[str, bytes] | None = None conversation_keys: HashMap<String, XChatConversationKey> ConversationKeys map[string][]byte Map<String, byte[]> conversationKeys (or the one-argument constructor) IReadOnlyDictionary<string, byte[]>? ConversationKeys (optional constructor argument) Pre-extracted keys: version → raw 32-byte key. Every binding passes raw key bytes. Optional everywhere but Rust, where the map may be empty: omitted or empty, the keys come from the opt-in key cache (setCacheKeys), and non-message events (typing, read receipts) need no keys at all.
signing_keys signingKeys?: SigningKeyEntry[] | null signing_keys: list[dict] | None (keyword) signing_keys: Option<Vec<SigningKeyEntry>> SigningKeys []SigningKeyEntry (nil = unset) List<SigningKeyEntry> signingKeys (null = unset) IReadOnlyList<SigningKeyEntry>? SigningKeys Signing keys for the event's sender. The SDK matches by publicKeyVersion against the version in the event's signature. Leaving it unset (or empty) falls back to the keys stored via setSigningKeys; if none are stored either, under the default reject-unverified policy every signed event fails with an error. Only after setRejectUnverified(false) are such events returned with verified: false.

Returns: Event directly — or throws/raises on failure (unlike decryptEvents which collects errors). It also throws an invalid-input error for an event decrypt drops: a member removal's title or description re-encryption, marked for_key_rotation (see decrypt_events). A loop over single events catches the error and moves on to the next event, as it does for an unverified one.

// JS — returns Event, throws on error
interface DecryptEventParams {
  eventB64: string;
  conversationKeys?: ConversationKeyMap | null;  // omitted ⇒ opt-in key cache (setCacheKeys)
  signingKeys?: SigningKeyEntry[] | null;  // unset ⇒ keys from setSigningKeys; if
                                           // none stored, signed events throw,
                                           // unless setRejectUnverified(false)
}
decryptEvent(params: DecryptEventParams): Event
# Python — returns dict, raises on error
DecryptEventParams(
    event_b64: str,
    conversation_keys: dict[str, bytes] | None = None,  # { version: bytes };
                                          # None ⇒ opt-in key cache (set_cache_keys)
    *,
    signing_keys: list[dict] | None = None,  # None ⇒ keys from set_signing_keys;
                                             # if none stored, signed events raise,
                                             # unless set_reject_unverified(False)
)
decrypt_event(params: DecryptEventParams) -> dict

JS example:

// You cached keys from a previous decryptEvents call
const cachedKeys = previousResult.conversationKeys.keys;

// A new event arrives via webhook
const event = chat.decryptEvent({
  eventB64: webhookEventB64,
  conversationKeys: cachedKeys,
  signingKeys: [
    { userId: "111", publicKeyVersion: "2", publicKey: "BASE64...", identityPublicKey: "BASE64...", identityPublicKeySignature: "BASE64..." },
  ],
});

console.log(event.type);             // "message"
console.log(event.senderId);         // "111"
console.log(event.content.text);     // "hello"
console.log(event.verified);         // true

Python example:

from chat_xdk import DecryptEventParams

event = chat.decrypt_event(
    DecryptEventParams(event_b64, cached_keys, signing_keys=sender_signing_keys)
)
if event["type"] == "Message":
    print(event["content"]["text"])

Go example:

event, err := chat.DecryptEvent(chatxdk.DecryptEventParams{
    EventB64:         eventB64,
    ConversationKeys: cachedKeys,
    SigningKeys:      senderSigningKeys,
})
if err != nil {
    panic(err)
}
fmt.Println(event.Type)

JVM example:

DecryptEventParams params = new DecryptEventParams(eventB64, cachedKeys);
params.signingKeys = senderSigningKeys;
JsonNode event = chat.decryptEvent(params);
if ("Message".equals(event.path("type").asText())) {
    System.out.println(event.path("content").path("text").asText());
}

.NET example:

var eventJson = chat.DecryptEvent(
    new DecryptEventParams(eventB64, cachedKeys) { SigningKeys = senderSigningKeys });
if (eventJson.GetProperty("type").GetString() == "Message")
    Console.WriteLine(eventJson.GetProperty("content").GetProperty("text").GetString());

Side-by-side comparison

decryptEvent decryptEvents
Input 1 event (string) N events (string[])
Conversation keys Caller provides (ConversationKeyMap); empty ⇒ opt-in key cache (setCacheKeys) SDK extracts from KeyChange events internally
Signing keys For the sender only, matched by publicKeyVersion; unset ⇒ setSigningKeys store For all participants, SDK filters by userId per event; unset ⇒ setSigningKeys store
Error handling Throws on failure Per-event errors collected in result.errors; throws only for invalid params
A removal's title and description re-encryptions Throws an invalid-input error Left out of events, recorded in errors
Return type Event (flat) DecryptEventsResult wrapping DecryptedEvent[]
Returns original b64 No Yes (originalB64 on each message)
Returns conv keys No Yes (conversationKeys with latestVersion)
Parses each event 1× multiple passes (key extraction → key-change signature enforcement → freshness pinning → senderId extraction → decrypt)
Requires KeyChange in input No — you bring your own keys Yes — or keyless messages end up in errors

Shared types

SigningKeyEntry — used by both methods. Every field maps directly to one entry of the X API public keys response, so you can pass the API data through unchanged:

// JS
interface SigningKeyEntry {
  userId: string;                     // user ID that owns this key
  publicKeyVersion: string;           // key version from the X API ("public_key_version")
  publicKey: string;                  // base64 signing public key ("signing_public_key")
  identityPublicKey: string;          // base64 identity public key ("public_key")
  identityPublicKeySignature: string; // base64 raw r||s binding signature ("identity_public_key_signature")
}
# Python
{
  "user_id": "123",
  "public_key_version": "1",
  "public_key": "BASE64...",                    # signing public key
  "identity_public_key": "BASE64...",
  "identity_public_key_signature": "BASE64...",
}

The SDK verifies each entry's identity binding (identityPublicKeySignature) before trusting it for signature verification, so all fields are required. In Rust, SigningKeyEntry::new takes them in the order above.

ConversationKeyMap — the conversationKeys field of DecryptEventParams and of the file decrypt params, and the keys field of ConversationKeyResult:

// JS
type ConversationKeyMap = { [version: string]: Uint8Array };
# Python
{ "1": b"\x01\x02...", "2": b"\x03\x04..." }

Unencrypted events: Events without a conversationKeyVersion carry no verifiable signature, so under the default reject-unverified policy they are rejected (decryptEvent throws; decryptEvents records them in errors). After setRejectUnverified(false) they are returned with conversationKeyVersion: null and verified: false.

Message Encryption

# Method Rust JS Python Go JVM .NET
26 encrypt_message encrypt_message(&EncryptMessageParams) → SendPayload encryptMessage(params: EncryptMessageParams) → SendPayload encrypt_message(EncryptMessageParams) → SendPayload EncryptMessage(params EncryptMessageParams) (*SendPayload, error) encryptMessage(EncryptMessageParams) EncryptMessage(EncryptMessageParams)
27 encrypt_reply encrypt_reply(&EncryptReplyParams) → SendPayload encryptReply(params: EncryptReplyParams) → SendPayload encrypt_reply(EncryptReplyParams) → SendPayload EncryptReply(params EncryptReplyParams) (*SendPayload, error) encryptReply(EncryptReplyParams) EncryptReply(EncryptReplyParams)
28 encrypt_add_reaction encrypt_add_reaction(&EncryptReactionParams) → SendPayload encryptAddReaction(params: EncryptReactionParams) → SendPayload encrypt_add_reaction(EncryptReactionParams) → SendPayload EncryptAddReaction(params EncryptReactionParams) (*SendPayload, error) encryptAddReaction(EncryptReactionParams) EncryptAddReaction(EncryptReactionParams)
29 encrypt_remove_reaction encrypt_remove_reaction(&EncryptReactionParams) → SendPayload encryptRemoveReaction(params: EncryptReactionParams) → SendPayload encrypt_remove_reaction(EncryptReactionParams) → SendPayload EncryptRemoveReaction(params EncryptReactionParams) (*SendPayload, error) encryptRemoveReaction(EncryptReactionParams) EncryptRemoveReaction(EncryptReactionParams)
30 encrypt_edit encrypt_edit(&EncryptEditParams) → SendPayload encryptEdit(params: EncryptEditParams) → SendPayload encrypt_edit(EncryptEditParams) → SendPayload EncryptEdit(params EncryptEditParams) (*SendPayload, error) encryptEdit(EncryptEditParams) EncryptEdit(EncryptEditParams)
31 prepare_message_delete prepare_message_delete(&MessageDeleteParams) → PreparedAction prepareMessageDelete(params: MessageDeleteParams) → PreparedAction prepare_message_delete(MessageDeleteParams) → dict PrepareMessageDelete(params MessageDeleteParams) (*PreparedAction, error) prepareMessageDelete(MessageDeleteParams) → PreparedAction PrepareMessageDelete(MessageDeleteParams) → PreparedAction
32 prepare_mark_conversation_read prepare_mark_conversation_read(&MarkConversationReadParams) → PreparedAction prepareMarkConversationRead(params: MarkConversationReadParams) → PreparedAction prepare_mark_conversation_read(MarkConversationReadParams) → dict PrepareMarkConversationRead(params MarkConversationReadParams) (*PreparedAction, error) prepareMarkConversationRead(MarkConversationReadParams) → PreparedAction PrepareMarkConversationRead(MarkConversationReadParams) → PreparedAction

Required fields of EncryptMessageParams (all bindings, names per language idiom): conversation_id and text. Everything else is an optional override:

  • sender_id / signing_key_version — resolve from the session identity (set_identity) when omitted.
  • conversation_key / conversation_key_version — resolve from the opt-in key cache (set_cache_keys) when omitted. The pair travels together: set both or neither. When set, conversation_key is the raw 32-byte key (Uint8Array in JS, bytes in Python, []byte in Go, byte[] in JVM/.NET) from prepare_conversation_key_change() / decrypt_conversation_key().
  • entities, attachments, quick_reply, should_notify (defaults to true), ttl_msec.

An omitted value and its explicit equivalent produce byte-identical signed output for the same logical inputs (Rust: params.with_identity(…) / params.with_conversation_key(…)). The SDK generates the message id and returns it on the SendPayload (see below), together with event_b64: the message as recipients receive it.

A message with files is built before its files are encrypted. Each file — a media attachment, or a link card's banner or favicon image — names the media_hash_key its upload started with. Encrypt the message first, then encrypt each file from the payload's event_b64 and its media_hash_key (see Files (Media)). In v1 (see below) the SDK derives the id of a message with files from the conversation key and its version, the sender, the conversation, and the files' media hash keys in order, so building the same message again (same files, sender, conversation and key) gives the same id, and the files encrypted for it stay valid. The text does not affect the id. v0 messages, and messages without files, take a random id, as in 0.5.x. Each file needs its own media hash key: an empty one, or one listed in more than one attachment, is rejected.

Upload each file for one message only. Because the text does not affect a v1 message's id, a second message in the same conversation, from the same sender under the same key version, that reuses the first one's uploads gets the first one's id, and recipients, who deduplicate on the message id, drop it as a repeat. To send the same file again, upload it again: the new upload gets its own media hash key.

The SDK picks each send's format. An encrypted send (message, reply, reaction, edit) is either v0, the 0.5.x format, with contents under the conversation key, no contents_encryption_version, and signature version 7, or v1, with contents encrypted under a key derived from the conversation key, the sender id, and the message id, the MessageCreateEvent marked with contents_encryption_version 1, and signature version 10, which covers that field (see CRYPTO.md — Message Keys). Files follow their message's format. The sender id must be the sender's numeric user id and the conversation key version an integer, as the key-change event carries it; anything else is rejected, since v1 recipients derive the key from the numeric id and look the key up by the version.

Every user id is a string of digits in every binding: the set_identity user id, sender_id, reply_to_sender_id, the user_id of each public key, and group member, admin and pending-member ids. Each must be a positive integer written without a sign or leading zeros, and anything else fails with an error naming the field (Invalid reply_to_sender_id '…'). JS takes no numbers for them: ids pass 2^53, where JS numbers stop being exact.

Every key version is a string of digits, as the X API returns it: the signing-key version (signing_key_version in set_identity, import_keys and per call, and each public key's public_key_version) and the conversation_key_version. Both kinds take the same check, a non-negative integer written without a sign or leading zeros, and anything else fails with an error naming the field.

The format follows the X API's v1_send feature switch, which the JS, Python, Go, .NET and JVM bindings read in the background once, when a chat is created (see ARCHITECTURE.md — Feature Switches). Every switch is off until the answer arrives, so a chat whose switch is on sends v0 first and v1 from then on. Files encrypted from a v0 build of a message don't open with a v1 build of it, so encrypt each file from the payload you send. The Rust crate and the C ABI never fetch it: a Rust app or a C caller makes the request and hands a chat the answer, with set_feature_switches or chat_xdk_set_feature_switches (see Constructor & Config), and a chat without one sends v0. They read v1 sends like every binding. The fetch logs nothing; to see which format a send used, read the payload's signature_info.signature_version: "7" for v0, "10" for v1. A 0.5.x reader rejects a v1 send as a signature verification failure, so in its logs v1 sends look like forged messages.

The entities parameter shape is split by binding: Rust, .NET, and JVM take structured EntityDescriptor objects ({ start, end, entity_type }), while JS, Python, and Go take positional [start, end, type] tuples (EntityTuple in JS/Go, plain tuples in Python). Same wire format underneath — see the EntityDescriptor / EntityTuple type section. The attachments parameter takes media, URL-card (optionally with encrypted banner/favicon preview images), and post descriptors — see AttachmentDescriptor (send side). The quick_reply parameter attaches a quick-reply request (selectable options) or response (the selected option) — see QuickReply. Temporary restriction: encrypt_message and encrypt_reply reject attachment lists that current first-party clients cannot render — multiple attachments are allowed only when every one is image/gif/video media (media_type 1/2/3, or omitted, which defaults to image), up to 10 per message; everything else — audio, file, svg, or unrecognized media types, URL cards, and posts — must be the sole attachment. Disallowed combinations fail with a "disallowed attachment combination…" (or "too many attachments…") error in every binding rather than producing a message that breaks receivers. This mirrors the first-party encrypted-send rules and will be relaxed when clients support heterogeneous attachment lists; the wire format already carries them.

Replies are event-based. The preferred form of EncryptReplyParams adds reply_to_event — the base64 raw signed event being replied to. The SDK derives the reply preview (sequence id, sender, text, entities, attachments) from it and embeds the raw event in the outgoing message so recipients can validate the preview (see CRYPTO.md — Reply preview validation). Two companions cover the edge cases: reply_to_edit_event carries the raw edit event when the original was edited, and reply_to_ckces carries the raw key-change events needed to decrypt an original that was encrypted under a different key version than this reply. When the original or its edit is encrypted under a per-message key, the reply also carries that key so recipients can open it. The explicit preview fields — reply_to_sequence_id, reply_to_sender_id, reply_to_text, reply_to_entities, reply_to_attachments — remain as overrides for callers that no longer hold the raw event.

Reactions are event-based too. EncryptReactionParams (shared by add and remove — the same params value can add and later remove the same reaction) requires emoji plus a target: pass target_event, the base64 raw event being reacted to, and the SDK derives conversation_id and target_message_sequence_id from it; or set those two fields explicitly instead.

Edits target an event the same way. EncryptEditParams requires updated_text — the replacement message text — plus the same target choice as a reaction: target_event (the base64 raw event of the message being edited) or the explicit conversation_id / target_message_sequence_id pair. Optional entities describe rich text in the replacement text; omitting them clears any entities the original carried. With target_event, the edit also names the original's signed message id, which receivers need to tie an edit to a message encrypted under a per-message key. The edit is encrypted and signed like a regular message (the identity/key overrides above apply) and returns a SendPayload with its own fresh message_id.

Delivery-race warning. Receiving clients apply an edit to their stored copy of the original and park it when the original has not arrived yet; the backend stops serving a superseded original, so an edit that reaches a client before the original leaves the message permanently invisible on that client. Give a freshly sent message a few seconds to be delivered before sending its first edit.

Message deletes are signed, not encrypted. MessageDeleteParams requires conversation_id, the sequence_ids of the messages to delete, and delete_for_all — true deletes for every participant (own messages only), false only from the caller's view. No conversation key is involved: prepare_message_delete returns a PreparedAction, the request's action_signatures with one entry. The entry is an ActionSignature, the same shape as the conversation-change actionSignatures entries (see Action signatures below); its encodedMessageEventDetail carries the MessageDeleteEvent. The optional sender_id / signing_key_version overrides resolve from the session identity, and a one-to-one id is signed in its canonical colon form, as everywhere.

Read receipts are signed, not encrypted. MarkConversationReadParams requires conversation_id and seen_until_sequence_id — the sequence_id of the newest event read. A seen_at_msec (milliseconds since the Unix epoch) records when it was read and defaults to the current time when unset; the JS binding reads the JavaScript clock. Sign a receipt with prepare_mark_conversation_read and send the PreparedAction's action_signatures on POST /2/chat/conversations/{id}/read as they are: recipients on the default policy drop an unsigned receipt. The call returns a PreparedAction shaped as for deletes, whose entry's encodedMessageEventDetail carries the MarkConversationReadEvent. The signature covers the read point and its time, so a recipient's decrypt_event returns a signed receipt as a ReadReceipt event with verified set, seen_until_sequence_id, and seen_at_msec. The identity overrides and the canonical one-to-one id work as for deletes.

Action signatures. Every prepare method returns its signed actions in the shape a request's action_signatures carries them, so they serialize into the request body as they are (snake_case shown; JS returns camelCase):

{
  "action_signatures": [
    {
      "message_id": "<the action's message id, generated by the SDK>",
      "encoded_message_event_detail": "<base64 MessageEventDetail>",
      "message_event_signature": {
        "signature": "<base64 ECDSA signature>",
        "signature_version": "7",
        "public_key_version": "<your signing key version>",
        "signing_public_key": "<your signing public key, as get_public_keys returns it>"
      }
    }
  ]
}

The Rust params structs are #[non_exhaustive]: build each with a new(…required…) constructor and set the optional fields with its with_* methods (the fields stay public):

// Rust — session identity + key cache resolve the omitted fields
chat.set_identity(&SetIdentityParams::new(my_user_id, signing_key_version))?;
chat.set_cache_keys(true); // filled by decrypt_events

let params = EncryptMessageParams::new(conversation_id, "Hello").with_ttl_msec(86_400_000);
let payload = chat.encrypt_message(&params)?;
let message_id = payload.message_id; // generated by the SDK

// Reply / react by handing back the raw event being answered
let reply = chat.encrypt_reply(&EncryptReplyParams::new(
    conversation_id, "pong", original_event_b64,
))?;
let reaction =
    chat.encrypt_add_reaction(&EncryptReactionParams::new(original_event_b64, "👍"))?;
// JS
chat.setIdentity({ userId: myUserId, signingKeyVersion });
chat.setCacheKeys(true);

const payload = chat.encryptMessage({
  conversationId,
  text: "Hello",
  ttlMsec: 86_400_000,
});
const messageId = payload.messageId; // generated by the SDK

const reply = chat.encryptReply({
  conversationId,
  text: "pong",
  replyToEvent: originalEventB64,
});
const reaction = chat.encryptAddReaction({ emoji: "👍", targetEvent: originalEventB64 });
# Python — params classes: required fields positional, optionals as keywords
from chat_xdk import (
    EncryptMessageParams,
    EncryptReactionParams,
    EncryptReplyParams,
    SetIdentityParams,
)

chat.set_identity(SetIdentityParams(my_user_id, signing_key_version))
chat.set_cache_keys(True)

payload = chat.encrypt_message(
    EncryptMessageParams(conversation_id, "Hello", ttl_msec=86_400_000)
)
message_id = payload.message_id  # generated by the SDK

reply = chat.encrypt_reply(EncryptReplyParams(conversation_id, "pong", original_event_b64))
reaction = chat.encrypt_add_reaction(EncryptReactionParams(original_event_b64, "👍"))

Files (Media)

# Method Rust JS Python Go JVM .NET
33 encrypt_file encrypt_file(&EncryptFileParams) → Vec<u8> encryptFile(params: EncryptFileParams) → Uint8Array encrypt_file(EncryptFileParams) → bytes EncryptFile(EncryptFileParams) ([]byte, error) encryptFile(EncryptFileParams) → byte[] EncryptFile(EncryptFileParams) → byte[]
34 file_encryptor file_encryptor(&FileEncryptorParams) → FileEncryptor fileEncryptor(params: FileEncryptorParams) → FileEncryptor file_encryptor(FileEncryptorParams) → FileEncryptor FileEncryptor(FileEncryptorParams) (*FileEncryptor, error) fileEncryptor(FileEncryptorParams) → FileEncryptor FileEncryptor(FileEncryptorParams) → FileEncryptor
35 decrypt_file decrypt_file(DecryptFileParams) → Vec<u8> decryptFile(params: DecryptFileParams) → Uint8Array decrypt_file(DecryptFileParams) → bytes DecryptFile(DecryptFileParams) ([]byte, error) decryptFile(DecryptFileParams) → byte[] DecryptFile(DecryptFileParams) → byte[]
36 file_decryptor file_decryptor(FileDecryptorParams) → FileDecryptor fileDecryptor(params: FileDecryptorParams) → FileDecryptor file_decryptor(FileDecryptorParams) → FileDecryptor FileDecryptor(FileDecryptorParams) (*FileDecryptor, error) fileDecryptor(FileDecryptorParams) → FileDecryptor FileDecryptor(FileDecryptorParams) → FileDecryptor
63 encrypt_file_with_key encrypt_file_with_key(&EncryptFileWithKeyParams) → Vec<u8> encryptFileWithKey(params: EncryptFileWithKeyParams) → Uint8Array encrypt_file_with_key(EncryptFileWithKeyParams) → bytes EncryptFileWithKey(EncryptFileWithKeyParams) ([]byte, error) encryptFileWithKey(EncryptFileWithKeyParams) → byte[] EncryptFileWithKey(EncryptFileWithKeyParams) → byte[]
64 decrypt_file_with_key decrypt_file_with_key(&DecryptFileWithKeyParams) → Vec<u8> decryptFileWithKey(params: DecryptFileWithKeyParams) → Uint8Array decrypt_file_with_key(DecryptFileWithKeyParams) → bytes DecryptFileWithKey(DecryptFileWithKeyParams) ([]byte, error) decryptFileWithKey(DecryptFileWithKeyParams) → byte[] DecryptFileWithKey(DecryptFileWithKeyParams) → byte[]

Which call. A file attached to a message goes through encrypt_file and decrypt_file (or their incremental forms): the app passes the message, and the SDK finds the attachment in event_b64 and derives the file's key from it, so the app never handles a file key. A file under a key the app already holds, outside any message, goes through encrypt_file_with_key and decrypt_file_with_key: a group's avatar image, which X clients encrypt under the conversation key whose version the avatar change signs next to the avatar URL (see prepare_group_avatar_change), or an app's own files at rest under a key it keeps. These take the raw 32-byte key and encrypt the file under the key itself, with nothing derived from a message, in the same file format as a message's files, the one 0.5.x encrypt_stream writes.

The message file calls take the key the way the message calls do: a send takes the one key its message was encrypted under, and a receive takes the conversation's key map, as decrypt_event does. The calls under a key take the key itself (names per language idiom):

Params Fields
EncryptFileParams file, event_b64, media_hash_key; optional conversation_key + conversation_key_version
FileEncryptorParams event_b64, media_hash_key; optional conversation_key + conversation_key_version
DecryptFileParams encrypted, event_b64, conversation_keys, media_hash_key
FileDecryptorParams event_b64, conversation_keys, media_hash_key
EncryptFileWithKeyParams file, key
DecryptFileWithKeyParams encrypted, key

event_b64 is the base64 event of the message that carries the file and media_hash_key the file's media hash key. On a send, conversation_key and conversation_key_version are the raw 32-byte key the message was encrypted under and its version, set together as on EncryptMessageParams; with both unset they resolve from the key cache. On a receive, conversation_keys is the conversation's key map (version → raw 32-byte key). Each file of a message is encrypted under its own key, derived from the message's key and the file's attachment (see CRYPTO.md — Media Keys), so a file encrypted for another message or attachment does not decrypt. key is the raw 32-byte key itself, such as a conversation key: nothing is derived from it, so every file encrypted under it opens with it.

Sending files. A message with files is built before its files are encrypted:

  1. Start each file's upload to the conversation's media store (POST /2/chat/media/upload/initialize, with total_bytes set to encrypted_file_size of the file's size) and keep the returned media_hash_key. A media hash key names one file of one message: start a new upload for each file of each message.
  2. Encrypt the message with encrypt_message or encrypt_reply, its attachments naming those media hash keys.
  3. Encrypt each file with encrypt_file or file_encryptor: pass the payload's event_b64, the file's media_hash_key, and the conversation key and version the message was encrypted under (or neither, to resolve them from the key cache). The SDK finds the file in the message and encrypts it under that attachment's key.
  4. Upload each encrypted file and finish its upload (POST /2/chat/media/upload/{session_id}/append, then POST /2/chat/media/upload/{session_id}/finalize), then send the message (POST /2/chat/conversations/{id}/messages).

The order of the uploads does not matter, and the file calls need no session identity: the sender, message id and key version come from event_b64. If you build the message again (for example after a restart), the same files, sender, conversation and key give the same message id, so files already encrypted for it stay valid.

In the examples, api.post(path, body) and api.get(path) stand for your HTTP client, sending requests to https://api.x.com + path with your user access token: post sends body as JSON, with its keys in snake_case, and returns the parsed response, and get returns the response body's bytes. The upload requests take conversation_id in its colon form, and URL paths take a conversation id in its hyphen form.

const totalBytes = encryptedFileSize(photo.length);
const { data: upload } = await api.post("/2/chat/media/upload/initialize", {
  conversation_id: conversationId, total_bytes: totalBytes,
});
const mediaHashKey = upload.media_hash_key;
const payload = chat.encryptMessage({
  conversationId,
  text: "a photo",
  attachments: [{ attachment_type: "media", media_hash_key: mediaHashKey, width, height, filesize_bytes: totalBytes, filename: "photo.jpg", media_type: 1 }],
});
const encrypted = chat.encryptFile({
  file: photo,
  eventB64: payload.eventB64,
  conversationKey,
  conversationKeyVersion,
  mediaHashKey,
});
await api.post(`/2/chat/media/upload/${upload.session_id}/append`, {
  conversation_id: conversationId, media_hash_key: mediaHashKey,
  segment_index: 0, media: bytesToBase64(encrypted),
});
await api.post(`/2/chat/media/upload/${upload.session_id}/finalize`, {
  conversation_id: conversationId, media_hash_key: mediaHashKey, num_parts: "1",
});
await api.post(`/2/chat/conversations/${conversationId.replaceAll(":", "-")}/messages`, {
  message_id: payload.messageId,
  encoded_message_create_event: payload.encodedMessageCreateEvent,
  encoded_message_event_signature: payload.encodedMessageEventSignature,
  xdk_version: payload.xdkVersion,
});

A larger file goes up in several append requests, segment_index 0, 1 and so on (the examples send 3 MB segments), with their count as num_parts.

Receiving files. Pass the base64 event of the message that carries the file and the conversation's key map — the same two values decrypt_event takes — plus the file's media_hash_key from the decrypted message: one of its media attachments, a link card's banner or favicon (both listed in media_hashes), or media quoted in its reply preview. The SDK finds that attachment in the event, picks the key for the message's version from the map, and derives the file's key: newer clients encrypt each attachment under its own key derived from the message, and older messages encrypt media under the conversation key. Everything comes from these values, so any instance can decrypt any file at any time, before or after the message itself. Signatures are not checked here; use the verified flag from decrypt_event.

const keys = result.conversationKeys.keys;
const message = chat.decryptEvent({ eventB64, conversationKeys: keys });
for (const { mediaHashKey } of message.mediaHashes ?? []) {
  const encrypted = await api.get(`/2/chat/media/${message.conversationId.replaceAll(":", "-")}/${mediaHashKey}`);
  const plaintext = chat.decryptFile({ encrypted, eventB64, conversationKeys: keys, mediaHashKey });
}

Files under a key you hold. Pass the file and the raw 32-byte key to encrypt_file_with_key, and the downloaded bytes and the same key to decrypt_file_with_key. The encrypted size is encrypted_file_size of the file's size, as for a message's file. A group avatar's key is the conversation key for the version its avatar change names; the full flow is under prepare_group_avatar_change. Files 0.5.x apps encrypted with encrypt_stream or a stream encryptor open with decrypt_file_with_key, and 0.5.x decrypt_stream opens the files encrypt_file_with_key returns. An empty file encrypts to its 24-byte header alone, which nothing authenticates, so any 24 bytes decrypt to an empty file under any key, a file cut to its header included: when the file can't be empty, as an avatar image can't, treat an empty result as a failure.

const encrypted = chat.encryptFileWithKey({ file: image, key: conversationKey });
// … upload `encrypted`; later, download it as `downloaded` …
const opened = chat.decryptFileWithKey({ encrypted: downloaded, key: conversationKey });

Whole file or incremental. encrypt_file and decrypt_file process the whole file in memory, and so do encrypt_file_with_key and decrypt_file_with_key, which have no incremental form: read a file under a key whole. file_encryptor and file_decryptor return an incremental object for large files, so memory stays bounded by the chunk size: feed chunks with push, then call finish once. The encryptor's finish emits the final frame, without which the file does not decrypt. The decryptor's finish errors if the file ended before its final frame (truncation detection), so output from push must not be treated as complete until finish succeeds. The native bindings (Go, JVM, .NET) free the object via Close/close/Dispose. In JS/WASM, finish() consumes and frees the object; call free() only when abandoning a file without finishing it (calling it after finish() throws).

The file calls raise an error that says what to pass instead when:

  • (under a key) key is not 32 bytes: kind invalid_input;
  • (decrypting under a key) the bytes are not a complete file encrypted under key (another key's file, corrupted, or truncated): kind decryption;
  • (encrypting) conversation_key_version is not the message's key version, conversation_key is not 32 bytes, or only one of the two is set;
  • (decrypting) the key map has no key for the message's version (the error names the version), or an entry is not a 32-byte key;
  • media_hash_key is empty, or is not one of the message's files (the error lists the media hash keys that are);
  • the event is not a message, is an edit, reaction or other entry without files, or is a message with no files;
  • (encrypting) media_hash_key names media the reply quotes: that file belongs to the quoted message, so there is nothing to encrypt;
  • (decrypting) the bytes are not the file's complete encrypted form: another file, corrupted, or truncated;
  • (decrypting) the message is not encrypted, so neither is its media: use the downloaded bytes as they are;
  • (decrypting) a reply shows quoted media but does not embed the quoted message: pass the quoted message's own event.

Group Metadata

# Method Rust JS Python Go JVM .NET
37 decrypt (&str, &Key) → String (string, Uint8Array) → string (str, bytes) → str Decrypt(ciphertextB64 string, conversationKey []byte) (string, error) — UTF-8 plaintext decrypt(String, byte[]) → String Decrypt(string, byte[]) → string

The X API returns a group's title and description (group_name and group_description on a conversation) encrypted under the conversation key. decrypt(ciphertext_b64, key) decrypts one with XSalsa20-Poly1305 under the raw key of the version it was encrypted under, and returns the UTF-8 plaintext. A title a 0.5.x app set is plaintext: when decrypt fails on a title, show the value as the API returns it. The avatar (group_avatar_url) is a media URL, not encrypted; the image it points to is, and it opens with decrypt_file_with_key under the conversation key for the version its avatar change names. Group creates and changes need no separate call: prepare_group_create, prepare_group_members_change, prepare_group_title_change and prepare_group_description_change encrypt the value, and decrypt_event decrypts a received create or change.

Wire format: base64(nonce[24B] || poly1305_tag[16B] || ciphertext) — same as message content encryption.

// JS — decrypt group name from API response
const groupName = chat.decrypt(conversation.groupName, conversationKey);
# Python
group_name = chat.decrypt(conversation["group_name"], conv_key)

Signing

# Method Rust JS Python Go JVM .NET
38 sign (&[u8]) → Vec<u8> (Uint8Array) → Uint8Array (bytes) → bytes Sign(data []byte) ([]byte, error) sign(byte[]) → byte[] Sign(byte[]) → byte[]
39 verify (pk_b64, &[u8] sig, &[u8] data) → bool (pk_b64, Uint8Array sig, Uint8Array data) → boolean (pk_b64, bytes sig, bytes data) → bool Verify(publicKeyB64 string, signature, data []byte) (bool, error) verify(String publicKeyB64, byte[] signature, byte[] data) Verify(string publicKeyB64, byte[] signature, byte[] data)
40 verify_key_binding (identity_b64, signing_b64, sig_b64) → bool verifyKeyBinding(identityB64, signingB64, sigB64) → boolean verify_key_binding(identity_b64, signing_b64, sig_b64) → bool VerifyKeyBinding(identityB64, signingB64, sigB64 string) (bool, error) verifyKeyBinding(String, String, String) VerifyKeyBinding(string, string, string)
41 matches_registered_key (public_key_b64) → bool matchesRegisteredKey(publicKeyB64) → boolean matches_registered_key(public_key_b64) → bool MatchesRegisteredKey(publicKeyB64 string) (bool, error) matchesRegisteredKey(String) MatchesRegisteredKey(string)

Conversation-key changes, group creates, and group member-adds are signed by the one-call prepare methods (prepare_conversation_key_change, prepare_group_create, prepare_group_members_change), which return the actionSignatures ready to POST. Group create and member-add each return two signatures (a conversation-key change plus the group change). Message deletes and read receipts are signed by prepare_message_delete and prepare_mark_conversation_read, which each return a PreparedAction with one entry in its actionSignatures.

Utilities

Common helpers exported as module-level functions (Rust crate root / JS module / Python module / Go chatxdk package / JVM and .NET ChatXdkUtilities classes):

# Function Rust JS Python Go JVM .NET
42 bytes_to_base64 (&[u8]) → String bytesToBase64(Uint8Array) → string bytes_to_base64(bytes) → str BytesToBase64(data []byte) (string, error) ChatXdkUtilities.bytesToBase64(byte[]) ChatXdkUtilities.BytesToBase64(ReadOnlySpan<byte>)
43 base64_to_bytes (&str) → Option<Vec<u8>> base64ToBytes(string) → Uint8Array? base64_to_bytes(str) → bytes? Base64ToBytes(b64 string) ([]byte, error) ChatXdkUtilities.base64ToBytes(String) ChatXdkUtilities.Base64ToBytes(string)
44 bytes_to_hex (&[u8]) → String bytesToHex(Uint8Array) → string bytes_to_hex(bytes) → str BytesToHex(data []byte) (string, error) ChatXdkUtilities.bytesToHex(byte[]) ChatXdkUtilities.BytesToHex(ReadOnlySpan<byte>)
45 hex_to_bytes (&str) → Option<Vec<u8>> hexToBytes(string) → Uint8Array? hex_to_bytes(str) → bytes? HexToBytes(hex string) ([]byte, error) ChatXdkUtilities.hexToBytes(String) ChatXdkUtilities.HexToBytes(string)
46 detect_mime_type (&[u8]) → Option<&str> detectMimeType(Uint8Array) → string? detect_mime_type(bytes) → str? DetectMimeType(data []byte) (mime string, err error) — empty string if unknown ChatXdkUtilities.detectMimeType(byte[]) → String or null ChatXdkUtilities.DetectMimeType(ReadOnlySpan<byte>) → string?
47 detect_image_dimensions (&[u8]) → Option<ImageDimensions> detectImageDimensions(Uint8Array) → {width, height}? detect_image_dimensions(bytes) → (w, h)? DetectImageDimensions(data []byte) (*ImageDimensions, error) — nil if unknown ChatXdkUtilities.detectImageDimensions(byte[]) → ImageDimensions or null ChatXdkUtilities.DetectImageDimensions(ReadOnlySpan<byte>) → ImageDimensions?
48 encrypted_file_size (plaintext_size: u64) → Option<u64> encryptedFileSize(plaintextSize: number) → number encrypted_file_size(plaintext_size: int) → int EncryptedFileSize(plaintextSize uint64) (uint64, error) ChatXdkUtilities.encryptedFileSize(long) → long ChatXdkUtilities.EncryptedFileSize(ulong) → ulong

encrypted_file_size returns the exact byte length that encrypt_file or file_encryptor will produce, without encrypting or requiring a key: 24 + plaintext_size + 17 × ceil(plaintext_size / 1024) for non-empty input, and 24 for empty input. Use it for the upload's total_bytes, which the upload needs before the file is encrypted.

Failure modes differ by binding. On undecodable input, base64_to_bytes / hex_to_bytes return None (Rust Option), undefined (JS), and None (Python), while Go returns a non-nil error and .NET/JVM throw ChatXdkException ("Invalid base64" / "Invalid hex"). For an unrecognized file type, detect_mime_type returns None/undefined/null in Rust/JS/Python/.NET/JVM, but Go returns ("", nil) — an empty string with no error. Match on the failure convention of the binding you are using when porting code.

// JS/TS example
import { bytesToBase64, detectMimeType, detectImageDimensions } from '@xdevplatform/chat-xdk';

const b64 = bytesToBase64(new Uint8Array([1, 2, 3]));  // "AQID"
const mime = detectMimeType(imageBytes);               // "image/png"
const dims = detectImageDimensions(imageBytes);        // { width: 1024, height: 768 }
# Python example
from chat_xdk import bytes_to_base64, detect_mime_type, detect_image_dimensions

b64 = bytes_to_base64(b"\x01\x02\x03")  # "AQID"
mime = detect_mime_type(image_bytes)     # "image/png"
dims = detect_image_dimensions(image_bytes)  # (1024, 768)
// Go example (package chatxdk)
import "github.com/xdevplatform/chat-xdk/go/chatxdk"

b64, _ := chatxdk.BytesToBase64([]byte{1, 2, 3}) // "AQID"
mime, _ := chatxdk.DetectMimeType(imageBytes)   // "image/png" or "" if unknown
dims, _ := chatxdk.DetectImageDimensions(imageBytes) // non-nil *ImageDimensions or nil
// JVM — static helpers on ChatXdkUtilities
import com.x.chatxdk.ChatXdkUtilities;
import com.x.chatxdk.Types.ImageDimensions;

String b64 = ChatXdkUtilities.bytesToBase64(new byte[] { 1, 2, 3 }); // "AQID"
String mime = ChatXdkUtilities.detectMimeType(imageBytes);           // "image/png" or null
ImageDimensions dims = ChatXdkUtilities.detectImageDimensions(imageBytes);
// .NET — static helpers on ChatXdkUtilities
using ChatXdk;

string b64 = ChatXdkUtilities.BytesToBase64(new byte[] { 1, 2, 3 }); // "AQID"
string? mime = ChatXdkUtilities.DetectMimeType(imageBytes);          // "image/png" or null
ImageDimensions? dims = ChatXdkUtilities.DetectImageDimensions(imageBytes);

Supported MIME types: PNG, JPEG, GIF, WebP, BMP, TIFF, ICO, HEIC, AVIF, MP4, WebM, AVI, MOV, PDF, ZIP, MP3, WAV, OGG, FLAC.

Supported image dimensions: PNG, JPEG, GIF, WebP, BMP.

Errors

Every failure has a kind, which the SDK core assigns and every binding reports with the same name in its own idiom:

Kind Rust ErrorKind Python exception JS ChatXdkError.kind Go chatxdk.ErrorKind .NET ChatXdkErrorKind / JVM ChatXdkException.Kind C ABI kind
A parameter is missing or invalid; the message names it InvalidInput InvalidInputError invalidInput ErrorKindInvalidInput InvalidInput / INVALID_INPUT invalid_input
A value was neither passed nor available from the chat's state, such as no keys loaded, no session identity or no Juicebox config InvalidState InvalidStateError invalidState ErrorKindInvalidState InvalidState / INVALID_STATE invalid_state
Event or other wire data is malformed Parse ParseError parse ErrorKindParse Parse / PARSE parse
Ciphertext didn't decrypt, or no key for it was available Decryption DecryptionError decryption ErrorKindDecryption Decryption / DECRYPTION decryption
A signature didn't verify, or couldn't be verified while unverified events are rejected Verification VerificationError verification ErrorKindVerification Verification / VERIFICATION verification
Juicebox rejected the PIN InvalidPin InvalidPinError invalidPin ErrorKindInvalidPin InvalidPin / INVALID_PIN invalid_pin
Another Juicebox key-storage failure Juicebox JuiceboxError juicebox ErrorKindJuicebox Juicebox / JUICEBOX juicebox
A failure inside the SDK Internal ChatXdkError internal ErrorKindInternal Internal / INTERNAL internal

Rust returns an SdkError, whose kind() and guesses_remaining() report the kind and, after a wrong PIN, the attempts left. Python raises the exception of the kind, a ChatXdkError (itself a ValueError) with kind and guesses_remaining; a missing argument or one of the wrong type raises TypeError. JS throws a ChatXdkError (an Error) with kind and guessesRemaining. Go returns a *chatxdk.Error, with Kind, Message and GuessesRemaining, matched with errors.As. .NET and the JVM throw ChatXdkException, with Kind / getKind() and GuessesRemaining / getGuessesRemaining(). The C ABI's FfiResult.error is a JSON document, {"kind": "...", "message": "..."}, with "guesses_remaining" after a wrong PIN. The message is the same in every binding, apart from the JS params check, which prefixes Invalid <method> params:. decrypt_event reports a member removal's title or description re-encryption, which decrypt drops, as InvalidInput.


Quick Start

JavaScript/WASM

First boot — juicebox_config is created by POST /2/users/{id}/public_keys, so a brand-new user creates the chat without one:

import { createChat } from '@xdevplatform/chat-xdk';

const chat = await createChat({
  getAuthToken: async (realmId) => await backend.getToken(realmId),
});
const payload = chat.generateKeypairs();
// … POST payload to /2/users/{id}/public_keys, GET juicebox_config back …
chat.updateConfig(configJson);
await chat.setup("2580");

Later sessions — the account is provisioned:

import { createChat } from '@xdevplatform/chat-xdk';

const chat = await createChat({
  juiceboxConfig: configJson,
  getAuthToken: async (realmId) => await backend.getToken(realmId),
});
await chat.unlock("2580");

// ── Set the session once ──
// Identity (sender + signing-key version), signing keys for all
// participants, and the opt-in conversation-key cache.
chat.setIdentity({ userId: myUserId, signingKeyVersion: mySigningKeyVersion });
chat.setCacheKeys(true);
chat.setSigningKeys([
  { userId: "111", publicKeyVersion: "1", publicKey: "BASE64...", identityPublicKey: "BASE64...", identityPublicKeySignature: "BASE64..." },
  { userId: "222", publicKeyVersion: "1", publicKey: "BASE64...", identityPublicKey: "BASE64...", identityPublicKeySignature: "BASE64..." },
]);

// ── Initial load: use decryptEvents ──
// The SDK extracts conversation keys, matches signing keys automatically,
// and (with the cache on) retains each conversation's latest verified key.
const result = chat.decryptEvents({ events: rawEvents });

for (const dm of result.events) {
  if (dm.event.type === "message") {
    console.log(dm.event.senderId, dm.event.content.text);
  }
}

// ── New event arrives (webhook / poll): use decryptEvent ──
// The empty key map and unset signing keys resolve from the key cache and
// the signing-key store.
const event = chat.decryptEvent({ eventB64: webhookEventB64 });
if (event.type === "message") {
  console.log(event.content.text);
}

// ── Send: identity and conversation key resolve from the session ──
const payload = chat.encryptMessage({ conversationId: event.conversationId, text: "hi!" });
const reply = chat.encryptReply({
  conversationId: event.conversationId,
  text: "pong",
  replyToEvent: webhookEventB64, // preview derived + embedded for validation
});
const reaction = chat.encryptAddReaction({ emoji: "👍", targetEvent: webhookEventB64 });

// ── Metadata (group name, description) ──
const groupName = chat.decrypt(conversation.groupName, convKey);

// ── Files (whole file): the message's event, its key map, the media hash key ──
const keys = result.conversationKeys.keys;
const mediaHashKey = event.mediaHashes[0].mediaHashKey;
const decrypted: Uint8Array = chat.decryptFile({
  encrypted: encryptedBytes,
  eventB64: webhookEventB64,
  conversationKeys: keys,
  mediaHashKey,
});

// ── Files (incremental, for large files) ──
// Decrypt chunk-by-chunk as bytes arrive, e.g. from a fetch() body stream,
// without holding the whole file in memory.
const dec = chat.fileDecryptor({ eventB64: webhookEventB64, conversationKeys: keys, mediaHashKey });
const reader = response.body.getReader();
const parts: Uint8Array[] = [];
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  parts.push(dec.push(value));
}
// finish() consumes and frees the decryptor (do not call free() after it);
// it throws if the file was truncated.
parts.push(dec.finish());

// ── Send a file: build the message first, then encrypt the file from it ──
// newMediaHashKey is the media_hash_key that POST /2/chat/media/upload/initialize
// returns for { conversation_id, total_bytes: encryptedFileSize(photo.length) }.
const photoMessage = chat.encryptMessage({
  conversationId: event.conversationId,
  text: "a photo",
  attachments: [{ attachment_type: "media", media_hash_key: newMediaHashKey, width, height, filesize_bytes: encryptedFileSize(photo.length), filename: "photo.jpg", media_type: 1 }],
});
const encryptedPhoto = chat.encryptFile({
  file: photo,
  eventB64: photoMessage.eventB64,
  mediaHashKey: newMediaHashKey,
});
// … upload encryptedPhoto (POST /2/chat/media/upload/{session_id}/append, then
// /finalize), then send photoMessage (POST /2/chat/conversations/{id}/messages) …

Python

from chat_xdk import (
    Chat,
    DecryptEventParams,
    DecryptEventsParams,
    DecryptFileParams,
    EncryptFileParams,
    EncryptMessageParams,
    EncryptReactionParams,
    EncryptReplyParams,
    SetIdentityParams,
    encrypted_file_size,
)

chat = Chat(config_json)
chat.unlock("2580")

# ── Set the session once ──
chat.set_identity(SetIdentityParams(my_user_id, my_signing_key_version))
chat.set_cache_keys(True)
chat.set_signing_keys(signing_keys)  # all participants

# ── Initial load: use decrypt_events ──
result = chat.decrypt_events(DecryptEventsParams(raw_events))
for dm in result["events"]:
    if dm["event"]["type"] == "Message":
        print(dm["event"]["sender_id"], dm["event"]["content"]["text"])

# ── New event arrives: use decrypt_event ──
# The empty key map and unset signing keys resolve from the key cache and
# the signing-key store.
event = chat.decrypt_event(DecryptEventParams(event_b64))
if event["type"] == "Message":
    print(event["content"]["text"])

# ── Send: identity and conversation key resolve from the session ──
payload = chat.encrypt_message(EncryptMessageParams(event["conversation_id"], "hi!"))
reply = chat.encrypt_reply(EncryptReplyParams(event["conversation_id"], "pong", event_b64))
reaction = chat.encrypt_add_reaction(EncryptReactionParams(event_b64, "👍"))

# ── Metadata (group name, description) ──
group_name = chat.decrypt(conversation["group_name"], conv_key)

# ── Files: the message's event, its key map, the media hash key ──
keys = result["conversation_keys"]["keys"]
media_hash_key = event["media_hashes"][0]["media_hash_key"]
decrypted = chat.decrypt_file(DecryptFileParams(encrypted_bytes, event_b64, keys, media_hash_key))

# ── Send a file: build the message first, then encrypt the file from it ──
# new_media_hash_key is the media_hash_key that POST /2/chat/media/upload/initialize
# returns for {conversation_id, total_bytes: encrypted_file_size(len(photo))}.
photo_message = chat.encrypt_message(
    EncryptMessageParams(
        event["conversation_id"],
        "a photo",
        attachments=[{
            "attachment_type": "media",
            "media_hash_key": new_media_hash_key,
            "width": width,
            "height": height,
            "filesize_bytes": encrypted_file_size(len(photo)),
            "filename": "photo.jpg",
            "media_type": 1,
        }],
    )
)
encrypted_photo = chat.encrypt_file(
    EncryptFileParams(photo, photo_message.event_b64, new_media_hash_key)
)
# … upload encrypted_photo (POST /2/chat/media/upload/{session_id}/append, then
# /finalize), then send photo_message (POST /2/chat/conversations/{id}/messages) …

Go

import (
    "fmt"

    "github.com/xdevplatform/chat-xdk/go/chatxdk"
)

func main() {
    chat := chatxdk.New()
    defer chat.Close()

    _ = chat.Unlock([]byte("2580"), juiceboxConfigJSON)

    // ── Set the session once ──
    chat.SetIdentity(chatxdk.SetIdentityParams{UserID: myUserID, SigningKeyVersion: mySigningKeyVersion})
    chat.SetCacheKeys(true)
    _ = chat.SetSigningKeys(signingKeys) // all participants

    // ── Initial load: use DecryptEvents (unset SigningKeys = stored signing keys) ──
    result, err := chat.DecryptEvents(chatxdk.DecryptEventsParams{Events: rawEvents})
    if err != nil {
        panic(err)
    }
    for _, dm := range result.Events {
        if dm.Event.Type == "Message" {
            fmt.Println(dm.Event.AsMessage().Text())
        }
    }

    // ── New event arrives: use DecryptEvent ──
    // The empty key map and unset signing keys resolve from the key cache
    // and the signing-key store.
    event, err := chat.DecryptEvent(chatxdk.DecryptEventParams{EventB64: eventB64})
    if err != nil {
        panic(err)
    }
    msg := event.AsMessage() // nil unless event.Type == "Message"
    if msg != nil {
        fmt.Println(msg.Text())
    }

    // ── Send: identity and conversation key resolve from the session ──
    payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
        ConversationID: *msg.ConversationID,
        Text:           "hi!",
    })
    if err != nil {
        panic(err)
    }
    _ = payload
    reply, _ := chat.EncryptReply(chatxdk.EncryptReplyParams{
        ConversationID: *msg.ConversationID,
        Text:           "pong",
        ReplyToEvent:   eventB64, // preview derived + embedded for validation
    })
    _ = reply
    reaction, _ := chat.EncryptAddReaction(chatxdk.EncryptReactionParams{
        Emoji:       "👍",
        TargetEvent: eventB64,
    })
    _ = reaction

    // Raw 32-byte conversation key for the metadata call below,
    // e.g. from DecryptConversationKey or result.ConversationKeys.Keys.
    convKey := result.ConversationKeys.Keys[keyVersion]

    // ── Metadata (group name, description) ──
    groupName, err := chat.Decrypt(conversationGroupNameB64, convKey)
    _ = groupName

    // ── Files: the message's event, its key map, the media hash key ──
    // Raw bytes in, raw bytes out.
    decrypted, err := chat.DecryptFile(chatxdk.DecryptFileParams{
        Encrypted:        encryptedMedia,
        EventB64:         eventB64,
        ConversationKeys: result.ConversationKeys.Keys,
        MediaHashKey:     msg.MediaHashes[0].MediaHashKey,
    })
    _ = decrypted

    // ── Send a file: build the message first, then encrypt the file from it ──
    // newMediaHashKey is the media_hash_key that POST /2/chat/media/upload/initialize
    // returns for {conversation_id, total_bytes: size}.
    size, _ := chatxdk.EncryptedFileSize(uint64(len(photo)))
    mediaType := int32(1)
    photoMessage, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
        ConversationID: *msg.ConversationID,
        Text:           "a photo",
        Attachments: []chatxdk.AttachmentDescriptor{{
            AttachmentType: "media",
            MediaHashKey:   newMediaHashKey,
            Width:          width,
            Height:         height,
            FilesizeBytes:  int64(size),
            Filename:       "photo.jpg",
            MediaType:      &mediaType,
        }},
    })
    if err != nil {
        panic(err)
    }
    encryptedPhoto, err := chat.EncryptFile(chatxdk.EncryptFileParams{
        File:         photo,
        EventB64:     photoMessage.EventB64,
        MediaHashKey: newMediaHashKey,
    })
    // … upload encryptedPhoto (POST /2/chat/media/upload/{session_id}/append, then
    // /finalize), then send photoMessage (POST /2/chat/conversations/{id}/messages) …
    _ = encryptedPhoto
}

Rust

use std::collections::HashMap;

use chat_xdk_core::keys::juicebox::JuiceboxConfig;
use chat_xdk_core::{
    AttachmentDescriptor, Chat, DecryptEventParams, DecryptEventsParams, DecryptFileParams,
    EncryptFileParams, EncryptMessageParams, EncryptReactionParams, EncryptReplyParams, Event,
};

let config = JuiceboxConfig::from_x_api_json(&config_json)?;
let chat = Chat::new(config);
chat.unlock(b"2580").await?;

// ── Set the session once ──
chat.set_identity(&SetIdentityParams::new(my_user_id, my_signing_key_version))?;
chat.set_cache_keys(true);
chat.set_signing_keys(&signing_keys); // all participants

// ── Initial load: use decrypt_events (unset signing keys = stored signing keys) ──
let result = chat.decrypt_events(&DecryptEventsParams::new(raw_events));
for dm in &result.events {
    if let Event::Message(msg) = &dm.event {
        println!("{}: {}", msg.meta.sender_id.as_deref().unwrap_or("?"), msg.text().unwrap_or(""));
    }
}

// ── New event arrives: use decrypt_event ──
// The empty key map and unset signing keys resolve from the key cache and
// the signing-key store.
let event = chat.decrypt_event(&DecryptEventParams::new(event_b64, HashMap::new()))?;

// ── Send: identity and conversation key resolve from the session ──
let payload = chat.encrypt_message(&EncryptMessageParams::new(conversation_id, "hi!"))?;
let reply = chat.encrypt_reply(&EncryptReplyParams::new(conversation_id, "pong", event_b64))?;
let reaction = chat.encrypt_add_reaction(&EncryptReactionParams::new(event_b64, "👍"))?;

// ── Metadata (group name, description) ──
let group_name = chat.decrypt(&conversation.group_name, &conv_key)?;

// ── Files: the message's event, its key map, the media hash key ──
// (`media_hash_key` from the decrypted message's `media_hashes`)
let keys = result.conversation_keys.keys.clone();
let decrypted = chat.decrypt_file(&DecryptFileParams::new(
    encrypted_bytes,
    event_b64,
    keys.clone(),
    media_hash_key,
))?;

// ── Send a file: build the message first, then encrypt the file from it ──
// `new_media_hash_key` is the `media_hash_key` that
// `POST /2/chat/media/upload/initialize` returns for
// `{conversation_id, total_bytes: size}`.
let size = chat_xdk_core::utils::encrypted_file_size(photo.len() as u64).unwrap();
let params = EncryptMessageParams::new(conversation_id, "a photo").with_attachments(vec![
    AttachmentDescriptor::Media {
        media_hash_key: new_media_hash_key.clone(),
        width,
        height,
        filesize_bytes: size as i64,
        filename: "photo.jpg".to_string(),
        media_type: Some(1),
        duration_millis: None,
    },
]);
let photo_message = chat.encrypt_message(&params)?;
let encrypted_photo = chat.encrypt_file(&EncryptFileParams::new(
    photo,
    &photo_message.event_b64,
    new_media_hash_key,
))?;
// … upload encrypted_photo (POST /2/chat/media/upload/{session_id}/append, then
// /finalize), then send photo_message (POST /2/chat/conversations/{id}/messages) …

.NET (C#)

Decrypted events are returned as System.Text.Json.JsonElement (snake_case property names in the JSON). Use GetProperty / GetString as in the examples below, or deserialize into your own models.

using ChatXdk;

using var chat = new Chat();
chat.UpdateConfig(juiceboxConfigJson);
chat.Unlock(pin, juiceboxConfigJson);

// Set the session once: identity, opt-in key cache, signing-key store.
chat.SetIdentity(new SetIdentityParams(myUserId, mySigningKeyVersion));
chat.SetCacheKeys(true);
chat.SetSigningKeys(signingKeys); // all participants

// Initial load — unset signing keys come from the store.
var result = chat.DecryptEvents(new DecryptEventsParams(rawEvents));
foreach (var dm in result.Events)
{
    if (dm.Event.GetProperty("type").GetString() == "Message")
        Console.WriteLine(dm.Event.GetProperty("content").GetProperty("text").GetString());
}

// Single event — the empty key map and unset signing keys resolve from the session stores.
var evt = chat.DecryptEvent(new DecryptEventParams(webhookB64, new Dictionary<string, byte[]>()));
var conversationId = evt.GetProperty("conversation_id").GetString()!;

// Send: identity and conversation key resolve from the session.
var payload = chat.EncryptMessage(new EncryptMessageParams(conversationId, "hi!"));
// Preview derived from + embedded raw event for validation.
var reply = chat.EncryptReply(new EncryptReplyParams(conversationId, "pong", webhookB64));
var reaction = chat.EncryptAddReaction(new EncryptReactionParams(webhookB64, "👍"));

var groupName = chat.Decrypt(conversationGroupNameB64, convKeyBytes);

// Files: the message's event, its key map, the media hash key.
var keys = result.ConversationKeys.Keys;
var mediaHashKey = evt.GetProperty("media_hashes")[0].GetProperty("media_hash_key").GetString()!;
var mediaPlain = chat.DecryptFile(
    new DecryptFileParams(encryptedMediaBytes, webhookB64, keys, mediaHashKey));

// Send a file: build the message first, then encrypt the file from it.
// newMediaHashKey is the media_hash_key that POST /2/chat/media/upload/initialize
// returns for { conversation_id, total_bytes = size }.
var size = ChatXdkUtilities.EncryptedFileSize((ulong)photo.Length);
var photoMessage = chat.EncryptMessage(new EncryptMessageParams(conversationId, "a photo")
{
    Attachments = new[]
    {
        AttachmentDescriptor.Media(newMediaHashKey, width, height, (long)size, "photo.jpg", 1),
    },
});
var encryptedPhoto = chat.EncryptFile(
    new EncryptFileParams(photo, photoMessage.EventB64, newMediaHashKey));
// … upload encryptedPhoto (POST /2/chat/media/upload/{session_id}/append, then
// /finalize), then send photoMessage (POST /2/chat/conversations/{id}/messages) …
make dotnet-build
make dotnet-test

Stateless helpers: ChatXdkUtilities.BytesToBase64, DetectMimeType, etc. (crates/dotnet/README.md).

JVM

com.x.chatxdk — same native chat_xdk_ffi cdylib as .NET, via JNA. Use try-with-resources on Chat (implements AutoCloseable). Stateless helpers live on ChatXdkUtilities.

import com.fasterxml.jackson.databind.JsonNode;
import com.x.chatxdk.*;
import com.x.chatxdk.Types.*;

import java.util.List;
import java.util.Map;

try (Chat chat = new Chat()) {
    chat.unlock("2580", juiceboxConfigJson);

    // Set the session once: identity, opt-in key cache, signing-key store.
    chat.setIdentity(new SetIdentityParams(myUserId, mySigningKeyVersion));
    chat.setCacheKeys(true);
    chat.setSigningKeys(signingKeys); // all participants

    // Initial load — unset (null) signing keys fall back to the store
    DecryptEventsResult batch = chat.decryptEvents(new DecryptEventsParams(rawEvents));
    for (DecryptedEvent dm : batch.events) {
        JsonNode evt = dm.event;
        if (evt != null && "Message".equals(evt.path("type").asText())) {
            String text = evt.path("content").path("text").asText();
            // …
        }
    }

    // Single event — the empty key map and unset signing keys resolve from the session stores
    JsonNode event = chat.decryptEvent(new DecryptEventParams(eventB64, Map.of()));
    String conversationId = event.path("conversation_id").asText();

    // Send: identity and conversation key resolve from the session
    SendPayload payload =
            chat.encryptMessage(new EncryptMessageParams(conversationId, "hi!"));
    // Preview derived from + embedded raw event for validation.
    SendPayload reply =
            chat.encryptReply(new EncryptReplyParams(conversationId, "pong", eventB64));
    SendPayload reaction =
            chat.encryptAddReaction(new EncryptReactionParams(eventB64, "👍"));

    // Files: the message's event, its key map, the media hash key
    Map<String, byte[]> keys = batch.conversationKeys.keys;
    String mediaHashKey = event.path("media_hashes").path(0).path("media_hash_key").asText();
    byte[] media = chat.decryptFile(
            new DecryptFileParams(encryptedMediaBytes, eventB64, keys, mediaHashKey));

    // Send a file: build the message first, then encrypt the file from it.
    // newMediaHashKey is the media_hash_key that POST /2/chat/media/upload/initialize
    // returns for {conversation_id, total_bytes: size}.
    long size = ChatXdkUtilities.encryptedFileSize(photo.length);
    EncryptMessageParams photoParams = new EncryptMessageParams(conversationId, "a photo");
    photoParams.attachments = List.of(
            AttachmentDescriptor.media(newMediaHashKey, width, height, size, "photo.jpg", 1, null));
    SendPayload photoMessage = chat.encryptMessage(photoParams);
    byte[] encryptedPhoto = chat.encryptFile(
            new EncryptFileParams(photo, photoMessage.eventB64, newMediaHashKey));
    // … upload encryptedPhoto (POST /2/chat/media/upload/{session_id}/append, then
    // /finalize), then send photoMessage (POST /2/chat/conversations/{id}/messages) …
}
# From repo root (see crates/jvm/README.md)
make jvm-test

Types

PublicKeys

Returned by setup() and get_public_keys().

interface PublicKeys {
  identity: string;  // Base64-encoded identity public key (P-256 SEC1)
  signing: string;   // Base64-encoded signing public key (P-256 SEC1)
}
# Python — same fields: .identity, .signing

PublicKeyRegistrationPayload

Returned by generate_keypairs(). POST this to the X API, then call set_identity with the public_key_version the response returns.

// JS/WASM — camelCase. Non-JS bindings (Python/Go/.NET/JVM) use the
// snake_case wire names (public_key, identity_public_key_signature, …).
interface PublicKeyRegistrationPayload {
  publicKey: {
    identityPublicKeySignature: string;   // Raw r||s ECDSA signature
    publicKey: string;                    // Base64 SPKI-encoded identity key
    publicKeyFingerprint?: string;        // URL-safe base64 SHA-256 of SPKI key
    registrationMethod: string;           // "CustomPin"
    signingPublicKey: string;             // Base64 SPKI-encoded signing key
    signingPublicKeySignature?: string;
  };
}

The publicKeyFingerprint (public_key_fingerprint on non-JS bindings) is URL_SAFE_NO_PAD(SHA-256(SPKI_bytes)) — a 43-character string users can compare out-of-band to verify key authenticity. Also available via get_public_key_fingerprint() at any time after keys are loaded.

Event

Returned by decrypt_event() and decrypt_events(). Uses type field to distinguish variants.

Casing differs by platform:

  • JS/WASM: camelCase types and fields — "message", senderId
  • Python: PascalCase types, snake_case fields — "Message", sender_id
  • .NET (JsonElement from DecryptEvent / DecryptEvents): same JSON as Go/Rust FFI — PascalCase type strings ("Message", "KeyChange", …) and snake_case field names (sender_id, conversation_id, …).
  • JVM: Native JSON from decryptEvent / DecryptEventsResult uses snake_case fields (e.g. "Message", sender_id) — inspect with Jackson JsonNode or bind to POJOs with @JsonProperty if you prefer camelCase in your code.

⚠️ JS is the only binding that camelCases enum discriminator values, not just field names: type: 'message' | 'keyChange' | …, contentType: 'text' | 'reactionRemoved' | …, attachmentType: 'unifiedCard', failure 'internalError'. Every other binding uses PascalCase values ("Message", "KeyChange", "Text", "UnifiedCard", "InternalError"). Porting warning: code that switches on these strings must be re-cased when moved to or from JS — a comparison like event.type === "Message" silently never matches in JS.

// JS/WASM — all camelCase
interface Event {
  type: 'message' | 'keyChange' | 'groupChange' |
        'messageDeleted' | 'conversationDeleted' |
        'typing' | 'readReceipt' | 'markedUnread' |
        'failure' | 'settingsChange' | 'memberDeleted' | 'unknown';
  // Meta fields are flattened to top level:
  sequenceId?: string;
  id?: string;
  senderId?: string;
  conversationId?: string;
  createdAtMsec?: number;
  // Additional fields depend on type (see below)
}
# Python — PascalCase types, snake_case fields
event["type"]            # "Message", "KeyChange", etc.
event["sequence_id"]     # str
event["sender_id"]       # str
event["conversation_id"] # str
event["created_at_msec"] # int

Message

The payload when type is "message" (JS) / "Message" (Python).

// JS/WASM
interface MessageEvent {
  type: 'message';
  sequenceId?: string;
  id?: string;
  senderId?: string;
  conversationId?: string;
  createdAtMsec?: number;
  content: MessageContent;
  conversationKeyVersion?: string; // null for unencrypted messages
  verified: boolean;            // always false for unencrypted messages
  shouldNotify?: boolean;
  ttlMsec?: number;
  attachments?: AttachmentInfo[];
  mediaHashes?: MediaHashReference[];
  replyPreviewValidation?: 'valid' | 'invalid';  // absent when no preview /
                                                 // preview embeds no raw event
}
# Python
event["content"]       # dict
event["conversation_key_version"]   # str or None
event["verified"]      # bool
event["attachments"]   # list[dict]
event["media_hashes"]  # list[dict]
event["reply_preview_validation"]  # "Valid" or "Invalid"; absent when no
                                   # preview / preview embeds no raw event

Reply preview validation: when a decrypted message carries a reply preview that embeds the raw signed original event, the SDK verifies that event's signature, decrypts it, and compares the preview's claims against the original; the outcome is reported in reply_preview_validation. The field's values are "Valid"/"Invalid" in Rust/Python/Go/.NET/JVM; the JS type layer camelCases them to 'valid'/'invalid'. It is absent when the message carries no preview or the preview embeds no raw event. Treat Invalid (and absent-on-a-preview) as an untrusted preview — render the claim only from the validated original. The validation contract is specified in CRYPTO.md — Reply preview validation.

Unencrypted messages: When conversationKeyVersion is null, the message was delivered without encryption. Because there is no signature to verify, the default reject-unverified policy rejects these messages (decryptEvent throws; decryptEvents records them in errors); they are returned only after setRejectUnverified(false), with verified: false. When returned, the content is parsed identically to encrypted messages.

MessageContent

The content field of a Message. Uses contentType (JS) / content_type (Python) for discrimination. The JS typings declare MessageContent as one permissive interface with these fields optional; the shapes below show which fields each contentType carries.

// JS/WASM
type MessageContent =
  | { contentType: 'text';
      text: string;
      entities?: unknown[];
      attachments?: unknown[];
      replyingToPreview?: unknown;
      forwardedMessage?: unknown;
      sentFrom?: string | number;
      quickReply?: QuickReply;
      ctas?: unknown[];
    }
  | { contentType: 'reaction'; emoji: string; targetMessageSequenceId: string }
  | { contentType: 'reactionRemoved'; emoji: string; targetMessageSequenceId: string }
  | { contentType: 'edit'; targetMessageSequenceId: string; targetMessageId?: string; updatedText: string; entities?: RichTextEntity[] }
  | { contentType: 'markRead' }
  | { contentType: 'markUnread' }
  | { contentType: 'unknown'; typeId?: number };

Reactions and edits name the message they target by its sequenceId, as targetMessageSequenceId (target_message_sequence_id in the other bindings), the name EncryptReactionParams and EncryptEditParams take. A v1 edit also carries the signed message id of the message it edits as targetMessageId (target_message_id), the id apps deduplicate and anchor on; a v0 edit doesn't carry it. An edit's replacement text is updatedText (updated_text), as EncryptEditParams names it.

Rust's MessageContent is #[non_exhaustive], and so is each variant with named fields: give a match a wildcard arm and end a variant's pattern in .., as in MessageContent::Edit { updated_text, .. }.

SendPayload

Returned by encrypt_message() and all encrypt_*() methods. The fields the send request takes carry its names: send message_id, encoded_message_create_event, encoded_message_event_signature and xdk_version as they are (JS returns them camelCased, as messageId, encodedMessageCreateEvent, encodedMessageEventSignature and xdkVersion).

The SDK generates the message_id (a UUID) and embeds it in the signed event: derived from its files for a v1 message with files (see Message Encryption), random otherwise. Send it as the message's message_id, keep it to dedup and to anchor replies, and reuse the same encrypted payload on retries so a message is never sent twice under two ids.

event_b64 is the outgoing message as a base64 event — what recipients receive, without the fields the server adds on delivery. Pass it to encrypt_file / file_encryptor to encrypt the message's files. It is not a field of the send request.

xdk_version is the version of the SDK that built the payload.

// Rust
pub struct SendPayload {
    pub message_id: String,              // UUID, also embedded in the signed event
    pub event_b64: String,               // the outgoing message as a base64 event
    pub encoded_message_create_event: String,    // Base64-encoded Thrift MessageCreateEvent
    pub signature: String,                       // Base64-encoded ECDSA signature
    pub encoded_message_event_signature: String, // Base64-encoded Thrift MessageEventSignature
    pub signature_info: SignatureInfo,   // { public_key_version, signature_version: "7" (v0) or "10" (v1) }
    pub conversation_key_version: String,
    pub should_notify: bool,
    pub xdk_version: String,             // SDK version
}

// Python (SendPayload class attributes)
// message_id: str
// event_b64: str
// encoded_message_create_event: str
// signature: str
// encoded_message_event_signature: str
// signature_info: SignatureInfo
// conversation_key_version: str
// should_notify: bool
// xdk_version: str

EntityDescriptor (Rust, .NET, JVM) / EntityTuple (Go, JS) / tuple (Python)

Describes a rich-text entity to embed in a message. start and end are offsets into the text the entity is in (a message's text, or an edit's updated_text) counted in bytes of its UTF-8 encoding, start inclusive and end exclusive, so 0 <= start <= end <= the text's length in UTF-8 bytes. Offsets outside that range fail the call with an invalid-input error naming them, in every binding: Invalid entity offsets 3..8: end is past the end of the text, which is 7 bytes of UTF-8. A reply preview's reply_to_entities index the quoted message's text, which the preview may carry only the start of, so only their start and order are checked. Rust and Go string indices are UTF-8 byte offsets already; from a string index i, Python takes len(text[:i].encode()), JS new TextEncoder().encode(text.slice(0, i)).length, .NET Encoding.UTF8.GetByteCount(text[..i]) and the JVM text.substring(0, i).getBytes(StandardCharsets.UTF_8).length. Rust, .NET, and JVM expose an EntityDescriptor struct/class, built from its start, end and type (EntityDescriptor::new, and the .NET and JVM constructors); Go and JS use an EntityTuple ([start, end, type]); Python takes plain (start, end, entity_type) tuples.

// Rust
pub struct EntityDescriptor {
    pub start: i32,          // First byte of the entity in the UTF-8 text (inclusive)
    pub end: i32,            // Byte after the entity's last one (exclusive)
    pub entity_type: String, // One of the types below, e.g. "url", "mention"
}
// Rust: build one with new(start, end, entity_type)
let entities = vec![EntityDescriptor::new(0, 23, "url"), EntityDescriptor::new(25, 30, "mention")];
// JS: EntityTuple = [start, end, type]
const entities: EntityTuple[] = [[0, 23, 'url'], [25, 30, 'mention']];
# Python: represented as a tuple (start: int, end: int, entity_type: str)
# Example: [(0, 23, "url"), (25, 30, "mention")]

Supported entity types:

Type Description
"url" A URL in the message text
"mention" An @mention in the message text
"hashtag" A #hashtag in the message text
"cashtag" A $cashtag in the message text
"email" An email address in the message text
"address" A physical address in the message text
"phone_number" A phone number in the message text (the camelCase spelling "phoneNumber" is accepted too)

Both the "phone_number" and "phoneNumber" spellings are accepted by every binding (the conversion happens in core). Any other type, an empty one included, fails the call with an invalid-input error naming it, in every binding: Invalid entity_type 'user_mention': pass url, mention, hashtag, cashtag, email, address or phone_number.

AttachmentDescriptor (send side)

Describes an attachment to embed when encrypting a message (encrypt_message/encrypt_reply). Lists mixing attachment types are temporarily rejected on encrypt — only image/gif/video media may appear in multiples (up to 10); everything else must be the sole attachment (see the note under encrypt_message above). Unlike most types, the field names stay snake_case in every binding — they mirror the wire protocol, so the same object shape passes through JS, Python dicts, and the Go/JVM/.NET structs (which add their own idiomatic casing on the struct fields but serialize to the snake_case keys below).

type AttachmentDescriptor =
  | { attachment_type: 'media'; media_hash_key: string; width: number; height: number; filesize_bytes: number; filename: string; media_type?: number; duration_millis?: number }
  | { attachment_type: 'url'; url: string; display_title?: string; banner_image?: UrlAttachmentImageDescriptor; favicon_image?: UrlAttachmentImageDescriptor }
  | { attachment_type: 'post'; rest_id?: string; post_url?: string };

interface UrlAttachmentImageDescriptor {
  media_hash_key: string;   // the media hash key the image's upload started with
  filesize_bytes: number;   // encrypted size: encryptedFileSize(image size)
  filename: string;         // receivers key their decrypted-file cache on it
  width?: number;
  height?: number;
}

media_hash_key, filesize_bytes, and filename are all required: first-party clients share an ingest path that silently discards the whole preview image when any of the three is missing on the wire, so an optional field here would be an invisible no-banner failure for callers. For the same reason a send fails with an invalid-input error when a media attachment or a URL card image has an empty media_hash_key or filename, or a filesize_bytes of 0 or less, such as media_hash_key 'banner' has an empty filename: pass the file's original name. width/height are optional on an image and currently ignored by receiving clients (emitted for wire fidelity); a negative one, on an image or a media attachment, fails the same way. So do a url attachment with an empty url (a URL card's url is empty: pass the link the card shows) and a post attachment with neither rest_id nor post_url.

URL cards ("clickable images"). A url attachment with only a url renders as a bare link on some clients: clients only auto-fetch missing card details for their own messages (an IP-leak guard), so previews for received messages must be embedded by the sender. To make every client render a full clickable preview card, hydrate the card yourself:

  1. Fetch/render your preview image (e.g. the page's OpenGraph image).
  2. Start its upload to the conversation's media store (POST /2/chat/media/upload/initialize, with total_bytes set to encryptedFileSize of the image's size) and keep the returned media_hash_key.
  3. Reference it as banner_image (plus a display_title) on the url attachment, with the encrypted size as filesize_bytes and the image's filename, and encrypt the message.
  4. Encrypt the image with encryptFile/fileEncryptor from the payload's eventB64 and the image's media_hash_key, finish its upload (append → finalize), and send the message.

Receivers see the image hashes on the decrypted message (AttachmentInfo bannerImageMediaHashKey and media_hashes entries with source "url_banner"/"url_favicon"), download them, and pass the message's event, key map and image hash key to decryptFile like any media attachment. Keep banners small (a few hundred KB): clients honor data-usage thresholds when deciding whether to auto-load media.

Rust, .NET, and JVM expose typed variants/factories (AttachmentDescriptor::Url { … } with images built by UrlAttachmentImageDescriptor::new(media_hash_key, filesize_bytes, filename) and with_dimensions(width, height), AttachmentDescriptor.UrlCard(url, displayTitle, bannerImage, faviconImage) with images built by new UrlAttachmentImageDescriptor(mediaHashKey, filesizeBytes, filename) and an optional { Width, Height } initializer, Types.AttachmentDescriptor .urlCard(url, displayTitle, bannerImage, faviconImage) with images built by new UrlAttachmentImageDescriptor(mediaHashKey, filesizeBytes, filename) and settable width and height). A .NET or JVM AttachmentDescriptor comes only from its Media/media, UrlCard/urlCard and Post/post factories. Go uses the flat AttachmentDescriptor struct with BannerImage/FaviconImage pointer fields (URLAttachmentImageDescriptor); JS and Python pass the plain object/dict shape above.

QuickReply

The optional quick_reply of EncryptMessageParams / EncryptReplyParams, and the quick_reply (quickReply in JS) of decrypted text content. A request offers selectable options; a response reports the option the user picked. Every binding uses one typed shape for both directions, so a received value can be passed back to encrypt_message / encrypt_reply unchanged. Omitting it sends no quick reply. A value with neither request nor response, or with both, fails with an "Invalid quick reply" error.

Type Fields
QuickReply exactly one of request: QuickReplyRequest, response: QuickReplyResponse
QuickReplyRequest options: QuickReplyOptionsRequest
QuickReplyResponse options: QuickReplyOptionsResponse
QuickReplyOptionsRequest id?, options?: QuickReplyOption[]
QuickReplyOptionsResponse request_id?, metadata?, selected_option_id?
QuickReplyOption id?, label?, metadata?, description?

Each binding names these types the same way, in its own idiom:

Binding Shape Field casing
Rust QuickReply::Request { request } / QuickReply::Response { response } enum; the option structs build with new() and with_* (QuickReplyOption::new().with_id("yes").with_label("Yes")) snake_case
JS/WASM { request: QuickReplyRequest } | { response: QuickReplyResponse } union camelCase (requestId, selectedOptionId)
Python QuickReply TypedDict (and the nested TypedDicts), exported from chat_xdk snake_case keys
Go *chatxdk.QuickReply struct with Request / Response pointer fields; TextContent.QuickReply decodes into the same type Go field names, snake_case JSON
.NET QuickReply class with Request / Response properties; decrypted JSON deserializes with JsonSerializer.Deserialize<QuickReply> PascalCase properties, snake_case JSON
JVM Types.QuickReply with request / response fields and QuickReply.request(...) / QuickReply.response(...) factories; decrypted JSON converts with ObjectMapper.treeToValue(node, QuickReply.class) camelCase fields, snake_case JSON
from chat_xdk import EncryptMessageParams, QuickReply, QuickReplyOption

# Ask a question with two options...
chat.encrypt_message(EncryptMessageParams(conversation_id, "Continue?", quick_reply=QuickReply(
    request={"options": {"id": "qr-1", "options": [
        QuickReplyOption(id="yes", label="Yes", metadata="m-yes"),
        QuickReplyOption(id="no", label="No", metadata="m-no"),
    ]}},
)))

# ...and answer it.
chat.encrypt_message(EncryptMessageParams(conversation_id, "Yes", quick_reply=QuickReply(
    response={"options": {
        "request_id": "qr-1", "metadata": "m-yes", "selected_option_id": "yes",
    }},
)))
chat.encryptMessage({
  conversationId,
  text: 'Yes',
  quickReply: { response: { options: { requestId: 'qr-1', metadata: 'm-yes', selectedOptionId: 'yes' } } },
});
str := func(s string) *string { return &s }
p := chatxdk.EncryptMessageParams{
    ConversationID: conversationID,
    Text:           "Yes",
    QuickReply: &chatxdk.QuickReply{Response: &chatxdk.QuickReplyResponse{
        Options: chatxdk.QuickReplyOptionsResponse{
            RequestID: str("qr-1"), Metadata: str("m-yes"), SelectedOptionID: str("yes"),
        },
    }},
}
var p = new EncryptMessageParams(conversationId, "Yes")
{
    QuickReply = new QuickReply
    {
        Response = new QuickReplyResponse
        {
            Options = new QuickReplyOptionsResponse { RequestId = "qr-1", Metadata = "m-yes", SelectedOptionId = "yes" },
        },
    },
};
QuickReplyOptionsResponse selected = new QuickReplyOptionsResponse();
selected.requestId = "qr-1";
selected.metadata = "m-yes";
selected.selectedOptionId = "yes";
EncryptMessageParams p = new EncryptMessageParams(conversationId, "Yes");
p.quickReply = QuickReply.response(selected);

KeyChangeEvent

Payload when type is "keyChange" (JS) / "KeyChange" (Python).

// JS/WASM
interface KeyChangeEvent {
  type: 'keyChange';
  // Flattened meta fields...
  conversationKeyVersion: string;
  verified: boolean;
  conversationParticipantKeys: ConversationParticipantKey[];
}

// The same type a prepared change carries in conversationParticipantKeys.
interface ConversationParticipantKey {
  userId: string;
  encryptedConversationKey: string;
  publicKeyVersion: string;
}

GroupChange

Payload when type is "groupChange" (JS) / "GroupChange" (Python).

// JS/WASM
type GroupChange =
  | { changeType: 'created'; memberIds: string[]; adminIds: string[]; title?: GroupText | null; avatarUrl?: string | null; conversationKeyVersion?: string | null }
  | { changeType: 'titleChanged'; newTitle: GroupText }
  | { changeType: 'avatarChanged'; newAvatarUrl: string; conversationKeyVersion?: string | null }
  | { changeType: 'descriptionChanged'; newDescription: GroupText }
  | { changeType: 'adminsAdded'; adminIds: string[] }
  | { changeType: 'adminsRemoved'; adminIds: string[] }
  | { changeType: 'membersAdded'; memberIds: string[]; currentMemberIds: string[]; currentAdminIds: string[] }
  | { changeType: 'membersRemoved'; memberIds: string[] }
  | { changeType: 'inviteEnabled'; inviteUrl: string; expiresAtMsec?: number }
  | { changeType: 'inviteDisabled'; disabledBy?: string }
  | { changeType: 'joinRequested'; userId: string }
  | { changeType: 'joinRejected'; userIds: string[] }
  | { changeType: 'unknown' };

A group's title and description travel encrypted under the conversation key the event names, and decrypt_event / decrypt_events return a created change's title, a titleChanged change's newTitle and a descriptionChanged change's newDescription as a GroupText. An avatar is a media URL, not encrypted: an avatarChanged change's newAvatarUrl, and the avatarUrl another client's create may carry, come back as the event carries them, with the conversationKeyVersion the image is encrypted under. Download the image and decrypt it with decrypt_file_with_key under the key for that version. A member removal's title and description re-encryptions, marked for_key_rotation, never arrive as titleChanged or descriptionChanged: decrypt drops them (see decrypt_events). Rust's GroupChange is #[non_exhaustive], as is each variant with named fields.

GroupText

A group title or description as a received event carries it. text carries the value decrypted with the key for the event's version, from the call's key map or the key cache (decrypt_events reads a create's key from the key change in the same batch), or the plaintext an event that names no key version carries. unavailable carries raw, the value as the event carries it, when that key isn't there or the value doesn't decrypt under it; the rest of the event still decrypts, so a create without its key still returns its members and admins. 0.5.x apps sent titles as plaintext while naming a key version: their titles arrive as unavailable with the title in raw, so show raw when a title isn't decryptable. A member removal's re-encryptions of the title and description aren't changes, and decrypt drops them.

// JS/WASM
type GroupText = { kind: 'text'; text: string } | { kind: 'unavailable'; raw: string };
Binding Shape kind values
Rust GroupText::Text { text } / GroupText::Unavailable { raw } enum, #[non_exhaustive] like each variant —
JS/WASM GroupText union above 'text', 'unavailable'
Python GroupText TypedDict (kind, text, raw), exported from chat_xdk; the event dict holds it "Text", "Unavailable"
Go chatxdk.GroupText struct (Kind, Text, Raw); decode GroupChangeEvent.Change into a struct with GroupText fields "Text", "Unavailable"
.NET GroupText class (Kind, Text, Raw); decrypted JSON deserializes with JsonSerializer.Deserialize<GroupText> "Text", "Unavailable"
JVM Types.GroupText (kind, text, raw); decrypted JSON converts with ObjectMapper.treeToValue(node, GroupText.class) "Text", "Unavailable"
match change {
    GroupChange::TitleChanged { new_title, .. } => match new_title {
        GroupText::Text { text, .. } => show_title(&text),
        GroupText::Unavailable { raw, .. } => show_title(&raw),
        _ => {}
    },
    _ => {}
}

SettingsChange

Payload when type is "settingsChange" (JS) / "SettingsChange" (Python).

// JS/WASM
type SettingsChange =
  | { setting: 'messageDuration'; ttlMsec: number; applyToAll: boolean }
  | { setting: 'messageDurationRemoved' }
  | { setting: 'muted' }
  | { setting: 'unmuted' }
  | { setting: 'screenCaptureDetectionEnabled' }
  | { setting: 'screenCaptureDetectionDisabled' }
  | { setting: 'screenCaptureBlockingEnabled' }
  | { setting: 'screenCaptureBlockingDisabled' }
  | { setting: 'unknown' };

FailureType

Payload when type is "failure" (JS) / "Failure" (Python).

// JS/WASM (camelCase values)
type FailureType =
  | 'emptyDetail'
  | 'internalError'
  | 'contentsTooLarge'
  | 'tooManyMessages'
  | 'invalidSenderSignature'
  | 'nonLatestKeyVersion'
  | 'recipientNotTrusted'
  | 'recipientKeyChanged'
  | 'onlyEncryptedMessagesAllowed'
  | 'requesterNotAdmin'
  | 'flaggedAsSpam'
  | 'rateLimitUpsell'
  | 'signatureFailedToVerifyAgainstPublicKey'
  | 'genericError'
  | 'senderNotGroupMember'
  | 'invalidSignatureVersion'
  | 'invalidPinRequest'
  | 'tooManyPins'
  | 'unknown';

type RateLimitTier =
  | 'free'
  | 'verifiedPhone'
  | 'premium'
  | 'premiumPlus'
  | 'premiumBusiness'
  | 'unknown';

Failure events include an optional rateLimitTier (JS) / rate_limit_tier (Python) when the failure type is rateLimitUpsell (JS) / RateLimitUpsell (other bindings).

AttachmentInfo

Parsed from decrypted message content. Uses attachmentType (JS) / attachment_type (Python) for discrimination.

// JS/WASM
type AttachmentInfo =
  | { attachmentType: 'media'; mediaHashKey?: string; dimensions?: { width?: number; height?: number }; mediaType?: string; durationMillis?: number; filesizeBytes?: number; filename?: string; attachmentId?: string }
  | { attachmentType: 'url'; url?: string; bannerImageMediaHashKey?: string; faviconImageMediaHashKey?: string; displayTitle?: string; attachmentId?: string }
  | { attachmentType: 'post'; restId?: string; postUrl?: string; attachmentId?: string }
  | { attachmentType: 'unifiedCard'; url?: string; attachmentId?: string }
  | { attachmentType: 'money'; fallbackText?: string };