Skip to main content
The Chat XDK handles key management, encryption, decryption, and signing for X Chat. It does not call the X HTTP API—pair it with the Python or TypeScript XDK, or with HTTPS and a user access token. App walkthrough: Getting Started. Sample bots: chat-xdk/examples.

Install

The PyPI package is chatxdk; import it as chat_xdk. Requires Python 3.10+.

Quick start

Load keys, set your identity once, decrypt a backlog, decrypt one live event, encrypt a message. Wire the send body to POST /2/chat/conversations/{id}/messages as in Getting Started. The snippets use the two optional session stores for the shortest call forms: set_signing_keys holds the other participants’ public keys (fetched from the public-keys endpoint) so decrypt calls can verify senders without a per-call argument, and set_cache_keys(true) lets the SDK remember each conversation’s verified key so encrypt calls need only the conversation id and text. Skip either and pass the same values per call instead—both styles verify identically; see Decrypt.

Lifecycle and keys

Construct the SDK, store private keys (passcode-protected secure key backup or a local key blob), register public keys with the Chat API, and call set_identity(user_id, signing_key_version) after unlock or import—it sets the sender and signing-key version every signed action defaults to, so the encrypt and prepare methods work without per-call identity arguments. Call generate_keypairs once per device/app identity; post the registration payload to the public-keys endpoint. Use setup / unlock (and related passcode helpers) for secure key backup on every binding. export_keys / import_keys (raw key-blob persistence for bots and servers) are available on the native bindings only—Python, Go, .NET, JVM, and Rust. The JS/WASM binding does not expose raw key export or import: in a browser any script that reaches the instance could exfiltrate the identity, so JS keeps keys inside secure key backup. A JS server that wants to avoid a backup-realm round-trip per request should reuse one unlocked Chat instance across requests, or run a native binding where key blobs are supported. The SDK also needs the version the X API reports for your registered public key, so key-change entries targeting other versions are skipped. set_identity records it together with the user id; import_keys accepts it directly as an optional argument (Rust and Go use import_keys_with_version / ImportKeysWithVersion).
The secure key backup config accepts three shapes: the X API juicebox_config object (recommended—passed verbatim), a full sdk_config wrapper, or a bare token_map. Optional: signature verification is on by default (reject_unverified = true)—call set_reject_unverified(false) to disable it (not recommended); update_config if backup realm config changes; is_unlocked / has_identity_key for UI state. Full field lists live in the chat-xdk repo stubs.

Conversation keys

Three prepare methods each make one call do everything a key change needs: generate a fresh conversation key, encrypt it for every participant (from the public keys you pass), and sign the change. The sender identity and signing-key version come from the session (set_identity); set sender_id / signing_key_version on the params to override. All return the same PreparedConversationChange shape, ready to POST—rename SDK field encrypted_key to encrypted_conversation_key in conversation_participant_keys, and map the action signatures into the required action_signatures body field. Keep the raw key bytes for encrypt_message and media; never pass the API’s encrypted envelope into encrypt.
Verify fetched keys before wrapping. The prepare methods encrypt the fresh conversation key to whatever public keys you pass. Before passing them, call verify_key_binding(identity, signing, signature) on each fetched record—its public_key, signing_public_key, and identity_public_key_signature fields from the public-keys API—so a substituted identity key cannot receive the conversation key.
Use extract_conversation_keys on key-change event payloads to rebuild { keys, latest_version }. decrypt_conversation_key unwraps a single ECIES blob.
For group create and member adds, pass the params each method needs (member/admin id lists for prepare_group_create; new plus current roster for prepare_group_members_change)—see Groups for samples. Both return two action signatures; the POST must include both.

Decrypt

decrypt_events is for history and backlog: it pulls conversation keys from the stream, returns decrypted messages, and collects per-event errors instead of failing the whole batch. decrypt_event is for a single live event; it raises/throws on failure. Pass signing keys so the SDK can verify senders. Map API public-key fields into SigningKeyEntry: public_key_versionpublic_key_version (same name), signing_public_keypublic_key, public_keyidentity_public_key, plus identity_public_key_signature and user_id. Two opt-in session stores let you omit the per-call key arguments:
  • set_signing_keys(entries) stores participant signing keys; a decrypt call that omits (or passes an empty) signing-keys argument uses the store instead. Verification itself is unchanged—keys enter the store only through this call, never from the events being decrypted. Each call replaces the previous set.
  • set_cache_keys(true) enables the conversation-key cache (off by default). While enabled, decrypt_events caches, per conversation, the latest key whose key change carried a valid signature; decrypt_event falls back to it when its conversation-keys argument is omitted, and the encrypt helpers resolve an omitted conversation key from it. Disabling clears the cache.
An explicit non-empty argument always wins over the stores. Explicit per-call arguments remain first-class—and are the right choice for serverless or multi-instance deployments, where a request can land on a fresh instance whose stores are empty. Verification is mandatory by default: omitting signing keys never skips it. With nothing passed and nothing stored, signed events fail (collected in errors for decrypt_events, thrown for decrypt_event). To actually skip verification you must first call set_reject_unverified(false) (not recommended in production).

Encrypt and send helpers

encrypt_message(conversation_id, text) builds the signed ciphertext for a text message; optional entities, attachments (via media_hash_key), should_notify, and ttl_msec. The sender identity resolves from the session (set_identity) and the conversation key from the opt-in key cache (set_cache_keys)—or pass sender_id / signing_key_version and conversation_key + conversation_key_version explicitly. The SDK generates the message_id (a UUID embedded in the signed event) and returns it on the payload—never mint your own; reuse the same payload on retries so an id is never minted twice. Map the payload into the send-message body: message_idmessage_id, encrypted_contentencoded_message_create_event, encoded_event_signatureencoded_message_event_signature. Replies are event-based. encrypt_reply(conversation_id, text, reply_to_event) takes the base64 raw event being replied to. The SDK derives the quoted preview (sequence id, sender, text, entities, attachments) from it and embeds the signed original in the outgoing message so recipients can validate the quote. Pass reply_to_ckces—the raw key-change events—when the original was encrypted under an older key version than the reply. When the original was edited, pass the raw edit event as reply_to_edit_event: the preview then quotes what the message says now (its text and entities come from the edit), and the edit travels alongside the original for the receiver to check. The explicit reply_to_* fields remain as overrides for callers that no longer hold the raw event. Reactions are event-based too. encrypt_add_reaction(target_event, emoji) and encrypt_remove_reaction(...) derive the conversation id and target sequence id from the raw event being reacted to; the same params can add and later remove a reaction. Set conversation_id and target_message_sequence_id explicitly only when you no longer hold the raw event. On the receiving side, a decrypted message that quotes a reply carries reply_preview_validation ("Valid" / "Invalid"; the JS binding uses 'valid' / 'invalid'): the SDK verified the embedded original’s signature against your signing keys—never a key carried in the event—decrypted it, and compared the quoted content and author against it. When the preview embeds an edit event, the SDK verifies the edit the same way (same conversation, same author as the original) and checks the quoted text against the edited contents rather than the pre-edit text. The field is absent when the message carries no preview or the preview embeds no original. Treat Invalid previews as untrusted: the message itself is authentic, but the quoted material is not—render quotes only from the validated original. encrypt / decrypt are for UTF-8 metadata under the conversation key (for example an encrypted group name)—not message envelopes. encrypt_stream / decrypt_stream encrypt attachment bytes; see Media. Low-level sign / verify / verify_key_binding support advanced flows; conversation-key changes, group creates, and member adds are signed by the prepare methods. The conversation id passed to encrypt_message / encrypt_reply can be any form you hold—A:B from events, A-B from listings or URL paths (in either order), or the bare recipient user id—the SDK canonicalizes it before signing. Group ids (prefixed with g) pass through unchanged.

Media streams

Encrypt file bytes with the same conversation key used for text, upload via Chat media APIs, and attach media_hash_key on encrypt_message. This is not the Posts media model (expansions=attachments.media_keys). Full upload/download flow: Media.

Incremental streaming for large media

For large files, avoid holding the whole payload in memory: stream_encryptor() / stream_decryptor() return a StreamEncryptor / StreamDecryptor you feed in chunks (about 1 MB each) with push(chunk), then call finish() once at the end. On decrypt, finish() detects a truncated stream (it fails if input ended before the final frame), so don’t treat pushed plaintext as complete until it succeeds.
JS/WASM only: finish() consumes and frees the underlying WASM object—never call free() after finish() (it throws). Call free() only to abandon a stream before finishing (e.g. on an error path).

Utilities

Base64/hex helpers, MIME sniffing, and image dimensions are available as module-level functions (Python/JS/Rust/Go) or ChatXdkUtilities (C#/Java)—useful when building attachment metadata without pulling in extra libraries.

Important types

These conceptual types show up across languages (exact field names differ; JS often uses camelCase event discriminators like message):
  • SendPayload — return value of encrypt_message and the other encrypt helpers: the SDK-generated message_id (a UUID embedded in the signed event—send it as the message’s message_id and keep it to dedup), encrypted_content, encoded_event_signature, signature metadata, conversation_key_version, and should_notify. Map into the Chat API send body.
  • PublicKeyRegistrationPayload — output of generate_keypairs / public-key getters for the add-public-key API.
  • SigningKeyEntry — sender public material passed into decrypt for signature verification, or stored via set_signing_keys.
  • PreparedConversationChange — output of the three prepare methods: the derived or passed conversation_id, the raw conversation_key bytes, conversation_key_version, participant_keys (user_id, encrypted_key, public_key_version), and action_signatures (message_id, encoded_message_event_detail, signature, signature_version, public_key_version, optional signature_payload—omitted on key-change signatures because that payload embeds the plaintext key).
  • DecryptEventsResult — messages, optional errors, and extracted conversation_keys. Decrypted messages that quote a reply carry reply_preview_validation (see Encrypt and send helpers).
For complete field lists, use language stubs in the chat-xdk repo (docs/API.md, *.pyi, index.d.ts).

Errors

Python typically raises ValueError with a descriptive message (for example an invalid passcode). TypeScript/JavaScript throws Error. Go returns (value, error). Prefer decrypt_events for history so one bad event does not abort the batch; inspect the errors collection for partial failures. Some verification errors are permanent. Signatures are immutable and verified by rebuilding the signed payload from the event itself, so an old event that fails with signature missing or no matching signing key or an ECDSA mismatch will fail on every future load—no retry, key refresh, or API call can heal it. Treat these as tombstones, not transient errors. Rotating the conversation key starts a clean, verifiable history from that point forward.

Next steps

Getting Started

Wire Chat XDK to the Chat API

Media

Stream encrypt and media REST

Real-time events

Webhooks and activity delivery

Troubleshooting

Common failures