diff --git a/docs.json b/docs.json
index 8696a6007..dcd30612b 100644
--- a/docs.json
+++ b/docs.json
@@ -1001,6 +1001,74 @@
}
]
},
+ {
+ "product": "Chat API",
+ "tabs": [
+ {
+ "tab": "Documentation",
+ "groups": [
+ {
+ "group": "Start here",
+ "pages": [
+ "xchat/introduction",
+ "xchat/cryptography-primer",
+ "xchat/getting-started",
+ "xchat/xchat-xdk"
+ ]
+ },
+ {
+ "group": "Guides",
+ "pages": [
+ "xchat/media",
+ "xchat/groups",
+ "xchat/real-time-events",
+ "xchat/troubleshooting"
+ ]
+ },
+ {
+ "group": "API reference",
+ "pages": [
+ {
+ "group": "Conversations and messages",
+ "expanded": true,
+ "pages": [
+ "x-api/chat/get-chat-conversations",
+ "x-api/chat/get-chat-conversation",
+ "x-api/chat/create-chat-group-conversation",
+ "x-api/chat/initialize-chat-group",
+ "x-api/chat/add-conversation-keys",
+ "x-api/chat/get-chat-conversation-events",
+ "x-api/chat/send-chat-message",
+ "x-api/chat/send-typing-indicator",
+ "x-api/chat/mark-conversation-as-read",
+ "x-api/chat/add-members-to-a-chat-group-conversation"
+ ]
+ },
+ {
+ "group": "Encryption keys",
+ "expanded": true,
+ "pages": [
+ "x-api/chat/add-public-key",
+ "x-api/chat/get-user-public-keys",
+ "x-api/users/get-public-keys-for-multiple-users"
+ ]
+ },
+ {
+ "group": "Media",
+ "expanded": true,
+ "pages": [
+ "x-api/chat/initialize-chat-media-upload",
+ "x-api/chat/append-chat-media-upload",
+ "x-api/chat/finalize-chat-media-upload",
+ "x-api/chat/download-chat-media"
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
{
"product": "Ads API",
"tabs": [
@@ -1309,6 +1377,11 @@
]
},
"redirects": [
+ {
+ "source": "/x-api/chat/initialize-conversation-keys",
+ "destination": "/x-api/chat/add-conversation-keys",
+ "permanent": true
+ },
{ "source": "/enterprise-api/enterprise-gnip-2.0/:path*", "destination": "/x-api/enterprise-gnip-2.0/:path*", "permanent": true },
{ "source": "/enterprise-api/fundamentals/:path*", "destination": "/x-api/fundamentals/:path*", "permanent": true },
{ "source": "/enterprise-api/migrate/:path*", "destination": "/x-api/migrate/:path*", "permanent": true },
diff --git a/openapi.json b/openapi.json
index 52707879d..5478cce14 100644
--- a/openapi.json
+++ b/openapi.json
@@ -1151,7 +1151,7 @@
{
"name" : "id",
"in" : "path",
- "description" : "The recipient's user ID for a 1:1 conversation, or a group conversation ID (prefixed with 'g').",
+ "description" : "The recipient's user ID for a 1:1 conversation, the hyphen-separated 1:1 conversation ID (e.g. '123-456'), or a group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -1232,17 +1232,17 @@
"tags" : [
"Chat"
],
- "summary" : "Initialize Conversation Keys",
- "description" : "Initializes encryption keys for a Chat conversation. This is the first step\nbefore sending messages in a new 1:1 conversation.\n\nFor 1:1 conversations, provide the recipient's user ID as the conversation_id.\nThe server constructs the canonical conversation ID from the authenticated user\nand recipient.\n\nThe request body must contain the conversation key version and participant keys\n(the conversation key encrypted for each participant using their public key).\n\n**Workflow (1:1 conversation):**\n1. Generate a conversation key using the SDK\n2. Encrypt the key for both participants using their public keys\n3. Call this endpoint to register the keys\n4. Send messages using `POST /chat/conversations/{id}/messages`\n\n**Authentication:**\n- Requires OAuth 1.0a User Context or OAuth 2.0 User Context\n- Required scopes: `tweet.read`, `users.read`, `dm.write`\n",
+ "summary" : "Add Conversation Keys",
+ "description" : "Adds (initializes or rotates) the encryption keys for a Chat conversation.\nCall this before sending messages in a new 1:1 conversation, and again with a\nnewer key version to rotate the conversation key.\n\nFor 1:1 conversations, provide the recipient's user ID as the conversation_id.\nThe server constructs the canonical conversation ID from the authenticated user\nand recipient.\n\nThe request body must contain the conversation key version and participant keys\n(the conversation key encrypted for each participant using their public key).\n\n**Workflow (new 1:1 conversation):**\n1. Generate a conversation key using the SDK\n2. Encrypt the key for both participants using their public keys\n3. Call this endpoint to register the keys\n4. Send messages using `POST /chat/conversations/{id}/messages`\n\nTo rotate the keys of an existing conversation, repeat the same call with a\nnewer conversation key version.\n\n**Authentication:**\n- Requires OAuth 1.0a User Context or OAuth 2.0 User Context\n- Required scopes: `tweet.read`, `users.read`, `dm.write`\n",
"externalDocs" : {
"url" : "https://developer.x.com/"
},
- "operationId" : "initializeChatConversationKeys",
+ "operationId" : "addConversationKeys",
"parameters" : [
{
"name" : "id",
"in" : "path",
- "description" : "The recipient's user ID for a 1:1 conversation, or a group conversation ID (prefixed with 'g').",
+ "description" : "The recipient's user ID for a 1:1 conversation, the hyphen-separated 1:1 conversation ID (e.g. '123-456'), or a group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -1316,7 +1316,7 @@
{
"name" : "id",
"in" : "path",
- "description" : "The Chat group conversation ID.",
+ "description" : "The Chat group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -1390,7 +1390,7 @@
{
"name" : "id",
"in" : "path",
- "description" : "The recipient's user ID for a 1:1 conversation, or a group conversation ID (prefixed with 'g').",
+ "description" : "The recipient's user ID for a 1:1 conversation, the hyphen-separated 1:1 conversation ID (e.g. '123-456'), or a group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -1464,7 +1464,7 @@
{
"name" : "id",
"in" : "path",
- "description" : "The recipient's user ID for a 1:1 conversation, or a group conversation ID (prefixed with 'g').",
+ "description" : "The recipient's user ID for a 1:1 conversation, the hyphen-separated 1:1 conversation ID (e.g. '123-456'), or a group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -1538,7 +1538,7 @@
{
"name" : "id",
"in" : "path",
- "description" : "The recipient's user ID for a 1:1 conversation, or a group conversation ID (prefixed with 'g').",
+ "description" : "The recipient's user ID for a 1:1 conversation, the hyphen-separated 1:1 conversation ID (e.g. '123-456'), or a group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -1810,7 +1810,7 @@
{
"name" : "id",
"in" : "path",
- "description" : "The recipient's user ID for a 1:1 conversation, or a group conversation ID (prefixed with 'g').",
+ "description" : "The recipient's user ID for a 1:1 conversation, the hyphen-separated 1:1 conversation ID (e.g. '123-456'), or a group conversation ID (prefixed with 'g').",
"required" : true,
"schema" : {
"$ref" : "#/components/schemas/ChatConversationOrRecipientId"
@@ -15044,24 +15044,70 @@
}
}
},
+ "ChatActionMessageEventSignature" : {
+ "type" : "object",
+ "description" : "Message event signature supplied with an action signature.",
+ "required" : [
+ "signature",
+ "public_key_version",
+ "signature_version"
+ ],
+ "properties" : {
+ "message_signing_key_info_list" : {
+ "type" : "array",
+ "description" : "List of signing key information for message verification.",
+ "items" : {
+ "$ref" : "#/components/schemas/ChatMessageSigningKeyInfo"
+ }
+ },
+ "public_key_version" : {
+ "type" : "string",
+ "description" : "The version of the public key used for signing.",
+ "pattern" : "^[0-9]+$"
+ },
+ "signature" : {
+ "type" : "string",
+ "description" : "The signature of the message event.",
+ "minLength" : 1,
+ "pattern" : "^[A-Za-z0-9+/=_-]+$"
+ },
+ "signature_version" : {
+ "type" : "string",
+ "description" : "The version of the signature algorithm.",
+ "pattern" : "^[0-9]+$"
+ },
+ "signing_public_key" : {
+ "type" : "string",
+ "description" : "The public key used for signing."
+ }
+ }
+ },
"ChatActionSignature" : {
"type" : "object",
"description" : "Cryptographic signature for a chat action.",
+ "required" : [
+ "message_id",
+ "encoded_message_event_detail",
+ "message_event_signature"
+ ],
"properties" : {
"encoded_message_event_detail" : {
"type" : "string",
- "description" : "Base64-encoded message event detail."
+ "description" : "Base64-encoded message event detail.",
+ "minLength" : 1,
+ "pattern" : "^[A-Za-z0-9+/=_-]+$"
},
"message_event_signature" : {
- "$ref" : "#/components/schemas/ChatMessageEventSignature"
+ "$ref" : "#/components/schemas/ChatActionMessageEventSignature"
},
"message_id" : {
"type" : "string",
- "description" : "ID of the message being signed."
+ "description" : "Client-generated ID of the message being signed.",
+ "minLength" : 1
},
"signature_payload" : {
"type" : "string",
- "description" : "Cryptographic signature payload."
+ "description" : "Payload string the client signed; used only in server-side failure logs."
}
}
},
@@ -15175,7 +15221,7 @@
"type" : "object",
"properties" : {
"data" : {
- "$ref" : "#/components/schemas/ChatPublicKey"
+ "$ref" : "#/components/schemas/PublicKey"
},
"errors" : {
"type" : "array",
@@ -15214,7 +15260,7 @@
},
"id" : {
"type" : "string",
- "description" : "The unique identifier for this conversation."
+ "description" : "The unique identifier for this conversation: the hyphen-separated participant pair for a 1:1 conversation (e.g. '123-456'), or a 'g'-prefixed ID for a group."
},
"is_muted" : {
"type" : "boolean",
@@ -15361,7 +15407,7 @@
"properties" : {
"conversation_id" : {
"type" : "string",
- "description" : "The ID of the created conversation."
+ "description" : "The ID of the created group conversation (prefixed with 'g')."
},
"conversation_key_change_sequence_id" : {
"type" : "string",
@@ -15501,6 +15547,10 @@
"data" : {
"type" : "object",
"properties" : {
+ "conversation_id" : {
+ "type" : "string",
+ "description" : "Canonical ID of the conversation the keys were added to: the hyphen-joined participant pair for a one-to-one (for example `123-456`), or the g-prefixed ID for a group. Use this ID for subsequent requests and to match conversation events."
+ },
"sequence_id" : {
"type" : "string",
"description" : "Sequence ID of the conversation key change event. Use this to track key changes in the conversation event stream."
@@ -15551,15 +15601,15 @@
},
"realm_state_string" : {
"type" : "string",
- "description" : "Serialized realm state for key recovery."
+ "description" : "Serialized realm state for key recovery. Only returned when registering a key."
},
"recover_threshold" : {
"type" : "integer",
- "description" : "Threshold required to recover the key."
+ "description" : "Threshold required to recover the key. Only returned when registering a key."
},
"register_threshold" : {
"type" : "integer",
- "description" : "Threshold required to register the key."
+ "description" : "Threshold required to register the key. Only returned when registering a key."
},
"token_map" : {
"type" : "array",
@@ -15581,7 +15631,7 @@
},
"public_key" : {
"type" : "string",
- "description" : "Realm public key."
+ "description" : "Realm public key. Only returned when registering a key."
},
"token" : {
"type" : "string",
@@ -15755,7 +15805,7 @@
"properties" : {
"conversation_id" : {
"type" : "string",
- "description" : "Conversation id associated with the upload.",
+ "description" : "Conversation ID associated with the upload: the colon-separated participant pair for a 1:1 conversation, or a 'g'-prefixed ID for a group.",
"example" : "1603419216513746946:1603419216513746946"
},
"media_hash_key" : {
@@ -15781,7 +15831,7 @@
"properties" : {
"conversation_id" : {
"type" : "string",
- "description" : "The conversation ID this message belongs to."
+ "description" : "The conversation ID this message belongs to, in the form embedded in events: the colon-separated participant pair for a 1:1 conversation (e.g. '123:456'), or a 'g'-prefixed ID for a group."
},
"conversation_token" : {
"type" : "string",
@@ -15867,31 +15917,6 @@
}
}
},
- "ChatPublicKey" : {
- "type" : "object",
- "description" : "A user's public key with associated key recovery configuration.",
- "properties" : {
- "identity_public_key_signature" : {
- "type" : "string",
- "description" : "DER-encoded signature proving the signing key is bound to the identity key (base64 encoded)."
- },
- "juicebox_config" : {
- "$ref" : "#/components/schemas/ChatJuiceboxConfig"
- },
- "public_key" : {
- "type" : "string",
- "description" : "Identity public key (base64 encoded)."
- },
- "signing_public_key" : {
- "type" : "string",
- "description" : "Signing public key (base64 encoded)."
- },
- "version" : {
- "type" : "string",
- "description" : "Public key version."
- }
- }
- },
"ChatSendMessageRequest" : {
"type" : "object",
"required" : [
@@ -21852,58 +21877,26 @@
},
"PublicKey" : {
"type" : "object",
- "description" : "Public key information for Chat encryption",
+ "description" : "A user's public key with associated key recovery configuration.",
"properties" : {
"identity_public_key_signature" : {
"type" : "string",
"description" : "DER-encoded signature proving the signing key is bound to the identity key (base64 encoded)."
},
+ "juicebox_config" : {
+ "$ref" : "#/components/schemas/ChatJuiceboxConfig"
+ },
"public_key" : {
"type" : "string",
"description" : "Identity public key (base64 encoded)."
},
- "signing_public_key" : {
+ "public_key_version" : {
"type" : "string",
- "description" : "Signing public key (base64 encoded)."
- },
- "token_map" : {
- "type" : "object",
- "description" : "Juicebox configuration.",
- "properties" : {
- "key_store_token_map_json" : {
- "type" : "string",
- "description" : "Raw JSON for Juicebox SDK."
- },
- "max_guess_count" : {
- "type" : "integer",
- "description" : "Maximum guess count for Juicebox."
- },
- "realms" : {
- "type" : "array",
- "description" : "List of Juicebox realms.",
- "items" : {
- "type" : "object",
- "properties" : {
- "address" : {
- "type" : "string",
- "description" : "Realm URL."
- },
- "realm_id" : {
- "type" : "string",
- "description" : "Realm identifier."
- },
- "token" : {
- "type" : "string",
- "description" : "JWT auth token for realm."
- }
- }
- }
- }
- }
+ "description" : "Public key version."
},
- "version" : {
+ "signing_public_key" : {
"type" : "string",
- "description" : "Public key version."
+ "description" : "Signing public key (base64 encoded)."
}
}
},
@@ -26476,16 +26469,16 @@
"identity_public_key_signature",
"juicebox_config",
"public_key",
- "signing_public_key",
- "version"
+ "public_key_version",
+ "signing_public_key"
]
},
"example" : [
"identity_public_key_signature",
"juicebox_config",
"public_key",
- "signing_public_key",
- "version"
+ "public_key_version",
+ "signing_public_key"
]
},
"explode" : false,
diff --git a/x-api/chat/initialize-conversation-keys.mdx b/x-api/chat/add-conversation-keys.mdx
similarity index 100%
rename from x-api/chat/initialize-conversation-keys.mdx
rename to x-api/chat/add-conversation-keys.mdx
diff --git a/xchat/cryptography-primer.mdx b/xchat/cryptography-primer.mdx
new file mode 100644
index 000000000..db9f295b0
--- /dev/null
+++ b/xchat/cryptography-primer.mdx
@@ -0,0 +1,295 @@
+---
+title: Cryptography Primer
+sidebarTitle: Cryptography Primer
+description: Understand the encryption concepts behind X Chat without diving into implementation details
+keywords: ["X Chat cryptography", "E2EE primer", "encryption basics", "public key encryption", "ECDH", "digital signatures", "conversation keys"]
+---
+
+import { Button } from '/snippets/button.mdx';
+
+This primer explains the cryptographic ideas behind X Chat at a conceptual level. You do not need this depth to build—the [Chat XDK](/xchat/xchat-xdk) performs encryption, decryption, signing, and key storage for you—but the mental model helps when you design your app or debug behavior.
+
+When you are ready to implement, use [Getting Started](/xchat/getting-started) for a full walkthrough and the [API reference](/x-api/chat/get-chat-conversations) in the sidebar for individual routes.
+
+
+**You don't implement this cryptography yourself.** The Chat XDK handles it. This page is for understanding, not an API checklist.
+
+
+---
+
+## The big picture
+
+X Chat uses a layered encryption system where:
+
+1. **Messages** are encrypted with a **conversation key** (fast symmetric encryption)
+2. **Conversation keys** are encrypted to each participant using their **identity public key** (asymmetric key exchange)
+3. **Messages are signed** so recipients can verify who sent them and that nothing was altered
+
+Symmetric encryption is efficient for lots of message traffic; asymmetric encryption is used mainly to **distribute** conversation keys safely.
+
+```mermaid
+flowchart TB
+ subgraph "Message Encryption"
+ A[Your Message] --> B[Encrypt with
Conversation Key]
+ B --> C[Encrypted Message]
+ end
+
+ subgraph "Key Distribution"
+ D[Conversation Key] --> E[Encrypt with
Recipient's Public Key]
+ E --> F[Encrypted Key
for Recipient]
+ end
+
+ subgraph "Authentication"
+ C --> G[Sign with
Your Private Key]
+ G --> H[Signature]
+ end
+```
+
+In the product flow, X transports **ciphertext and key envelopes**—not readable message content or the raw conversation key. Your app uses the Chat XDK for crypto and the [Chat API](/xchat/introduction) (via the XDK in Python/TypeScript, or HTTPS) to register keys and send or receive those encrypted payloads. See [Getting Started](/xchat/getting-started) for how those pieces fit together.
+
+---
+
+## Key types explained
+
+X Chat uses three kinds of key material, each with a specific purpose.
+
+### 1. Identity keypair
+
+**Purpose:** Securely exchange conversation keys between users
+
+| Component | Description |
+|:----------|:------------|
+| **Identity public key** | Shared with others; used to encrypt conversation keys *to* you |
+| **Identity private key** | Kept secret; used to decrypt conversation keys sent *to* you |
+
+When someone adds you to a conversation, they encrypt the conversation key using your identity public key. Only your identity private key can decrypt it.
+
+Public halves are registered and discovered through the platform’s **public-key** APIs (see Encryption keys under API reference). Private halves stay in the Chat XDK (for example via [Juicebox](#juicebox-distributed-key-storage) or a carefully protected key blob).
+
+### 2. Signing keypair
+
+**Purpose:** Prove that you authored a message
+
+| Component | Description |
+|:----------|:------------|
+| **Signing public key** | Shared with others; used to verify your signatures |
+| **Signing private key** | Kept secret; used to sign your messages |
+
+When you send a message, it is signed with your signing private key. Recipients verify using your signing public key (also published through the public-key APIs). The Chat XDK signs as part of encrypting a message and can verify on decrypt when you supply the sender’s public key material.
+
+### 3. Conversation key
+
+**Purpose:** Encrypt and decrypt messages (and media) within a specific conversation
+
+| Property | Description |
+|:---------|:------------|
+| **Symmetric** | Same key encrypts and decrypts |
+| **Per-conversation** | Each conversation has its own key |
+| **Shared among participants** | All participants who should read the thread have a copy |
+| **Versioned** | Keys can rotate; apps should track versions over time |
+
+Conversation keys are generated when a conversation is set up or when keys rotate. Each participant gets an **encrypted copy** of the key, produced with their identity public key. After you decrypt your copy once, you keep the **raw** conversation key and use it for fast message (and [media](/xchat/media)) encryption. Setting up those copies for a conversation is done through the Chat XDK together with conversation **key** endpoints—walked through in [Getting Started](/xchat/getting-started#4-set-up-conversation-keys).
+
+---
+
+## How encryption works (conceptually)
+
+### Sending a message
+
+
+
+ You type: "Hello, how are you?"
+
+
+ Your app uses the raw conversation key for this chat (from setup or from an earlier key-distribution event), for the right key version.
+
+
+ The Chat XDK encrypts your message with the conversation key. The result is ciphertext that is useless without that key.
+
+
+ The Chat XDK signs the encrypted payload with your signing private key, proving you authored this exact content.
+
+
+ Your app sends the encrypted payload and signature to X through the Chat API **send message** endpoint. X stores and delivers bytes it cannot read as plaintext.
+
+
+
+### Receiving a message
+
+
+
+ Your app receives ciphertext from X—via [webhooks or an activity stream](/xchat/real-time-events), or by reading conversation **events** for history.
+
+
+ Use your cached raw key, or obtain it by decrypting your copy from a key-distribution (key change) event if this is new or rotated.
+
+
+ The Chat XDK checks the signature using the sender’s signing public key (and related identity binding), so you know who sent it and that it was not modified.
+
+
+ The Chat XDK decrypts with the conversation key. You can now read: "Hello, how are you?"
+
+
+
+Implementation of encrypt, send, receive, and decrypt is in [Getting Started](/xchat/getting-started) and the [Chat XDK](/xchat/xchat-xdk) reference.
+
+---
+
+## Key distribution explained
+
+A central challenge in end-to-end encryption is **key distribution**: how participants get the conversation key **without** X (or an observer) seeing that key in the clear.
+
+### Initial key setup
+
+When a conversation is prepared for messaging:
+
+1. A random conversation key is generated (in the Chat XDK)
+2. For **each participant**, that key is encrypted to their **identity public key**
+3. Those encrypted copies are stored and delivered through X’s Chat APIs
+4. Each participant decrypts **their** copy with their identity private key (in the Chat XDK)
+
+X only ever handles the **wrapped** copies, not the raw conversation key.
+
+### Key change events
+
+When the conversation key rotates (for example when membership changes), participants receive a **key change** event with new encrypted copies for each member.
+
+Your app should:
+
+1. Notice key-change material on live events or in conversation history
+2. Decrypt and store the new conversation key (and version)
+3. Use the latest version for subsequent sends
+
+[Getting Started](/xchat/getting-started#6-receive-and-decrypt) and [Real-time events](/xchat/real-time-events) describe where those events appear in practice.
+
+---
+
+## Juicebox: Distributed key storage
+
+Your **private** identity and signing keys must be stored carefully. X Chat integrates **Juicebox** so keys can be recovered with a PIN across devices without giving any single server the full secret.
+
+### The problem with traditional key storage
+
+| Approach | Problem |
+|:---------|:--------|
+| Store on device only | Lose the device = lose the keys = lose access to message history |
+| Store in an ordinary cloud backup | The provider might access key material |
+| Remember a long key | People cannot reliably memorize high-entropy keys |
+
+### How Juicebox solves it
+
+Juicebox combines **secret sharing** with **PIN protection**:
+
+1. Private keys are **split into shares**
+2. Shares are held by **independent realms** (separate servers)
+3. **No single realm** has enough information to reconstruct the keys alone
+4. Recovery requires your **PIN** and cooperation from **enough realms**
+5. Wrong PINs are **rate-limited** to slow guessing
+
+```mermaid
+flowchart LR
+ A[Your Private Keys] --> B[Split into Shares]
+ B --> C[Realm 1
Share A]
+ B --> D[Realm 2
Share B]
+ B --> E[Realm 3
Share C]
+
+ subgraph Recovery
+ F[Your PIN + Multiple Realms] --> G[Reconstruct Keys]
+ end
+```
+
+You get recoverability (new device + PIN) without a single party holding the whole secret.
+
+
+You do not configure Juicebox servers by hand for the normal path. The Chat XDK includes the Juicebox client; realm configuration comes from the X API as **`juicebox_config`** on your public-key record. First-time PIN storage and later unlock are Chat XDK calls—see [Getting Started](/xchat/getting-started#2-initialize-the-chat-xdk) and [register public keys](/xchat/getting-started#3-register-public-keys-one-time). Some apps (especially servers and bots) use an exported key blob instead of Juicebox; protect that material like a password.
+
+
+---
+
+## Signatures explained
+
+Every X Chat message includes a **digital signature** that supports:
+
+1. **Authenticity** — it was produced with the sender’s signing private key
+2. **Integrity** — the encrypted content was not modified after signing
+
+### How signatures work (conceptually)
+
+| Action | Key used | Result |
+|:-------|:---------|:-------|
+| **Sign** | Sender’s signing private key | A signature bound to this exact encrypted message |
+| **Verify** | Sender’s signing public key | Confirms the signature matches the message and key |
+
+If anything in the signed material changes, verification fails. Only someone with the signing private key can produce a valid signature for that key.
+
+### In your app
+
+The Chat XDK signs when you encrypt outbound messages and verifies when you decrypt inbound ones against the sender’s public key material (from the public-key APIs). Verification is **mandatory by default**: the SDK rejects unverified signed events unless you explicitly disable the check (not recommended). Details are in the [Chat XDK](/xchat/xchat-xdk) reference.
+
+### Signed state changes (action signatures)
+
+Messages are not the only signed material. Every call that changes conversation state—adding or rotating conversation keys, creating a group, adding members—must carry one or more **action signatures**: the sender signs a payload describing exactly what the change does (for a key change, that payload includes the new conversation key itself), and the API rejects the request if the signatures are missing or malformed.
+
+Because the server never holds the plaintext conversation key, it cannot check a key change's signature cryptographically; it validates that the signed, encoded description of the change matches the request it received. The **cryptographic** check happens at the edges: each recipient's Chat XDK verifies the signature against the sender's signing public key when it decrypts the key-change event. The Chat XDK's `prepare` methods produce these signatures for you—group creates and member adds return **two** (the key change plus the group action), and both must be sent.
+
+Signatures are bound to the event contents and are immutable: an event whose signature does not verify can never become valid later. See [Troubleshooting](/xchat/troubleshooting) for how to treat those.
+
+---
+
+## Security properties
+
+### What X Chat protects against
+
+| Threat | Protection |
+|:-------|:-----------|
+| **X reading message bodies** | Content is encrypted before it is sent to X |
+| **Network eavesdroppers** | Transport security plus end-to-end encrypted content |
+| **Message tampering** | Signatures detect modification |
+| **Trivial sender impersonation** | Valid signatures require the sender’s signing private key |
+| **Single-server key theft (with Juicebox)** | Shares are split across realms and PIN-gated |
+
+### What X Chat does **not** protect against
+
+| Threat | Why not |
+|:-------|:--------|
+| **Compromised device** | Plaintext and keys may be exposed on an unlocked client |
+| **Metadata** | X can know who messaged whom and when—not the message text |
+| **Forward secrecy** | Compromise of identity keys can expose conversation keys that were wrapped to those keys |
+| **Post-compromise security** | Rotating keys does not rewrite history |
+
+---
+
+## Glossary
+
+| Term | Definition |
+|:-----|:-----------|
+| **Symmetric encryption** | Same key encrypts and decrypts (used for messages and media streams) |
+| **Asymmetric encryption** | Different keys for encrypt vs decrypt (used to wrap conversation keys) |
+| **Public key** | Safe to share; used to encrypt *to* someone or verify their signatures |
+| **Private key** | Must stay secret; used to decrypt or sign |
+| **Keypair** | A linked public key and private key |
+| **ECDH / ECIES** | Algorithms used when wrapping conversation keys to identity keys |
+| **ECDSA** | Signature algorithm used for message authorship |
+| **P-256** | Elliptic curve used in X Chat (secp256r1) |
+| **Conversation key** | Symmetric key shared by participants in one conversation (versioned over time) |
+| **Secret sharing** | Splitting a secret so multiple pieces are needed to reconstruct it |
+| **Realm** | An independent Juicebox server holding one share of your key material |
+
+---
+
+## Next steps
+
+
+
+ Implement keys, send, and receive step by step
+
+
+ Encryption SDK methods and types
+
+
+ Product overview and architecture
+
+
+ How encrypted events are delivered
+
+
diff --git a/xchat/getting-started.mdx b/xchat/getting-started.mdx
new file mode 100644
index 000000000..464cb4852
--- /dev/null
+++ b/xchat/getting-started.mdx
@@ -0,0 +1,985 @@
+---
+title: Getting Started with Chat API
+sidebarTitle: Getting Started
+description: Build end-to-end encrypted X Chat messaging with the Chat XDK and X API
+keywords: ["X Chat tutorial", "X Chat quickstart", "Chat XDK", "encrypted DM", "Python", "TypeScript", "Go", "Rust", "C#", "Java"]
+---
+
+Send and receive end-to-end encrypted direct messages on X: set up keys, initialize a conversation, send a message, and decrypt inbound traffic.
+
+X Chat apps use two pieces together:
+
+| Component | Role |
+|:----------|:-----|
+| **[Chat XDK](/xchat/xchat-xdk)** | Encryption, decryption, signing, and private key storage (Juicebox or a key blob) |
+| **X API** | Public keys, conversation keys, messages, and events—via the [Python](/xdks/python/overview) or [TypeScript](/xdks/typescript/overview) XDK, or HTTPS with a user access token |
+
+
+**Prerequisites**
+
+- [Developer account](https://developer.x.com/en/portal/petition/essential/basic-info) and an app configured for OAuth 2.0
+- User access token with `dm.read`, `dm.write`, `tweet.read`, and `users.read`
+
+
+---
+
+## 1. Install dependencies
+
+
+
+ ```bash
+ pip install chat-xdk xdk
+ ```
+
+
+ ```bash
+ npm install @xdevplatform/chat-xdk @xdevplatform/xdk
+ ```
+
+
+ ```toml
+ [dependencies]
+ chat-xdk-core = "0.2"
+ reqwest = { version = "0.12", features = ["blocking", "json"] }
+ serde_json = "1"
+ base64 = "0.22"
+ uuid = { version = "1", features = ["v4"] }
+ ```
+
+
+ ```bash
+ go get github.com/xdevplatform/chat-xdk/go/chatxdk
+ ```
+
+
+ ```bash
+ # From the chat-xdk repo: cargo build -p chat-xdk-dotnet --release
+ # Reference the ChatXdk package; place the native library on the loader path (.NET 8+)
+ ```
+
+
+ ```bash
+ # Use com.xdevplatform.chatxdk (JNA) and the same native library as .NET (JDK 17+)
+ # Set jna.library.path to the built chat_xdk_dotnet library
+ ```
+
+
+
+Create an API client with your **user** OAuth 2.0 access token:
+
+
+
+ ```python
+ from xdk import Client
+
+ client = Client(access_token="YOUR_OAUTH2_USER_TOKEN")
+ ```
+
+
+ ```typescript
+ import { Client } from '@xdevplatform/xdk';
+
+ const client = new Client({ accessToken: 'YOUR_OAUTH2_USER_TOKEN' });
+ ```
+
+
+ ```rust
+ let access_token = std::env::var("X_ACCESS_TOKEN")?;
+ let http = reqwest::blocking::Client::new();
+ let auth = format!("Bearer {access_token}");
+ ```
+
+
+ ```go
+ accessToken := os.Getenv("X_ACCESS_TOKEN")
+ httpClient := &http.Client{Timeout: 30 * time.Second}
+ ```
+
+
+ ```csharp
+ using var http = new HttpClient();
+ http.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue(
+ "Bearer", Environment.GetEnvironmentVariable("X_ACCESS_TOKEN"));
+ ```
+
+
+ ```java
+ String accessToken = System.getenv("X_ACCESS_TOKEN");
+ HttpClient http = HttpClient.newHttpClient();
+ ```
+
+
+
+---
+
+## 2. Initialize the Chat XDK
+
+Load private keys with **Juicebox** (PIN + `juicebox_config` from the API) or a **key blob** (`import_keys`). Then set your registered public-key version (`public_key_version` on your record).
+
+
+
+ ```python
+ import json
+ from chat_xdk import Chat
+
+ resp = client.chat.get_user_public_keys(
+ "YOUR_USER_ID",
+ public_key_fields=[
+ "public_key_version", "public_key", "signing_public_key",
+ "identity_public_key_signature", "juicebox_config",
+ ],
+ )
+ record = resp.data[0]
+ signing_key_version = str(record["public_key_version"])
+
+ chat = Chat(json.dumps(record["juicebox_config"]))
+ chat.unlock("YOUR_PIN") # after first-time setup() — see next section
+ chat.set_key_version(signing_key_version)
+ ```
+
+
+ ```typescript
+ import { createChat } from '@xdevplatform/chat-xdk';
+
+ const resp = await client.chat.getUserPublicKeys('YOUR_USER_ID', {
+ publicKeyFields: [
+ 'public_key_version', 'public_key', 'signing_public_key',
+ 'identity_public_key_signature', 'juicebox_config',
+ ],
+ });
+ const record = resp.data[0];
+ const signingKeyVersion = String(record.public_key_version);
+
+ const chat = await createChat({
+ juiceboxConfig: JSON.stringify(record.juicebox_config),
+ getAuthToken: async (realmId) => getRealmTokenFromYourBackend(realmId),
+ });
+ await chat.unlock('YOUR_PIN');
+ chat.setKeyVersion(signingKeyVersion);
+ ```
+
+
+ ```rust
+ use base64::{engine::general_purpose::STANDARD as B64, Engine};
+ use chat_xdk_core::ChatCore;
+
+ let mut chat = ChatCore::new();
+ let blob = B64.decode(std::env::var("PRIVATE_KEYS_B64")?)?;
+ let signing_key_version = std::env::var("SIGNING_KEY_VERSION").unwrap_or_else(|_| "1".into());
+ chat.import_keys(&blob)?;
+ chat.set_key_version(&signing_key_version);
+ ```
+
+
+ ```go
+ import "github.com/xdevplatform/chat-xdk/go/chatxdk"
+
+ chat := chatxdk.New()
+ defer chat.Close()
+
+ blob, err := chatxdk.Base64ToBytes(os.Getenv("PRIVATE_KEYS_B64"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := chat.ImportKeys(blob); err != nil {
+ log.Fatal(err)
+ }
+ signingKeyVersion := os.Getenv("SIGNING_KEY_VERSION")
+ if signingKeyVersion == "" {
+ signingKeyVersion = "1"
+ }
+ chat.SetKeyVersion(signingKeyVersion)
+ ```
+
+
+ ```csharp
+ using ChatXdk;
+
+ using var chat = new Chat();
+ var signingKeyVersion = Environment.GetEnvironmentVariable("SIGNING_KEY_VERSION") ?? "1";
+ chat.ImportKeys(Convert.FromBase64String(
+ Environment.GetEnvironmentVariable("PRIVATE_KEYS_B64")!));
+ chat.SetKeyVersion(signingKeyVersion);
+ ```
+
+
+ ```java
+ import com.xdevplatform.chatxdk.Chat;
+
+ String signingKeyVersion = Optional.ofNullable(System.getenv("SIGNING_KEY_VERSION")).orElse("1");
+ try (Chat chat = new Chat()) {
+ chat.importKeys(Base64.getDecoder().decode(System.getenv("PRIVATE_KEYS_B64")));
+ chat.setKeyVersion(signingKeyVersion);
+ }
+ ```
+
+
+
+Server and bot samples often use a **key blob** (`export_keys` / `import_keys`). Client apps often use **Juicebox** (`setup` / `unlock` with a PIN). See the [Chat XDK](/xchat/xchat-xdk) reference for both paths.
+
+---
+
+## 3. Register public keys (one-time)
+
+Generate keypairs, register **public** keys with X, then store **private** keys (`setup` with a PIN, or `export_keys`).
+
+
+
+ ```python
+ from xdk.chat.models import AddUserPublicKeyRequest
+
+ registration = chat.generate_keypairs()
+ pk = registration.public_key
+ client.chat.add_user_public_key(
+ "YOUR_USER_ID",
+ AddUserPublicKeyRequest(
+ public_key={
+ "identity_public_key_signature": pk.identity_public_key_signature,
+ "public_key": pk.public_key,
+ "public_key_fingerprint": pk.public_key_fingerprint,
+ "registration_method": pk.registration_method,
+ "signing_public_key": pk.signing_public_key,
+ "signing_public_key_signature": pk.signing_public_key_signature,
+ },
+ version=registration.version,
+ generate_version=registration.generate_version,
+ ),
+ )
+ chat.setup("YOUR_PIN")
+ chat.set_key_version(str(registration.version or signing_key_version))
+ ```
+
+
+ ```typescript
+ const registration = chat.generateKeypairs();
+ const pk = registration.publicKey;
+ await client.chat.addUserPublicKey('YOUR_USER_ID', {
+ public_key: {
+ identity_public_key_signature: pk.identityPublicKeySignature,
+ public_key: pk.publicKey,
+ public_key_fingerprint: pk.publicKeyFingerprint,
+ registration_method: pk.registrationMethod,
+ signing_public_key: pk.signingPublicKey,
+ signing_public_key_signature: pk.signingPublicKeySignature,
+ },
+ version: registration.version,
+ generate_version: registration.generateVersion,
+ });
+ await chat.setup('YOUR_PIN');
+ chat.setKeyVersion(String(registration.version ?? signingKeyVersion));
+ ```
+
+
+ ```rust
+ let registration = chat.generate_keypairs()?;
+ let body = serde_json::to_value(®istration)?;
+ let resp = http
+ .post(format!("https://api.x.com/2/users/{user_id}/public_keys"))
+ .header("Authorization", &auth)
+ .json(&body)
+ .send()?;
+ if !resp.status().is_success() {
+ anyhow::bail!("register keys: {}", resp.text()?);
+ }
+ let _blob = chat.export_keys()?; // store securely
+ ```
+
+
+ ```go
+ registration, err := chat.GenerateKeypairs()
+ if err != nil {
+ log.Fatal(err)
+ }
+ regJSON, _ := json.Marshal(registration)
+ req, _ := http.NewRequest(http.MethodPost,
+ "https://api.x.com/2/users/"+userID+"/public_keys",
+ bytes.NewReader(regJSON))
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ log.Fatal(err)
+ }
+ resp.Body.Close()
+ privateKeysB64, _ := chat.ExportKeys() // store securely
+ _ = privateKeysB64
+ chat.SetKeyVersion(signingKeyVersion)
+ ```
+
+
+ ```csharp
+ var registration = chat.GenerateKeypairs();
+ var regJson = System.Text.Json.JsonSerializer.Serialize(registration);
+ using var content = new StringContent(regJson, Encoding.UTF8, "application/json");
+ using var regResp = await http.PostAsync(
+ $"https://api.x.com/2/users/{Uri.EscapeDataString(userId)}/public_keys", content);
+ regResp.EnsureSuccessStatusCode();
+ var blob = chat.ExportKeys(); // store securely
+ chat.SetKeyVersion(signingKeyVersion);
+ ```
+
+
+ ```java
+ var registration = chat.generateKeypairs();
+ String regJson = new ObjectMapper().writeValueAsString(registration);
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create("https://api.x.com/2/users/" + userId + "/public_keys"))
+ .header("Authorization", "Bearer " + accessToken)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(regJson))
+ .build();
+ HttpResponse regResp = http.send(req, HttpResponse.BodyHandlers.ofString());
+ if (regResp.statusCode() >= 300) {
+ throw new RuntimeException("register keys: " + regResp.body());
+ }
+ byte[] blob = chat.exportKeys(); // store securely
+ chat.setKeyVersion(signingKeyVersion);
+ ```
+
+
+
+
+Use a strong PIN for Juicebox. Losing the PIN or an unprotected key blob can prevent decrypting past messages.
+
+
+---
+
+## 4. Set up conversation keys
+
+Call **`prepare_conversation_key_change`** with your user ID, your signing key version, and every participant's identity public key. One call generates a fresh conversation key, encrypts it for each participant, and signs the change. POST the result to the **add conversation keys** endpoint (`POST /2/chat/conversations/{id}/keys`)—the body needs `conversation_key_version`, `conversation_participant_keys` (SDK `encrypted_key` → API `encrypted_conversation_key`), and **`action_signatures`** (required; the API rejects the call without them). Keep the **raw** conversation key for sending.
+
+The response returns the canonical conversation id (`data.conversation_id`—the hyphen-joined pair for a 1:1, or the g-prefixed id for a group) and the key change's `data.sequence_id`. Use that returned id for subsequent requests instead of reconstructing it client-side. The same call also **rotates** keys later: pass the existing conversation id to `prepare_conversation_key_change` and POST with the newer key version. Rotate when you suspect the conversation key was exposed—rotation protects **future** messages only; messages encrypted under earlier key versions stay readable to anyone who holds those versions.
+
+
+**Verify fetched keys before wrapping.** `prepare_conversation_key_change` encrypts the fresh conversation key to whatever public keys you pass. Check each fetched record first with `verify_key_binding(identity, signing, signature)`—passing the record's `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.
+
+
+
+
+ ```python
+ def public_key_input(user_id: str) -> dict:
+ r = client.chat.get_user_public_keys(
+ user_id, public_key_fields=["public_key_version", "public_key"]
+ ).data[0]
+ return {"user_id": user_id, "public_key": r["public_key"], "key_version": r["public_key_version"]}
+
+ prepared = chat.prepare_conversation_key_change(
+ "YOUR_USER_ID",
+ signing_key_version,
+ [public_key_input("YOUR_USER_ID"), public_key_input("RECIPIENT_USER_ID")],
+ # conversation_id=None for a new 1:1; pass the id to rotate later
+ )
+ resp = client.chat.add_conversation_keys(
+ "RECIPIENT_USER_ID",
+ {
+ "conversation_key_version": prepared["conversation_key_version"],
+ "conversation_participant_keys": [
+ {
+ "user_id": pk["user_id"],
+ "encrypted_conversation_key": pk["encrypted_key"],
+ "public_key_version": pk["public_key_version"],
+ }
+ for pk in prepared["participant_keys"]
+ ],
+ "action_signatures": [
+ {
+ "message_id": sig["message_id"],
+ "encoded_message_event_detail": sig["encoded_message_event_detail"],
+ "message_event_signature": {
+ "signature": sig["signature"],
+ "public_key_version": sig["public_key_version"],
+ "signature_version": sig["signature_version"],
+ },
+ }
+ for sig in prepared["action_signatures"]
+ ],
+ },
+ )
+ conversation_id = resp.data["conversation_id"] # canonical id for later requests
+ sequence_id = resp.data["sequence_id"]
+ conv_key = prepared["conversation_key"]
+ conv_key_version = prepared["conversation_key_version"]
+ ```
+
+
+ ```typescript
+ async function publicKeyInput(userId: string) {
+ const r = (await client.chat.getUserPublicKeys(userId, {
+ publicKeyFields: ['public_key_version', 'public_key'],
+ })).data[0];
+ return { userId, publicKey: r.public_key, keyVersion: r.public_key_version };
+ }
+
+ // Omit conversationId for a new 1:1; pass the id to rotate later
+ const prepared = chat.prepareConversationKeyChange({
+ senderId: 'YOUR_USER_ID',
+ signingKeyVersion,
+ publicKeys: [
+ await publicKeyInput('YOUR_USER_ID'),
+ await publicKeyInput('RECIPIENT_USER_ID'),
+ ],
+ });
+ const resp = await client.chat.addConversationKeys('RECIPIENT_USER_ID', {
+ conversation_key_version: prepared.conversationKeyVersion,
+ conversation_participant_keys: prepared.participantKeys.map((pk) => ({
+ user_id: pk.userId,
+ encrypted_conversation_key: pk.encryptedKey,
+ public_key_version: pk.publicKeyVersion,
+ })),
+ action_signatures: prepared.actionSignatures.map((sig) => ({
+ message_id: sig.messageId,
+ encoded_message_event_detail: sig.encodedMessageEventDetail,
+ message_event_signature: {
+ signature: sig.signature,
+ public_key_version: sig.publicKeyVersion,
+ signature_version: sig.signatureVersion,
+ },
+ })),
+ });
+ const conversationId = resp.data.conversation_id; // canonical id for later requests
+ const sequenceId = resp.data.sequence_id;
+ const convKey = prepared.conversationKey;
+ const convKeyVersion = prepared.conversationKeyVersion;
+ ```
+
+
+ ```rust
+ // public_key_inputs: Vec from GET public keys
+ // (user_id, public_key, key_version ← public_key_version)
+ // new 1:1; set params.conversation_id = Some(id) to rotate later
+ let prepared = chat.prepare_conversation_key_change(
+ ConversationKeyChangeParams::new(&sender_id, &signing_key_version, public_key_inputs),
+ )?;
+ let participant_keys: Vec<_> = prepared
+ .participant_keys
+ .iter()
+ .map(|pk| {
+ serde_json::json!({
+ "user_id": pk.user_id,
+ "encrypted_conversation_key": pk.encrypted_key,
+ "public_key_version": pk.public_key_version,
+ })
+ })
+ .collect();
+ let action_signatures: Vec<_> = prepared
+ .action_signatures
+ .iter()
+ .map(|sig| {
+ serde_json::json!({
+ "message_id": sig.message_id,
+ "encoded_message_event_detail": sig.encoded_message_event_detail,
+ "message_event_signature": {
+ "signature": sig.signature,
+ "public_key_version": sig.public_key_version,
+ "signature_version": sig.signature_version,
+ },
+ })
+ })
+ .collect();
+ let body = serde_json::json!({
+ "conversation_key_version": prepared.conversation_key_version,
+ "conversation_participant_keys": participant_keys,
+ "action_signatures": action_signatures,
+ });
+ let resp: serde_json::Value = http
+ .post(format!("https://api.x.com/2/chat/conversations/{recipient_id}/keys"))
+ .header("Authorization", &auth)
+ .json(&body)
+ .send()?
+ .json()?;
+ // Canonical id for later requests
+ let conversation_id = resp["data"]["conversation_id"].as_str().unwrap().to_string();
+ // conversation_key is Option; encrypt_message wants owned bytes
+ let conv_key = prepared.conversation_key.expect("key present").to_bytes();
+ let conv_key_version = prepared.conversation_key_version;
+ ```
+
+
+ ```go
+ // KeyVersion comes from the public_key_version field on each record
+ prepared, err := chat.PrepareConversationKeyChange(chatxdk.ConversationKeyChangeParams{
+ SenderID: myUserID,
+ SigningKeyVersion: signingKeyVersion,
+ PublicKeys: []chatxdk.PublicKeyInput{
+ {UserID: myUserID, PublicKey: myIdentityPubB64, KeyVersion: myKeyVersion},
+ {UserID: recipientID, PublicKey: theirIdentityPubB64, KeyVersion: theirKeyVersion},
+ },
+ // ConversationID empty for a new 1:1; pass the id to rotate later
+ })
+ var parts []map[string]string
+ for _, pk := range prepared.ParticipantKeys {
+ parts = append(parts, map[string]string{
+ "user_id": pk.UserID,
+ "encrypted_conversation_key": pk.EncryptedKey,
+ "public_key_version": pk.PublicKeyVersion,
+ })
+ }
+ var sigs []map[string]any
+ for _, sig := range prepared.ActionSignatures {
+ sigs = append(sigs, map[string]any{
+ "message_id": sig.MessageID,
+ "encoded_message_event_detail": sig.EncodedMessageEventDetail,
+ "message_event_signature": map[string]string{
+ "signature": sig.Signature,
+ "public_key_version": sig.PublicKeyVersion,
+ "signature_version": sig.SignatureVersion,
+ },
+ })
+ }
+ body, _ := json.Marshal(map[string]any{
+ "conversation_key_version": prepared.ConversationKeyVersion,
+ "conversation_participant_keys": parts,
+ "action_signatures": sigs,
+ })
+ req, _ := http.NewRequest(http.MethodPost,
+ "https://api.x.com/2/chat/conversations/"+recipientID+"/keys",
+ bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := httpClient.Do(req)
+ // Response data.conversation_id is the canonical id for later requests
+ // prepared.ConversationKey feeds EncryptMessage
+ _ = resp
+ ```
+
+
+ ```csharp
+ // KeyVersion comes from the public_key_version field on each record
+ var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams {
+ SenderId = myUserId,
+ SigningKeyVersion = signingKeyVersion,
+ PublicKeys = new[] {
+ new PublicKeyInput { UserId = myUserId, PublicKey = myPk, KeyVersion = myVer },
+ new PublicKeyInput { UserId = recipientId, PublicKey = theirPk, KeyVersion = theirVer },
+ },
+ }); // ConversationId null for a new 1:1; pass the id to rotate later
+ var keysBody = new {
+ conversation_key_version = prepared.ConversationKeyVersion,
+ conversation_participant_keys = prepared.ParticipantKeys.Select(pk => new {
+ user_id = pk.UserId,
+ encrypted_conversation_key = pk.EncryptedKey,
+ public_key_version = pk.PublicKeyVersion,
+ }),
+ action_signatures = prepared.ActionSignatures.Select(sig => new {
+ message_id = sig.MessageId,
+ encoded_message_event_detail = sig.EncodedMessageEventDetail,
+ message_event_signature = new {
+ signature = sig.Signature,
+ public_key_version = sig.PublicKeyVersion,
+ signature_version = sig.SignatureVersion,
+ },
+ }),
+ };
+ var json = System.Text.Json.JsonSerializer.Serialize(keysBody);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var resp = await http.PostAsync(
+ $"https://api.x.com/2/chat/conversations/{Uri.EscapeDataString(recipientId)}/keys",
+ content);
+ resp.EnsureSuccessStatusCode();
+ var data = System.Text.Json.JsonDocument.Parse(await resp.Content.ReadAsStringAsync())
+ .RootElement.GetProperty("data");
+ string conversationId = data.GetProperty("conversation_id").GetString()!; // canonical id
+ byte[] convKey = prepared.ConversationKey!;
+ string convKeyVersion = prepared.ConversationKeyVersion;
+ ```
+
+
+ ```java
+ // keyVersion comes from the public_key_version field on each record
+ PublicKeyInput mine = new PublicKeyInput();
+ mine.userId = myUserId; mine.publicKey = myIdentityPubB64; mine.keyVersion = myKeyVersion;
+ PublicKeyInput theirs = new PublicKeyInput();
+ theirs.userId = recipientId; theirs.publicKey = theirIdentityPubB64; theirs.keyVersion = theirKeyVersion;
+
+ ConversationKeyChangeParams keyParams = new ConversationKeyChangeParams();
+ keyParams.senderId = myUserId;
+ keyParams.signingKeyVersion = signingKeyVersion;
+ keyParams.publicKeys = List.of(mine, theirs);
+ // keyParams.conversationId null for a new 1:1; set the id to rotate later
+ PreparedConversationChange prepared = chat.prepareConversationKeyChange(keyParams);
+
+ List
+
+
+---
+
+## 5. Send a message
+
+Encrypt with the **raw** conversation key bytes. On the send request, map:
+
+| Chat XDK field | Request body field |
+|:---------------|:-------------------|
+| `encrypted_content` / `encryptedContent` / `EncryptedContent` | `encoded_message_create_event` |
+| `encoded_event_signature` / `encodedEventSignature` / `EncodedEventSignature` | `encoded_message_event_signature` |
+| Your generated id | `message_id` |
+
+Use a **hyphenated** conversation id in the URL path when the API requires it (`:` → `-`). The SDK itself is flexible: `encrypt_message` and `encrypt_reply` accept the id in any form you hold—`A:B` from events, `A-B` from listings or URL paths (in either order), or just the recipient's user id—and canonicalize it before signing. Group ids (prefixed with `g`) pass through unchanged.
+
+
+
+ ```python
+ import uuid
+ from xdk.chat.models import SendMessageRequest
+
+ message_id = str(uuid.uuid4())
+ payload = chat.encrypt_message(
+ message_id,
+ "YOUR_USER_ID",
+ "CONVERSATION_ID",
+ conv_key,
+ "Hello!",
+ conv_key_version,
+ signing_key_version,
+ )
+ client.chat.send_message(
+ "RECIPIENT_USER_ID",
+ SendMessageRequest(
+ message_id=message_id,
+ encoded_message_create_event=payload.encrypted_content,
+ encoded_message_event_signature=payload.encoded_event_signature,
+ ),
+ )
+ ```
+
+
+ ```typescript
+ import { randomUUID } from 'crypto';
+
+ const messageId = randomUUID();
+ const payload = chat.encryptMessage({
+ messageId,
+ senderId: 'YOUR_USER_ID',
+ conversationId: 'CONVERSATION_ID',
+ conversationKey: convKey,
+ text: 'Hello!',
+ conversationKeyVersion: convKeyVersion,
+ signingKeyVersion,
+ });
+ await client.chat.sendMessage('RECIPIENT_USER_ID', {
+ message_id: messageId,
+ encoded_message_create_event: payload.encryptedContent,
+ encoded_message_event_signature: payload.encodedEventSignature,
+ });
+ ```
+
+
+ ```rust
+ use chat_xdk_core::EncryptMessageParams;
+
+ let message_id = uuid::Uuid::new_v4().to_string();
+ let payload = chat.encrypt_message(EncryptMessageParams::new(
+ &message_id,
+ &sender_id,
+ &conversation_id,
+ conv_key,
+ "Hello!",
+ &conv_key_version,
+ &signing_key_version,
+ ))?;
+ let body = serde_json::json!({
+ "message_id": message_id,
+ "encoded_message_create_event": payload.encrypted_content,
+ "encoded_message_event_signature": payload.encoded_event_signature,
+ });
+ let path_id = conversation_id.replace(':', "-");
+ http.post(format!("https://api.x.com/2/chat/conversations/{path_id}/messages"))
+ .header("Authorization", &auth)
+ .json(&body)
+ .send()?;
+ ```
+
+
+ ```go
+ messageID := uuid.NewString()
+ payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
+ MessageID: messageID,
+ SenderID: senderID,
+ ConversationID: conversationID,
+ ConversationKey: convKey,
+ Text: "Hello!",
+ ConversationKeyVersion: convKeyVersion,
+ SigningKeyVersion: signingKeyVersion,
+ })
+ body, _ := json.Marshal(map[string]string{
+ "message_id": messageID,
+ "encoded_message_create_event": payload.EncryptedContent,
+ "encoded_message_event_signature": payload.EncodedEventSignature,
+ })
+ pathID := strings.ReplaceAll(conversationID, ":", "-")
+ req, _ := http.NewRequest(http.MethodPost,
+ "https://api.x.com/2/chat/conversations/"+pathID+"/messages",
+ bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := httpClient.Do(req)
+ _ = resp
+ ```
+
+
+ ```csharp
+ var messageId = Guid.NewGuid().ToString();
+ var payload = chat.EncryptMessage(new EncryptMessageParams {
+ MessageId = messageId,
+ SenderId = senderId,
+ ConversationId = conversationId,
+ ConversationKey = convKey,
+ Text = "Hello!",
+ ConversationKeyVersion = convKeyVersion,
+ SigningKeyVersion = signingKeyVersion,
+ });
+ var sendJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary {
+ ["message_id"] = messageId,
+ ["encoded_message_create_event"] = payload.EncryptedContent,
+ ["encoded_message_event_signature"] = payload.EncodedEventSignature,
+ });
+ using var content = new StringContent(sendJson, Encoding.UTF8, "application/json");
+ var pathId = conversationId.Replace(':', '-');
+ using var resp = await http.PostAsync(
+ $"https://api.x.com/2/chat/conversations/{Uri.EscapeDataString(pathId)}/messages",
+ content);
+ resp.EnsureSuccessStatusCode();
+ ```
+
+
+ ```java
+ EncryptMessageParams params = new EncryptMessageParams();
+ params.messageId = UUID.randomUUID().toString();
+ params.senderId = senderId;
+ params.conversationId = conversationId;
+ params.conversationKey = convKey;
+ params.text = "Hello!";
+ params.conversationKeyVersion = convKeyVersion;
+ params.signingKeyVersion = signingKeyVersion;
+ SendPayload payload = chat.encryptMessage(params);
+
+ String pathId = conversationId.replace(':', '-');
+ String sendJson = new ObjectMapper().writeValueAsString(Map.of(
+ "message_id", params.messageId,
+ "encoded_message_create_event", payload.encryptedContent,
+ "encoded_message_event_signature", payload.encodedEventSignature));
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create("https://api.x.com/2/chat/conversations/" + pathId + "/messages"))
+ .header("Authorization", "Bearer " + accessToken)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(sendJson))
+ .build();
+ http.send(req, HttpResponse.BodyHandlers.ofString());
+ ```
+
+
+
+---
+
+## 6. Receive and decrypt
+
+Use [webhooks or the activity stream](/xchat/real-time-events) for live traffic, or page conversation **events** for history.
+
+- Live payload fields: `encoded_event`, optional `conversation_key_change_event`
+- History: `GET /2/chat/conversations/{id}/events` — prefer **`decrypt_events`** on all events plus `meta.conversation_key_events`
+- Pass the sender’s public keys into decrypt for signature verification (map API fields into `SigningKeyEntry`; see [Chat XDK](/xchat/xchat-xdk))
+- JavaScript uses camelCase event types (`message`); other languages use `"Message"` and snake_case fields in JSON
+
+
+
+ ```python
+ conversation_keys = {} # conversation_id -> { version: key_bytes }
+
+ def signing_keys_for(user_id: str) -> list[dict]:
+ resp = client.chat.get_user_public_keys(
+ user_id,
+ public_key_fields=[
+ "public_key_version", "public_key", "signing_public_key", "identity_public_key_signature",
+ ],
+ )
+ return [
+ {
+ "user_id": user_id,
+ "public_key_version": r["public_key_version"],
+ "public_key": r["signing_public_key"],
+ "identity_public_key": r["public_key"],
+ "identity_public_key_signature": r["identity_public_key_signature"],
+ }
+ for r in resp.data
+ ]
+
+ def handle_payload(payload: dict):
+ cid = payload["conversation_id"]
+ if payload.get("conversation_key_change_event"):
+ conversation_keys[cid] = chat.extract_conversation_keys(
+ [payload["conversation_key_change_event"]]
+ )["keys"]
+ event = chat.decrypt_event(
+ payload["encoded_event"],
+ conversation_keys.get(cid, {}),
+ signing_keys_for(payload["sender_id"]),
+ )
+ if event.get("type") == "Message" and event.get("content", {}).get("content_type") == "Text":
+ print(event["sender_id"], event["content"]["text"], event.get("verified"))
+ ```
+
+
+ ```typescript
+ const conversationKeys = new Map>();
+
+ async function signingKeysFor(userId: string) {
+ const resp = await client.chat.getUserPublicKeys(userId, {
+ publicKeyFields: [
+ 'public_key_version', 'public_key', 'signing_public_key', 'identity_public_key_signature',
+ ],
+ });
+ return resp.data.map((r: {
+ public_key_version: string;
+ public_key: string;
+ signing_public_key: string;
+ identity_public_key_signature: string;
+ }) => ({
+ userId,
+ publicKeyVersion: r.public_key_version,
+ publicKey: r.signing_public_key,
+ identityPublicKey: r.public_key,
+ identityPublicKeySignature: r.identity_public_key_signature,
+ }));
+ }
+
+ async function handlePayload(payload: {
+ conversation_id: string;
+ encoded_event: string;
+ sender_id: string;
+ conversation_key_change_event?: string;
+ }) {
+ const cid = payload.conversation_id;
+ if (payload.conversation_key_change_event) {
+ conversationKeys.set(
+ cid,
+ chat.extractConversationKeys([payload.conversation_key_change_event]).keys,
+ );
+ }
+ const event = chat.decryptEvent(
+ payload.encoded_event,
+ conversationKeys.get(cid) ?? {},
+ await signingKeysFor(payload.sender_id),
+ );
+ if (event.type === 'message' && event.content?.contentType === 'text') {
+ console.log(event.senderId, event.content.text, event.verified);
+ }
+ }
+ ```
+
+
+ ```rust
+ // Build Vec from GET /2/users/{id}/public_keys
+ // (public_key_version, public_key, signing_public_key, identity_public_key_signature)
+
+ if let Some(kc) = key_change_b64.as_deref() {
+ let extracted = chat.extract_conversation_keys(&[kc]);
+ conv_keys.extend(extracted.keys);
+ }
+ let event = chat.decrypt_event(&encoded_event, &conv_keys, &sender_signing_keys)?;
+ ```
+
+
+ ```go
+ if keyChange != "" {
+ extracted, _ := chat.ExtractConversationKeys([]string{keyChange})
+ for v, k := range extracted.Keys {
+ convKeys[v] = k
+ }
+ }
+ event, err := chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys)
+ if err == nil && event.Type == "Message" {
+ fmt.Println(event.AsMessage().Text())
+ }
+ ```
+
+
+ ```csharp
+ if (!string.IsNullOrEmpty(keyChangeB64))
+ {
+ var extracted = chat.ExtractConversationKeys(new[] { keyChangeB64 });
+ foreach (var kv in extracted.Keys)
+ convKeys[kv.Key] = kv.Value;
+ }
+ var evt = chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys);
+ if (evt.GetProperty("type").GetString() == "Message")
+ Console.WriteLine(evt.GetProperty("content").GetProperty("text").GetString());
+ ```
+
+
+ ```java
+ if (keyChangeB64 != null && !keyChangeB64.isEmpty()) {
+ var extracted = chat.extractConversationKeys(List.of(keyChangeB64));
+ convKeys.putAll(extracted.keys);
+ }
+ JsonNode evt = chat.decryptEvent(encodedEvent, convKeys, senderSigningKeys);
+ if ("Message".equals(evt.path("type").asText())) {
+ System.out.println(evt.path("content").path("text").asText());
+ }
+ ```
+
+
+
+Complete poll-and-reply bots for every language: [chat-xdk/examples](https://github.com/xdevplatform/chat-xdk/tree/main/examples).
+
+---
+
+## Best practices
+
+- Cache raw conversation keys and sender public keys; refresh on signature verification failures
+- Deduplicate live deliveries with `event_uuid`
+- Page event history until pagination is complete so key-change metadata is not missed
+- Do not log PINs, private keys, or message plaintext in production
+- In web apps, keep OAuth tokens (and Juicebox realm token minting) on a server; prefer holding private keys only in the client Chat XDK
+
+---
+
+## Next steps
+
+
+
+ Methods and types for all language bindings
+
+
+ Encrypted images and file attachments
+
+
+ Multi-participant conversations and metadata
+
+
+ Webhooks and activity delivery
+
+
diff --git a/xchat/groups.mdx b/xchat/groups.mdx
new file mode 100644
index 000000000..7b882a4d5
--- /dev/null
+++ b/xchat/groups.mdx
@@ -0,0 +1,192 @@
+---
+title: Group conversations
+sidebarTitle: Groups
+description: Multi-participant X Chat conversations, keys, and encrypted metadata
+keywords: ["X Chat groups", "group DM", "conversation keys", "group name encryption"]
+---
+
+Group chats use the **same encryption model** as 1:1 X Chat: one **conversation key** shared by members, wrapped to each member’s **identity public key**, with messages encrypted and signed by the Chat XDK. What changes is **membership**, **how you create the thread**, and often **encrypted title/avatar** fields on the conversation.
+
+1:1 flows are in [Getting Started](/xchat/getting-started). Endpoint details are under **API reference → Conversations and messages**.
+
+---
+
+## How groups differ from 1:1
+
+| Topic | 1:1 | Group |
+|:------|:----|:------|
+| Identity | Often addressed by peer user id in paths | Conversation id typically starts with `g` |
+| Create | Keys + messaging to a user | Create / initialize group APIs, then keys |
+| Participants | You + one peer | Many users; membership can change |
+| Metadata | Minimal | Name, avatar, etc. may be **ciphertext** (decrypt with the conversation key) |
+| Key rotation | Less frequent | Common when people join or leave |
+
+Crypto is still: **Chat XDK** for keys and payloads; **X API** to create the group, publish participant key wraps, send messages, and load events.
+
+---
+
+## Create the group and establish keys
+
+1. Mint the group id with `POST /2/chat/conversations/group/initialize` — the response's `data.conversation_id` is the g-prefixed id you use everywhere below.
+2. Load each member's identity public key and `public_key_version` (`GET` public-key routes under **Encryption keys**; [`GET /2/users/public_keys`](/x-api/users/get-public-keys-for-multiple-users) fetches several users in one request). Verify each record with `verify_key_binding` before using it (see the warning in [Getting Started](/xchat/getting-started#4-set-up-conversation-keys)).
+3. Run **`prepare_group_create`** once, with **all** members (including yourself), the g-prefixed id, and the member/admin id lists. One call generates the conversation key, wraps it for every member, and signs the create — it returns **two** action signatures (the conversation-key change and the group create).
+4. `POST /2/chat/conversations/group` with the group members/admins, `conversation_key_version`, `conversation_participant_keys` (SDK **`encrypted_key`** → API **`encrypted_conversation_key`**), and **both** `action_signatures`. Validation failures come back as stable, human-readable messages, for example `"Too many members: adding these members would exceed the allowed group size."` or `"Cannot add all members: one or more of the requested members cannot be added to this conversation."`.
+5. Keep the **raw** conversation key and **version** for encrypt/decrypt.
+
+The `title` and `avatar_url` you pass to `prepare_group_create` are signed and embedded verbatim in the group-create event, and the server matches them against the request — so the `group_name` / `group_avatar_url` values in the POST body must be **byte-identical** to what you passed the SDK, or the call fails signature validation.
+
+
+
+ ```python
+ prepared = chat.prepare_group_create(
+ "YOUR_USER_ID", signing_key_version, member_public_keys,
+ group_id, # g-prefixed id from POST /2/chat/conversations/group/initialize
+ member_ids, admin_ids, title="Project team",
+ )
+ # POST /2/chat/conversations/group with group_members, group_admins,
+ # conversation_key_version, conversation_participant_keys, and BOTH
+ # entries of prepared["action_signatures"]
+ ```
+
+
+ ```typescript
+ const prepared = chat.prepareGroupCreate({
+ senderId: myUserId, signingKeyVersion, publicKeys: memberPublicKeys,
+ conversationId: groupId, // g-prefixed id from POST /2/chat/conversations/group/initialize
+ memberIds, adminIds, title: 'Project team',
+ });
+ // prepared.actionSignatures has two entries — send both
+ ```
+
+
+ ```rust
+ let mut params = GroupCreateParams::new(
+ &sender_id, &signing_key_version, member_public_keys,
+ &group_id, member_ids, admin_ids,
+ );
+ params.title = Some("Project team".into());
+ let prepared = chat.prepare_group_create(params)?;
+ // prepared.action_signatures has two entries — send both
+ ```
+
+
+ ```go
+ prepared, err := chat.PrepareGroupCreate(chatxdk.GroupCreateParams{
+ SenderID: myUserID, SigningKeyVersion: signingKeyVersion,
+ PublicKeys: memberPublicKeys, ConversationID: groupID,
+ MemberIDs: memberIDs, AdminIDs: adminIDs, Title: "Project team",
+ })
+ // prepared.ActionSignatures has two entries — send both
+ _ = prepared
+ _ = err
+ ```
+
+
+ ```csharp
+ var prepared = chat.PrepareGroupCreate(new GroupCreateParams {
+ SenderId = myUserId, SigningKeyVersion = signingKeyVersion,
+ PublicKeys = memberPublicKeys, ConversationId = groupId,
+ MemberIds = memberIds, AdminIds = adminIds, Title = "Project team",
+ });
+ // prepared.ActionSignatures has two entries — send both
+ ```
+
+
+ ```java
+ GroupCreateParams params = new GroupCreateParams();
+ params.senderId = myUserId;
+ params.signingKeyVersion = signingKeyVersion;
+ params.publicKeys = memberPublicKeys;
+ params.conversationId = groupId;
+ params.memberIds = memberIds;
+ params.adminIds = adminIds;
+ params.title = "Project team";
+ PreparedConversationChange prepared = chat.prepareGroupCreate(params);
+ // prepared.actionSignatures has two entries — send both
+ ```
+
+
+
+The body mapping for participant keys and action signatures (`message_id`, `encoded_message_event_detail`, nested `message_event_signature`) is the same as the keys POST in [Getting Started — conversation keys](/xchat/getting-started#4-set-up-conversation-keys).
+
+When membership changes, call **`prepare_group_members_change`** with the new member ids plus the current roster (members, admins, pending members, and the current title/avatar/TTL if set). It rotates the conversation key and, like group create, returns **two** action signatures — POST all of it to **add members** (`POST /2/chat/conversations/{id}/members`). Then expect **key-change** traffic: treat it like [key rotation in Getting Started](/xchat/getting-started#6-receive-and-decrypt) (`extract_conversation_keys` / `decrypt_events`, then encrypt with the latest version).
+
+Because `prepare_group_members_change` generates a **fresh** conversation key wrapped only to the roster you pass, new members receive the new key version and cannot decrypt messages sent under earlier versions. The reverse is not true: rotation never revokes access to **earlier** versions — anyone who already holds an old key can still read the messages encrypted under it. If you suspect a conversation key was exposed, rotate with `prepare_conversation_key_change`; this protects future messages only.
+
+---
+
+## Encrypted group metadata
+
+Some conversation fields (for example display **name** or **avatar URL**) may arrive **encrypted** under the conversation key. That is **not** `encrypt_message`; it is the generic Chat XDK **`encrypt` / `decrypt`** pair (UTF-8 string in, base64 ciphertext out, with the **raw** conversation key).
+
+Whether a given field is stored encrypted is decided by the client that writes it: `prepare_group_create` signs and sends the title exactly as you provide it (the conversation key does not exist until that call generates it, so a create-time title cannot be encrypted under it). When you read a conversation whose fields are ciphertext, decrypt them with `decrypt` and the key version that was active when the field was written.
+
+
+
+ ```python
+ # Decrypt a field from the conversation object (name may vary by API shape)
+ group_name = chat.decrypt(conversation["group_name"], raw_conv_key)
+
+ # Encrypt before update if your API accepts ciphertext metadata
+ encrypted_name = chat.encrypt("Project team", raw_conv_key)
+ ```
+
+
+ ```typescript
+ const groupName = chat.decrypt(conversation.groupName, rawConvKey);
+ const encryptedName = chat.encrypt('Project team', rawConvKey);
+ ```
+
+
+ ```rust
+ // conv_key: &XChatConversationKey from extract_conversation_keys / decrypt_conversation_key
+ let group_name = chat.decrypt(&conversation_group_name_b64, &conv_key)?;
+ let encrypted_name = chat.encrypt("Project team", &conv_key)?;
+ ```
+
+
+ ```go
+ groupName, err := chat.Decrypt(conversationGroupNameB64, rawConvKey)
+ encryptedName, err := chat.Encrypt("Project team", rawConvKey)
+ _ = groupName
+ _ = encryptedName
+ ```
+
+
+ ```csharp
+ string groupName = chat.Decrypt(conversationGroupNameB64, rawConvKey);
+ string encryptedName = chat.Encrypt("Project team", rawConvKey);
+ ```
+
+
+ ```java
+ String groupName = chat.decrypt(conversationGroupNameB64, rawConvKey);
+ String encryptedName = chat.encrypt("Project team", rawConvKey);
+ ```
+
+
+
+Use the **current** conversation key version that applies to that metadata. If keys rotated, decrypt with the version that was active when the field was written (or follow product rules if metadata is always rewritten on rotation).
+
+---
+
+## Messages and events
+
+Sending and receiving in a group is the same as 1:1 once you have the raw conversation key:
+
+- **Send:** `encrypt_message` → send-message API ([Getting Started](/xchat/getting-started#5-send-a-message))
+- **Receive:** events API or [real-time delivery](/xchat/real-time-events) → `decrypt_event` / `decrypt_events`
+- **Media:** [Media](/xchat/media) with the group conversation id
+
+Always encrypt with the **latest** key version after a membership-driven rotation.
+
+---
+
+## Checklist
+
+1. Mint the g-prefixed id with `POST /2/chat/conversations/group/initialize`
+2. `prepare_group_create` with **every** member; POST participant key wraps and **both** action signatures to `POST /2/chat/conversations/group`
+3. Cache raw key + version; update on key-change events
+4. On membership changes, `prepare_group_members_change` (two signatures) → `POST /2/chat/conversations/{id}/members`
+5. Decrypt group metadata with `decrypt` when fields are ciphertext
+6. Send/receive with the same patterns as 1:1
diff --git a/xchat/introduction.mdx b/xchat/introduction.mdx
new file mode 100644
index 000000000..a40b83332
--- /dev/null
+++ b/xchat/introduction.mdx
@@ -0,0 +1,56 @@
+---
+title: Introduction to Chat API
+sidebarTitle: Introduction
+description: End-to-end encrypted messaging on X for developers
+keywords: ["Chat API", "encrypted DM", "E2EE", "Chat XDK"]
+---
+
+The **Chat API** lets you send and receive **end-to-end encrypted** direct messages on X. Message bodies are encrypted on the client; X routes ciphertext and cannot read plaintext content. Messages are also **signed** so recipients can verify the sender.
+
+---
+
+## What you need in your app
+
+| Piece | Responsibility |
+|:------|:---------------|
+| **[Chat XDK](/xchat/xchat-xdk)** | Generate keys, encrypt/decrypt, sign/verify, optional Juicebox PIN storage (Python, JS, Rust, Go, C#, Java) |
+| **X API access** | Public keys, conversation keys, messages, events, media—via **[XDK](/xdks/python/overview)** (Python/TypeScript) or HTTPS |
+| **Delivery** | [Webhooks or activity stream](/xchat/real-time-events) for live events; events API for history |
+
+Follow **[Getting Started](/xchat/getting-started)** for a full implementation. For concepts only, see the **[Cryptography primer](/xchat/cryptography-primer)**.
+
+---
+
+## How encryption works (overview)
+
+1. Create **identity** and **signing** keypairs; store private keys securely (Juicebox or protected blob).
+2. **Publish public keys** so others can wrap conversation keys to you and verify signatures.
+3. Share a **conversation key** by posting encrypted copies for each participant.
+4. **Encrypt and sign** outbound messages; send only ciphertext to X.
+5. **Receive** ciphertext via webhooks, stream, or event history.
+6. **Verify and decrypt** with the Chat XDK.
+
+---
+
+## Useful endpoints
+
+Grouped under **API reference** in the sidebar, including:
+
+- Public keys — register and fetch
+- Conversations and messages — list/get threads, add/rotate keys, events, send, typing, read, group membership
+- Media — upload and download encrypted attachments ([guide](/xchat/media))
+
+---
+
+## Auth notes
+
+Use **OAuth 2.0 user context** with DM-related scopes (`dm.read`, `dm.write`, plus `users.read` / `tweet.read` as required; `media.write` for uploads). X Chat activity for a user requires that user’s authorization. Juicebox config for PIN storage is returned on **your** public-key record (`juicebox_config` field)—see Getting Started.
+
+---
+
+## Next steps
+
+1. [Cryptography primer](/xchat/cryptography-primer) — optional background on E2EE concepts
+2. [Getting Started](/xchat/getting-started) — implement keys, send, and receive
+3. [Chat XDK](/xchat/xchat-xdk) — encryption SDK reference
+4. [Real-time events](/xchat/real-time-events), [Media](/xchat/media), or [Troubleshooting](/xchat/troubleshooting) when you need those topics
diff --git a/xchat/media.mdx b/xchat/media.mdx
new file mode 100644
index 000000000..b074e065f
--- /dev/null
+++ b/xchat/media.mdx
@@ -0,0 +1,366 @@
+---
+title: Media and attachments
+sidebarTitle: Media
+description: Encrypt, upload, send, download, and decrypt images and files in X Chat
+keywords: ["X Chat media", "encrypted images", "attachments", "encrypt_stream", "media upload"]
+---
+
+Images and other files use the **same conversation key** as text. Encrypt bytes with the Chat XDK (`encrypt_stream` / `decrypt_stream`), upload via the **`/2/chat/media/upload`** routes (sidebar **API reference → Media**), then attach **`media_hash_key`** on `encrypt_message`.
+
+Include **`media.write`** with your DM scopes when uploading. Use hyphenated conversation ids in paths (`:` → `-`). Prefer MIME/dimensions from **decrypted** bytes.
+
+This path is **not** the Posts media model (`expansions=attachments.media_keys`, `media.fields=variants`, etc.). Those parameters apply to **Posts**; E2EE X Chat blobs are addressed by **`media_hash_key`** and X Chat media download.
+
+```mermaid
+flowchart LR
+ A[Plain bytes] --> B[encrypt_stream]
+ B --> C[Upload 3 steps]
+ C --> D[media_hash_key]
+ D --> E[encrypt_message + send]
+ F[GET media] --> G[decrypt_stream]
+ G --> H[Plain bytes]
+```
+
+---
+
+## Encrypt
+
+
+
+ ```python
+ from chat_xdk import detect_mime_type, detect_image_dimensions
+
+ with open("photo.jpg", "rb") as f:
+ plaintext = f.read()
+
+ mime = detect_mime_type(plaintext)
+ dims = detect_image_dimensions(plaintext)
+ width, height = dims if dims else (0, 0)
+
+ encrypted_blob = chat.encrypt_stream(plaintext, raw_conv_key)
+ ```
+
+
+ ```typescript
+ import { detectMimeType, detectImageDimensions } from '@xdevplatform/chat-xdk';
+ import { readFile } from 'fs/promises';
+
+ const plaintext = await readFile('photo.jpg');
+ const mime = detectMimeType(plaintext);
+ const dims = detectImageDimensions(plaintext);
+ const width = dims?.width ?? 0;
+ const height = dims?.height ?? 0;
+
+ const encryptedBlob = chat.encryptStream(plaintext, rawConvKey);
+ ```
+
+
+ ```rust
+ use chat_xdk_core::{detect_image_dimensions, detect_mime_type};
+
+ let plaintext = std::fs::read("photo.jpg")?;
+ let _mime = detect_mime_type(&plaintext);
+ let dims = detect_image_dimensions(&plaintext);
+ let (width, height) = dims.map(|d| (d.width as i64, d.height as i64)).unwrap_or((0, 0));
+ // conv_key: &XChatConversationKey from extract_conversation_keys / decrypt_conversation_key
+ let encrypted_blob = chat.encrypt_stream(&plaintext, &conv_key)?;
+ ```
+
+
+ ```go
+ plaintext, err := os.ReadFile("photo.jpg")
+ mime, _ := chatxdk.DetectMimeType(plaintext)
+ dims, _ := chatxdk.DetectImageDimensions(plaintext)
+ _ = mime
+ encrypted, err := chat.EncryptStream(plaintext, rawConvKey)
+ _ = dims
+ _ = encrypted
+ ```
+
+
+ ```csharp
+ using ChatXdk;
+
+ byte[] plaintext = await File.ReadAllBytesAsync("photo.jpg");
+ string? mime = ChatXdkUtilities.DetectMimeType(plaintext);
+ var dims = ChatXdkUtilities.DetectImageDimensions(plaintext);
+ int width = (int)(dims?.Width ?? 0);
+ int height = (int)(dims?.Height ?? 0);
+ byte[] encryptedBlob = chat.EncryptStream(plaintext, rawConvKey);
+ ```
+
+
+ ```java
+ import com.xdevplatform.chatxdk.ChatXdkUtilities;
+ import com.xdevplatform.chatxdk.Types.ImageDimensions;
+
+ byte[] plaintext = Files.readAllBytes(Path.of("photo.jpg"));
+ String mime = ChatXdkUtilities.detectMimeType(plaintext);
+ ImageDimensions dims = ChatXdkUtilities.detectImageDimensions(plaintext);
+ int width = dims != null ? (int) dims.width : 0;
+ int height = dims != null ? (int) dims.height : 0;
+ byte[] encryptedBlob = chat.encryptStream(plaintext, rawConvKey);
+ ```
+
+
+
+`encrypt_stream` / `decrypt_stream` process the whole payload in memory. For large files, `stream_encryptor()` / `stream_decryptor()` return incremental objects (`StreamEncryptor` / `StreamDecryptor`): feed chunks with `push`, then call `finish` once—`finish` errors if the stream was truncated.
+
+---
+
+## Upload
+
+| Step | Method | Path |
+|:-----|:-------|:-----|
+| Initialize | `POST` | `/2/chat/media/upload/initialize` |
+| Append | `POST` | `/2/chat/media/upload/{id}/append` |
+| Finalize | `POST` | `/2/chat/media/upload/{id}/finalize` |
+
+Use the request bodies on the OpenAPI pages under **API reference → Media**. Prefer **encrypted** blob size where size is required. Finalize yields **`media_hash_key`** for attachments and download. Retry transient `5xx` with backoff. Python/TypeScript may use the XDK when media helpers exist; otherwise POST with a Bearer token in any language.
+
+---
+
+## Send with an attachment
+
+Encrypt with a media attachment, then POST the send-message body (same field mapping as [Getting Started](/xchat/getting-started#5-send-a-message)).
+
+
+
+ ```python
+ import uuid
+ from xdk.chat.models import SendMessageRequest
+
+ message_id = str(uuid.uuid4())
+ payload = chat.encrypt_message(
+ message_id,
+ sender_id,
+ conversation_id,
+ raw_conv_key,
+ caption or "",
+ conversation_key_version,
+ signing_key_version,
+ attachments=[{
+ "attachment_type": "media",
+ "media_hash_key": media_hash_key,
+ "width": width,
+ "height": height,
+ "filesize_bytes": len(plaintext),
+ "filename": "photo.jpg",
+ }],
+ )
+ client.chat.send_message(
+ conversation_id.replace(":", "-"),
+ SendMessageRequest(
+ message_id=message_id,
+ encoded_message_create_event=payload.encrypted_content,
+ encoded_message_event_signature=payload.encoded_event_signature,
+ ),
+ )
+ ```
+
+
+ ```typescript
+ const messageId = crypto.randomUUID();
+ const payload = chat.encryptMessage({
+ messageId,
+ senderId,
+ conversationId,
+ conversationKey: rawConvKey,
+ text: caption || '',
+ conversationKeyVersion,
+ signingKeyVersion,
+ attachments: [{
+ attachmentType: 'media',
+ mediaHashKey: mediaHashKey,
+ width,
+ height,
+ filesizeBytes: plaintext.byteLength,
+ filename: 'photo.jpg',
+ }],
+ });
+ await client.chat.sendMessage(conversationId.replace(/:/g, '-'), {
+ message_id: messageId,
+ encoded_message_create_event: payload.encryptedContent,
+ encoded_message_event_signature: payload.encodedEventSignature,
+ });
+ ```
+
+
+ ```rust
+ // Set attachments on EncryptMessageParams per chat_xdk_core AttachmentDescriptor::Media
+ let payload = chat.encrypt_message(params_with_media_attachment)?;
+ let body = serde_json::json!({
+ "message_id": message_id,
+ "encoded_message_create_event": payload.encrypted_content,
+ "encoded_message_event_signature": payload.encoded_event_signature,
+ });
+ let path_id = conversation_id.replace(':', "-");
+ http.post(format!("https://api.x.com/2/chat/conversations/{path_id}/messages"))
+ .header("Authorization", &auth)
+ .json(&body)
+ .send()?;
+ ```
+
+
+ ```go
+ payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
+ MessageID: messageID, SenderID: senderID, ConversationID: conversationID,
+ ConversationKey: rawConvKey, Text: caption,
+ ConversationKeyVersion: conversationKeyVersion, SigningKeyVersion: signingKeyVersion,
+ Attachments: []chatxdk.AttachmentDescriptor{{
+ AttachmentType: "media",
+ MediaHashKey: mediaHashKey,
+ Width: width,
+ Height: height,
+ FilesizeBytes: int64(len(plaintext)),
+ Filename: "photo.jpg",
+ }},
+ })
+ // POST payload.EncryptedContent / EncodedEventSignature to /2/chat/conversations/{id}/messages
+ ```
+
+
+ ```csharp
+ var payload = chat.EncryptMessage(new EncryptMessageParams {
+ MessageId = messageId,
+ SenderId = senderId,
+ ConversationId = conversationId,
+ ConversationKey = rawConvKey,
+ Text = caption ?? "",
+ ConversationKeyVersion = conversationKeyVersion,
+ SigningKeyVersion = signingKeyVersion,
+ // Attachments = media descriptor with MediaHashKey, Width, Height,
+ // FilesizeBytes, and Filename (as in the Go tab above)
+ });
+ // POST EncryptedContent / EncodedEventSignature as for text messages
+ ```
+
+
+ ```java
+ EncryptMessageParams params = new EncryptMessageParams();
+ params.messageId = messageId;
+ params.senderId = senderId;
+ params.conversationId = conversationId;
+ params.conversationKey = rawConvKey;
+ params.text = caption != null ? caption : "";
+ params.conversationKeyVersion = conversationKeyVersion;
+ params.signingKeyVersion = signingKeyVersion;
+ // params.attachments — media type with mediaHashKey, width, height, filename
+ SendPayload payload = chat.encryptMessage(params);
+ // POST to /2/chat/conversations/{id}/messages
+ ```
+
+
+
+---
+
+## Download and decrypt
+
+Path: [`GET /2/chat/media/{conversation_id}/{media_hash_key}`](/x-api/chat/download-chat-media). Response body is ciphertext. On inbound messages, read `media_hash_key` from decrypted attachments / `media_hashes`.
+
+**Pick the key by the event's key version.** Each decrypted message event carries the `keyVersion` (JS; `key_version` in the other bindings) its content was encrypted under. Decrypt an attachment with the conversation key for **that** version—`conversationKeys.keys[event.keyVersion]`—not the latest. After a key rotation (for example a member add), the latest key cannot decrypt media attached to older messages.
+
+
+
+ ```python
+ keys = result["conversation_keys"]["keys"]
+ key_for_media = keys[event["key_version"]] # not the latest version
+ plaintext = chat.decrypt_stream(encrypted_blob, key_for_media)
+ ```
+
+
+ ```typescript
+ const keys = result.conversationKeys.keys;
+ const keyForMedia = keys[event.keyVersion]; // not the latest version
+ const plaintext = chat.decryptStream(encryptedBlob, keyForMedia);
+ ```
+
+
+
+
+
+ ```python
+ import requests
+ from chat_xdk import detect_mime_type
+
+ api_id = conversation_id.replace(":", "-")
+ url = f"https://api.x.com/2/chat/media/{api_id}/{media_hash_key}"
+ r = requests.get(url, headers={"Authorization": f"Bearer {access_token}"})
+ r.raise_for_status()
+
+ plaintext = chat.decrypt_stream(r.content, raw_conv_key)
+ mime = detect_mime_type(plaintext) or "application/octet-stream"
+ ```
+
+
+ ```typescript
+ import { detectMimeType } from '@xdevplatform/chat-xdk';
+
+ const apiId = conversationId.replace(/:/g, '-');
+ const res = await fetch(
+ `https://api.x.com/2/chat/media/${apiId}/${mediaHashKey}`,
+ { headers: { Authorization: `Bearer ${accessToken}` } },
+ );
+ const encryptedBlob = new Uint8Array(await res.arrayBuffer());
+ const plaintext = chat.decryptStream(encryptedBlob, rawConvKey);
+ const mime = detectMimeType(plaintext) ?? 'application/octet-stream';
+ ```
+
+
+ ```rust
+ let api_id = conversation_id.replace(':', "-");
+ let encrypted_blob = http
+ .get(format!("https://api.x.com/2/chat/media/{api_id}/{media_hash_key}"))
+ .header("Authorization", &auth)
+ .send()?
+ .bytes()?;
+ // conv_key: &XChatConversationKey from extract_conversation_keys / decrypt_conversation_key
+ let plaintext = chat.decrypt_stream(&encrypted_blob, &conv_key)?;
+ ```
+
+
+ ```go
+ url := fmt.Sprintf("https://api.x.com/2/chat/media/%s/%s",
+ strings.ReplaceAll(conversationID, ":", "-"), mediaHashKey)
+ req, _ := http.NewRequest(http.MethodGet, url, nil)
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ resp, err := http.DefaultClient.Do(req)
+ // read body into []byte → chat.DecryptStream(encryptedBlob, rawConvKey)
+ _ = resp
+ _ = err
+ ```
+
+
+ ```csharp
+ var apiId = conversationId.Replace(':', '-');
+ byte[] encryptedBlob = await http.GetByteArrayAsync(
+ $"https://api.x.com/2/chat/media/{apiId}/{mediaHashKey}");
+ byte[] plaintext = chat.DecryptStream(encryptedBlob, rawConvKey);
+ string? mime = ChatXdkUtilities.DetectMimeType(plaintext);
+ ```
+
+
+ ```java
+ String apiId = conversationId.replace(':', '-');
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create("https://api.x.com/2/chat/media/" + apiId + "/" + mediaHashKey))
+ .header("Authorization", "Bearer " + accessToken)
+ .GET()
+ .build();
+ byte[] encryptedBlob = http.send(req, HttpResponse.BodyHandlers.ofByteArray()).body();
+ byte[] plaintext = chat.decryptStream(encryptedBlob, rawConvKey);
+ String mime = ChatXdkUtilities.detectMimeType(plaintext);
+ ```
+
+
+
+---
+
+## Tips
+
+- Use the same **conversation key version** as when the media was encrypted
+- Do not log plaintext media or raw keys
+- Detect MIME **after** decrypt
+- Web clients: encrypt/decrypt on the client when possible; keep OAuth tokens on your server
+
+Full request and response schemas for each media route are under **API reference → Media** in the sidebar (initialize upload, append chunk, finalize upload, and download media).
diff --git a/xchat/real-time-events.mdx b/xchat/real-time-events.mdx
new file mode 100644
index 000000000..0aa9603d9
--- /dev/null
+++ b/xchat/real-time-events.mdx
@@ -0,0 +1,261 @@
+---
+title: Real-time X Chat Events
+sidebarTitle: Real-time events
+description: Receive encrypted chat events via webhooks or the activity stream
+---
+
+X delivers **`chat.received`**, **`chat.sent`**, and related X Chat activity with **ciphertext** in the payload. Decrypt with the [Chat XDK](/xchat/xchat-xdk).
+
+| Layer | Role |
+|:------|:-----|
+| **Activity API** | `GET /2/activity/stream`; `POST` / `GET` / `PUT` / `DELETE` `/2/activity/subscriptions` (see OpenAPI security per operation) |
+| **Account Activity webhooks** | Optional `/2/account_activity/webhooks/{webhook_id}` subscription routes if you terminate on your own HTTPS URL |
+| **Chat XDK** | `extract_conversation_keys`, `decrypt_event` / `decrypt_events` |
+
+Private X Chat event types need authorization for the user you monitor. Encrypted X Chat file attachments use **`media_hash_key`** and X Chat media download—not Post API `expansions=attachments.media_keys` / `media.fields=variants`.
+
+---
+
+## Event types
+
+| Event | When |
+|:------|:-----|
+| `chat.received` | Subscribed user receives an encrypted DM |
+| `chat.sent` | Subscribed user sends an encrypted DM |
+| `chat.conversation_join` | Subscribed user joins a group (when offered) |
+
+---
+
+## 1. Choose delivery
+
+**Activity stream (often simplest for bots):** `GET /2/activity/stream` with an app Bearer token (optional `backfill_minutes`, `start_time`, `end_time` per OpenAPI). Filter client-side for `chat.received` / `chat.sent`.
+
+**Activity subscriptions:** manage durable subscriptions with:
+
+- `POST /2/activity/subscriptions` — create
+- `GET /2/activity/subscriptions` — list (paginated)
+- `PUT /2/activity/subscriptions/{subscription_id}` — update
+- `DELETE /2/activity/subscriptions/{subscription_id}` or `DELETE /2/activity/subscriptions?ids=` — delete
+
+Request bodies and required scopes are defined in the OpenAPI operation for each route (typically Bearer for app-level XAA management).
+
+**Webhooks (Account Activity-style):** if you terminate events on your HTTPS endpoint, register a webhook, pass CRC challenges, then create subscriptions under `/2/account_activity/webhooks/{webhook_id}/subscriptions/all` (see Account Activity operations in OpenAPI). Python/TypeScript XDK may expose helpers for webhooks and activity when your SDK version includes them.
+
+
+
+ ```python
+ from xdk import Client
+
+ # Stream (app token) — exact helper names depend on your XDK version
+ stream_client = Client(bearer_token="YOUR_BEARER_TOKEN")
+ # for event in stream_client.activity.stream():
+ # handle_payload(event) # see "Decrypt with the Chat XDK" below
+
+ # Or create a subscription (check XDK for create_subscription vs activity API)
+ client = Client(bearer_token="YOUR_BEARER_TOKEN")
+ client.activity.create_subscription({
+ "event_type": "chat.received",
+ "filter": {"user_id": "USER_ID_TO_MONITOR"},
+ })
+ ```
+
+
+ ```typescript
+ import { Client } from '@xdevplatform/xdk';
+
+ const client = new Client({ bearerToken: 'YOUR_BEARER_TOKEN' });
+ await client.activity.createSubscription({
+ event_type: 'chat.received',
+ filter: { user_id: 'USER_ID_TO_MONITOR' },
+ });
+ // Stream: client.activity.stream() when available in your SDK version
+ ```
+
+
+
+Subscribe to `chat.sent` as well if you need outbound copies. Other languages: call the same `/2/activity/*` HTTPS routes with a Bearer token.
+
+---
+
+## 2. CRC (webhooks only)
+
+If you use Account Activity webhooks, respond to Challenge-Response Checks (GET `crc_token`) with HMAC-SHA256 of the token using your consumer secret, in the JSON shape your webhook product expects (typically `sha256=`).
+
+---
+
+## 3. Decrypt with the Chat XDK
+
+Live fields: **`payload.encoded_event`**, optional **`payload.conversation_key_change_event`**. Deduplicate on **`event_uuid`**.
+
+JavaScript uses camelCase event types (`message`); other bindings use `"Message"` and snake_case fields.
+
+
+
+ ```python
+ from chat_xdk import Chat
+
+ chat = Chat(JUICEBOX_CONFIG_JSON)
+ chat.unlock("YOUR_PIN")
+ chat.set_key_version(SIGNING_KEY_VERSION)
+ conversation_keys = {}
+
+ def signing_keys(user_id: str):
+ resp = api_client.chat.get_user_public_keys(
+ user_id,
+ public_key_fields=[
+ "public_key_version", "public_key", "signing_public_key", "identity_public_key_signature",
+ ],
+ )
+ return [
+ {
+ "user_id": user_id,
+ "public_key_version": r["public_key_version"],
+ "public_key": r["signing_public_key"],
+ "identity_public_key": r["public_key"],
+ "identity_public_key_signature": r["identity_public_key_signature"],
+ }
+ for r in resp.data
+ ]
+
+ data = body.get("data") or {}
+ if data.get("event_type") in ("chat.received", "chat.sent"):
+ p = data.get("payload") or {}
+ cid = p.get("conversation_id")
+ if p.get("conversation_key_change_event"):
+ conversation_keys[cid] = chat.extract_conversation_keys(
+ [p["conversation_key_change_event"]]
+ )["keys"]
+ ev = chat.decrypt_event(
+ p["encoded_event"],
+ conversation_keys.get(cid, {}),
+ signing_keys(p["sender_id"]),
+ )
+ ```
+
+
+ ```typescript
+ import { createChat } from '@xdevplatform/chat-xdk';
+
+ const chat = await createChat({
+ juiceboxConfig: JUICEBOX_CONFIG_JSON,
+ getAuthToken: async (realmId) => getRealmToken(realmId),
+ });
+ await chat.unlock('YOUR_PIN');
+ chat.setKeyVersion(SIGNING_KEY_VERSION);
+ const conversationKeys = new Map>();
+
+ async function signingKeys(userId: string) {
+ const resp = await apiClient.chat.getUserPublicKeys(userId, {
+ publicKeyFields: [
+ 'public_key_version', 'public_key', 'signing_public_key', 'identity_public_key_signature',
+ ],
+ });
+ return resp.data.map((r: any) => ({
+ userId,
+ publicKeyVersion: r.public_key_version,
+ publicKey: r.signing_public_key,
+ identityPublicKey: r.public_key,
+ identityPublicKeySignature: r.identity_public_key_signature,
+ }));
+ }
+
+ const data = body?.data ?? {};
+ if (data.event_type === 'chat.received' || data.event_type === 'chat.sent') {
+ const p = data.payload ?? {};
+ const cid = p.conversation_id as string;
+ if (p.conversation_key_change_event) {
+ conversationKeys.set(
+ cid,
+ chat.extractConversationKeys([p.conversation_key_change_event]).keys,
+ );
+ }
+ const ev = chat.decryptEvent(
+ p.encoded_event,
+ conversationKeys.get(cid) ?? {},
+ await signingKeys(p.sender_id),
+ );
+ }
+ ```
+
+
+ ```rust
+ // chat: ChatCore or Chat, already unlocked / keys imported
+ if let Some(kc) = key_change.as_deref() {
+ let extracted = chat.extract_conversation_keys(&[kc]);
+ conv_keys.extend(extracted.keys);
+ }
+ // sender_signing_keys from GET /2/users/{sender_id}/public_keys
+ let event = chat.decrypt_event(&encoded_event, &conv_keys, &sender_signing_keys)?;
+ ```
+
+
+ ```go
+ if keyChange != "" {
+ extracted, _ := chat.ExtractConversationKeys([]string{keyChange})
+ for v, k := range extracted.Keys {
+ convKeys[v] = k
+ }
+ }
+ event, err := chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys)
+ if err == nil && event.Type == "Message" {
+ fmt.Println(event.AsMessage().Text())
+ }
+ ```
+
+
+ ```csharp
+ if (!string.IsNullOrEmpty(keyChangeB64))
+ {
+ var extracted = chat.ExtractConversationKeys(new[] { keyChangeB64 });
+ foreach (var kv in extracted.Keys)
+ convKeys[kv.Key] = kv.Value;
+ }
+ var evt = chat.DecryptEvent(encodedEvent, convKeys, senderSigningKeys);
+ if (evt.GetProperty("type").GetString() == "Message")
+ Console.WriteLine(evt.GetProperty("content").GetProperty("text").GetString());
+ ```
+
+
+ ```java
+ if (keyChangeB64 != null && !keyChangeB64.isEmpty()) {
+ var extracted = chat.extractConversationKeys(List.of(keyChangeB64));
+ convKeys.putAll(extracted.keys);
+ }
+ JsonNode evt = chat.decryptEvent(encodedEvent, convKeys, senderSigningKeys);
+ if ("Message".equals(evt.path("type").asText())) {
+ System.out.println(evt.path("content").path("text").asText());
+ }
+ ```
+
+
+
+History: [`GET /2/chat/conversations/{id}/events`](/x-api/chat/get-chat-conversation-events) + **`decrypt_events`** — see [Getting Started](/xchat/getting-started#6-receive-and-decrypt).
+
+---
+
+## Payload shape (live)
+
+```json
+{
+ "data": {
+ "event_type": "chat.received",
+ "event_uuid": "0f52b591-4b7e-4f13-92cd-30e6b2a3f18a",
+ "payload": {
+ "conversation_id": "1215441834412953600-1843439638876491776",
+ "sender_id": "1843439638876491776",
+ "encoded_event": "BASE64_ENCODED_MESSAGE_EVENT",
+ "conversation_key_version": "1782945126642",
+ "conversation_key_change_event": "BASE64_ENCODED_KEY_CHANGE_EVENT"
+ }
+ }
+}
+```
+
+---
+
+## Practices
+
+- Verify webhook signatures per platform requirements
+- Cache conversation keys and sender public keys
+- Apply key-change blobs before decrypting dependent messages
+- Deduplicate on `event_uuid`
diff --git a/xchat/troubleshooting.mdx b/xchat/troubleshooting.mdx
new file mode 100644
index 000000000..75f9e99f7
--- /dev/null
+++ b/xchat/troubleshooting.mdx
@@ -0,0 +1,214 @@
+---
+title: Troubleshooting
+sidebarTitle: Troubleshooting
+description: X Chat encryption and Chat XDK issues
+keywords: ["X Chat errors", "Chat XDK", "decryption", "Juicebox", "encryption"]
+---
+
+This page covers problems that are **specific to X Chat encryption and the Chat XDK**—keys, Juicebox, decrypt/verify, and building encrypted send payloads.
+
+For webhooks, OAuth, HTTP status codes, and rate limits, use the general [X API](/x-api/introduction) and [authentication](/fundamentals/authentication/overview) documentation.
+
+---
+
+## Keys and Juicebox
+
+### Unlock fails (invalid PIN)
+
+- Confirm the PIN matches the one used with `setup`
+- Wait between attempts; realms rate-limit wrong guesses and can lock recovery after too many failures
+
+
+
+ ```python
+ try:
+ chat.unlock(pin)
+ except ValueError as e:
+ print(e) # may mention InvalidPin or guesses remaining
+ ```
+
+
+ ```typescript
+ try {
+ await chat.unlock(pin);
+ } catch (e) {
+ console.error((e as Error).message);
+ }
+ ```
+
+
+ ```rust
+ chat.unlock(pin_bytes).await?;
+ ```
+
+
+ ```go
+ if err := chat.Unlock(pin, juiceboxConfigJSON); err != nil {
+ log.Println(err)
+ }
+ ```
+
+
+ ```csharp
+ try { chat.Unlock(pin, juiceboxConfigJson); }
+ catch (Exception e) { Console.WriteLine(e.Message); }
+ ```
+
+
+ ```java
+ try { chat.unlock(pin, juiceboxConfigJson); }
+ catch (Exception e) { System.out.println(e.getMessage()); }
+ ```
+
+
+
+### Encrypt or decrypt fails because keys are not loaded
+
+Load private keys first, then set the public-key **version** from your record on X.
+
+
+
+ ```python
+ chat.unlock(pin) # or: chat.import_keys(blob)
+ chat.set_key_version(signing_key_version)
+ ```
+
+
+ ```typescript
+ await chat.unlock(pin);
+ chat.setKeyVersion(signingKeyVersion);
+ ```
+
+
+ ```rust
+ chat.import_keys(&blob)?;
+ chat.set_key_version(&signing_key_version);
+ ```
+
+
+ ```go
+ blob, _ := chatxdk.Base64ToBytes(privateKeysB64)
+ _ = chat.ImportKeys(blob)
+ chat.SetKeyVersion(signingKeyVersion)
+ ```
+
+
+ ```csharp
+ chat.ImportKeys(blobBytes);
+ chat.SetKeyVersion(signingKeyVersion);
+ ```
+
+
+ ```java
+ chat.importKeys(blobBytes);
+ chat.setKeyVersion(signingKeyVersion);
+ ```
+
+
+
+### Missing conversation key for a message
+
+You do not have the **raw** key for that message’s `conversation_key_version`.
+
+1. Decrypt key material from `conversation_key_change_event` (live events) or `meta.conversation_key_events` (history) with `extract_conversation_keys`, **or** include those blobs in `decrypt_events`
+2. Confirm conversation keys were added for that version and you are still a participant (see [Getting Started](/xchat/getting-started#4-set-up-conversation-keys))
+
+### Peer has no public keys
+
+They may not have finished onboarding. After they register, load `public_key`, `signing_public_key`, `identity_public_key_signature`, and `public_key_version` from **API reference → Encryption keys**.
+
+---
+
+## Decryption and signatures
+
+### Decrypt fails
+
+- Stale or wrong **raw** conversation key, or wrong key version
+- Incomplete `encoded_event` string
+- Event type is not an encrypted message you can treat as decryptable content
+
+### Signature does not verify
+
+Verification is **fail-closed by default** (`reject_unverified = true`): the SDK already rejects unverified signed events, so a failure here means the verification inputs are wrong, not that you need to turn checking on. Common causes:
+
+- Missing or incomplete signing-key entry for the **sender** (all fields required by the Chat XDK—see the [Chat XDK](/xchat/xchat-xdk) reference)
+- The sender rotated versions—re-fetch their public keys
+- A key version below the accepted floor never verifies
+
+The `set_reject_unverified` setter exists to opt **out** of this default (`false`, not recommended). If you disabled it earlier, restore the fail-closed default:
+
+
+
+ ```python
+ chat.set_reject_unverified(True)
+ ```
+
+
+ ```typescript
+ chat.setRejectUnverified(true);
+ ```
+
+
+ ```rust
+ chat.set_reject_unverified(true);
+ ```
+
+
+ ```go
+ chat.SetRejectUnverified(true)
+ ```
+
+
+ ```csharp
+ chat.SetRejectUnverified(true);
+ ```
+
+
+ ```java
+ chat.setRejectUnverified(true);
+ ```
+
+
+
+### Old events permanently fail verification
+
+Errors like `signature missing or no matching signing key` or an ECDSA mismatch on **old** events are permanent. Signatures are immutable and verified by rebuilding the signed payload from the event itself, so an event that was signed over different bytes (or never signed) fails on every future load—no retry, key refresh, or API call can heal it. Treat these events as tombstones, not retryable errors. Rotating the conversation key starts a clean, verifiable history from that point forward; new messages are unaffected.
+
+---
+
+## Building the send payload
+
+These mistakes are specific to X Chat encryption (not general HTTP errors):
+
+| Issue | Fix |
+|:------|:----|
+| Wrong key bytes | Pass the **raw** conversation key bytes into the Chat XDK, not the encrypted key string from the API |
+| Wrong JSON field names | Map `encrypted_content` → `encoded_message_create_event` and `encoded_event_signature` → `encoded_message_event_signature` |
+| Missing message id | Generate `message_id` yourself and send the same value in the request body |
+| Version mismatch | Align `conversation_key_version` with the key you use; align signing key version with `set_key_version` / your public-key record |
+| Path id form | URL paths still need the hyphenated conversation id (`:` → `-`), but for signing the SDK accepts any form: `A:B`, `A-B` (either order), or the bare recipient user id—all canonicalize to the same signed bytes |
+
+### API returns 400 for a state-changing call
+
+Every state-changing chat call—adding or rotating conversation keys, creating a group, adding members—requires **`action_signatures`** in the request body, validated at the API boundary. A missing or malformed entry (each needs `message_id`, `encoded_message_event_detail`, and a `message_event_signature` with `signature`, `public_key_version`, and `signature_version`) returns an HTTP 400 problem-details response immediately. Use the SDK prepare methods (`prepare_conversation_key_change`, `prepare_group_create`, `prepare_group_members_change`) and send **all** returned signatures—group create and member adds return two.
+
+---
+
+## Media encrypt and decrypt
+
+- Use the **same** conversation key (and version) as the message that references the attachment
+- Treat download responses as **ciphertext** until you run `decrypt_stream`
+- Infer MIME type **after** decrypt; the download `Content-Type` is often not the real image type
+
+Details: [Media](/xchat/media).
+
+---
+
+## Safe debugging
+
+When investigating crypto failures:
+
+- Log conversation ids, event ids, and key **versions** only
+- Do **not** log plaintext, PINs, private keys, or full key blobs
+- Confirm `set_key_version` matches the `public_key_version` on your public-key record
+- For incomplete history, page **all** event pages so key-change metadata is not skipped before decrypting
diff --git a/xchat/xchat-xdk.mdx b/xchat/xchat-xdk.mdx
new file mode 100644
index 000000000..dafcd4297
--- /dev/null
+++ b/xchat/xchat-xdk.mdx
@@ -0,0 +1,812 @@
+---
+title: Chat XDK Reference
+sidebarTitle: Chat XDK
+description: Encryption SDK for X Chat (Python, TypeScript/JS, Rust, Go, C#, Java)
+keywords: ["Chat XDK", "chat-xdk", "encryption SDK", "E2EE SDK"]
+---
+
+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](/xdks/python/overview) or [TypeScript](/xdks/typescript/overview) **XDK**, or with HTTPS and a user access token.
+
+App walkthrough: [Getting Started](/xchat/getting-started). Sample bots: [chat-xdk/examples](https://github.com/xdevplatform/chat-xdk/tree/main/examples).
+
+### Install
+
+
+
+ ```bash
+ pip install chat-xdk
+ ```
+
+
+ ```bash
+ npm install @xdevplatform/chat-xdk
+ ```
+
+
+ ```toml
+ [dependencies]
+ chat-xdk-core = "0.2"
+ # or: chat_xdk for the async Juicebox-oriented crate
+ ```
+
+
+ ```bash
+ go get github.com/xdevplatform/chat-xdk/go/chatxdk
+ ```
+
+
+ ```bash
+ # From the chat-xdk repo: cargo build -p chat-xdk-dotnet --release
+ # Reference the ChatXdk package; place the native library on the loader path (.NET 8+)
+ ```
+
+
+ ```bash
+ # Use com.xdevplatform.chatxdk (JNA) and the same native library as .NET (JDK 17+)
+ # Set jna.library.path to the built chat_xdk_dotnet library
+ ```
+
+
+
+---
+
+## Quick start
+
+Decrypt a backlog, cache keys, decrypt one event, encrypt a reply. Wire the send body to [`POST /2/chat/conversations/{id}/messages`](/x-api/chat/send-chat-message) as in [Getting Started](/xchat/getting-started).
+
+
+
+ ```python
+ from chat_xdk import Chat
+
+ chat = Chat(juicebox_config_json) # or Chat() + import_keys(blob)
+ chat.unlock("YOUR_PIN")
+ chat.set_key_version(signing_key_version)
+
+ result = chat.decrypt_events(raw_events, signing_keys)
+ for dm in result["messages"]:
+ ev = dm["event"]
+ if ev.get("type") == "Message":
+ print(ev.get("sender_id"), ev.get("content", {}).get("text"))
+
+ cached = result["conversation_keys"]["keys"]
+ event = chat.decrypt_event(one_event_b64, cached, sender_signing_keys)
+
+ raw_key = cached[result["conversation_keys"]["latest_version"]]
+ payload = chat.encrypt_message(
+ message_id, sender_id, conversation_id, raw_key, "Hi!",
+ conversation_key_version, signing_key_version,
+ )
+ ```
+
+
+ ```typescript
+ import { createChat } from '@xdevplatform/chat-xdk';
+
+ const chat = await createChat({
+ juiceboxConfig: juiceboxConfigJson,
+ getAuthToken: async (realmId) => getRealmToken(realmId),
+ });
+ await chat.unlock('YOUR_PIN');
+ chat.setKeyVersion(signingKeyVersion);
+
+ const result = chat.decryptEvents(rawEvents, signingKeys);
+ for (const dm of result.messages) {
+ if (dm.event.type === 'message') {
+ console.log(dm.event.senderId, dm.event.content?.text);
+ }
+ }
+
+ const cached = result.conversationKeys.keys;
+ const event = chat.decryptEvent(oneEventB64, cached, senderSigningKeys);
+
+ const rawKey = cached[result.conversationKeys.latestVersion!];
+ const payload = chat.encryptMessage({
+ messageId, senderId, conversationId, conversationKey: rawKey, text: 'Hi!',
+ conversationKeyVersion, signingKeyVersion,
+ });
+ ```
+
+
+ ```rust
+ // ChatCore + import_keys, or chat_xdk::Chat + unlock().await
+ let result = chat.decrypt_events(&raw_events, &signing_keys);
+ let cached = &result.conversation_keys.keys;
+ let event = chat.decrypt_event(one_event_b64, cached, &sender_signing_keys)?;
+ // cached values are XChatConversationKey; encrypt_message wants owned bytes
+ let latest = result.conversation_keys.latest_version.as_deref().unwrap_or_default();
+ let conv_key = cached[latest].to_bytes();
+ let payload = chat.encrypt_message(EncryptMessageParams::new(
+ &message_id, &sender_id, &conversation_id, conv_key, "Hi!",
+ &conversation_key_version, &signing_key_version,
+ ))?;
+ ```
+
+
+ ```go
+ chat := chatxdk.New()
+ defer chat.Close()
+ blob, _ := chatxdk.Base64ToBytes(privateKeysB64)
+ _ = chat.ImportKeys(blob)
+ chat.SetKeyVersion(signingKeyVersion)
+
+ result, err := chat.DecryptEvents(rawEvents, signingKeys)
+ cached := result.ConversationKeys.Keys
+ event, err := chat.DecryptEvent(oneEventB64, cached, senderSigningKeys)
+ rawKey := cached[*result.ConversationKeys.LatestVersion]
+ payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
+ MessageID: messageID, SenderID: senderID, ConversationID: conversationID,
+ ConversationKey: rawKey, Text: "Hi!",
+ ConversationKeyVersion: conversationKeyVersion, SigningKeyVersion: signingKeyVersion,
+ })
+ _ = event
+ _ = payload
+ _ = err
+ ```
+
+
+ ```csharp
+ using var chat = new Chat();
+ chat.ImportKeys(privateKeyBytes);
+ chat.SetKeyVersion(signingKeyVersion);
+
+ var result = chat.DecryptEvents(rawEvents, signingKeys);
+ var cached = result.ConversationKeys.Keys;
+ var evt = chat.DecryptEvent(oneEventB64, cached, senderSigningKeys);
+ var payload = chat.EncryptMessage(new EncryptMessageParams {
+ MessageId = messageId, SenderId = senderId, ConversationId = conversationId,
+ ConversationKey = rawKey, Text = "Hi!",
+ ConversationKeyVersion = conversationKeyVersion, SigningKeyVersion = signingKeyVersion,
+ });
+ ```
+
+
+ ```java
+ try (Chat chat = new Chat()) {
+ chat.importKeys(privateKeyBytes);
+ chat.setKeyVersion(signingKeyVersion);
+
+ DecryptEventsResult result = chat.decryptEvents(rawEvents, signingKeys);
+ Map cached = result.conversationKeys.keys;
+ JsonNode event = chat.decryptEvent(oneEventB64, cached, senderSigningKeys);
+
+ EncryptMessageParams params = new EncryptMessageParams();
+ params.messageId = messageId;
+ params.senderId = senderId;
+ params.conversationId = conversationId;
+ params.conversationKey = rawKey;
+ params.text = "Hi!";
+ params.conversationKeyVersion = conversationKeyVersion;
+ params.signingKeyVersion = signingKeyVersion;
+ SendPayload payload = chat.encryptMessage(params);
+ }
+ ```
+
+
+
+---
+
+## Lifecycle and keys
+
+Construct the SDK, store private keys (Juicebox PIN or a local key blob), register **public** keys with the Chat API, and set your registered **public-key version** after unlock or import. Call `generate_keypairs` once per device/app identity; post the registration payload to the public-keys endpoint. Use `setup` / `unlock` (and related PIN helpers) for Juicebox PIN storage 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 Juicebox. A JS server that wants to avoid a Juicebox round-trip per request should reuse one unlocked `Chat` instance across requests, or run a native binding where key blobs are supported.
+
+
+
+ ```python
+ from chat_xdk import Chat
+
+ # Juicebox (client)
+ chat = Chat(juicebox_config_json)
+ chat.setup("YOUR_PIN") # first time — generates keypairs
+ # chat.unlock("YOUR_PIN") # later sessions
+ chat.set_key_version(version) # from add-public-key / get-public-keys response
+ reg = chat.get_public_keys() # or registration fields from generate_keypairs
+
+ # Key blob (server / bot)
+ chat2 = Chat()
+ chat2.import_keys(secret_blob)
+ chat2.set_key_version(version)
+ blob = chat2.export_keys() # treat as a password
+ ```
+
+
+ ```typescript
+ import { createChat } from '@xdevplatform/chat-xdk';
+
+ const chat = await createChat({
+ juiceboxConfig: juiceboxConfigJson,
+ getAuthToken: async (realmId) => getRealmToken(realmId),
+ });
+ await chat.setup('YOUR_PIN');
+ // await chat.unlock('YOUR_PIN');
+ chat.setKeyVersion(version);
+ const publics = chat.getPublicKeys();
+
+ // JS/WASM stores keys only through Juicebox — there is no raw key
+ // export/import here. For key-blob persistence, use a native binding.
+ ```
+
+
+ ```rust
+ // chat_xdk::Chat — async Juicebox unlock, or ChatCore + import_keys
+ chat.setup("YOUR_PIN").await?;
+ // chat.unlock("YOUR_PIN").await?;
+ chat.set_key_version(&version);
+ let publics = chat.get_public_keys()?;
+ let blob = chat.export_keys()?;
+ chat.import_keys(&blob)?;
+ ```
+
+
+ ```go
+ chat := chatxdk.New()
+ defer chat.Close()
+
+ // Prefer ImportKeys for servers; Juicebox unlock where supported
+ keyBlob, _ := chatxdk.Base64ToBytes(privateKeysB64)
+ if err := chat.ImportKeys(keyBlob); err != nil {
+ log.Fatal(err)
+ }
+ chat.SetKeyVersion(version)
+ publics, err := chat.GetPublicKeys()
+ blob, err := chat.ExportKeys()
+ _ = publics
+ _ = blob
+ _ = err
+ ```
+
+
+ ```csharp
+ using var chat = new Chat();
+ chat.ImportKeys(privateKeyBytes);
+ // or Juicebox setup / unlock when config is available
+ chat.SetKeyVersion(version);
+ var publics = chat.GetPublicKeys();
+ var blob = chat.ExportKeys();
+ ```
+
+
+ ```java
+ try (Chat chat = new Chat()) {
+ chat.importKeys(privateKeyBytes);
+ chat.setKeyVersion(version);
+ var publics = chat.getPublicKeys();
+ byte[] blob = chat.exportKeys();
+ }
+ ```
+
+
+
+The Juicebox 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 Juicebox realm config changes; `is_unlocked` / `has_identity_key` for UI state. Full field lists live in the [chat-xdk repo](https://github.com/xdevplatform/chat-xdk) 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. 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.
+
+| Scenario | Method | Action signatures returned |
+|:---------|:-------|:---------------------------|
+| Start a 1:1 (omit the conversation id—the SDK derives it) or rotate any conversation’s key (pass the id) | `prepare_conversation_key_change` | 1 |
+| Create a group (id minted by `POST /2/chat/conversations/group/initialize`) | `prepare_group_create` | 2—send both |
+| Add members to a group | `prepare_group_members_change` | 2—send both |
+
+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.
+
+
+
+ ```python
+ # One entry per participant public key, from the public-keys API:
+ # participants = [
+ # {"user_id": "1215441834412953600", "public_key": "BASE64_IDENTITY_PUBLIC_KEY", "key_version": "1733889755256"},
+ # {"user_id": "1843439638876491776", "public_key": "BASE64_IDENTITY_PUBLIC_KEY", "key_version": "1766181805686"},
+ # ]
+ prepared = chat.prepare_conversation_key_change(my_user_id, signing_key_version, participants)
+ # prepared["conversation_key"] — raw bytes for encrypt_message
+ # prepared["participant_keys"] — per-user wraps; rename encrypted_key → encrypted_conversation_key on POST
+ # prepared["action_signatures"] — required on the POST body
+
+ extracted = chat.extract_conversation_keys(key_change_blobs)
+ keys = extracted["keys"]
+ latest = extracted["latest_version"]
+ raw = keys[latest]
+
+ one = chat.decrypt_conversation_key(encrypted_blob)
+ ```
+
+
+ ```typescript
+ const prepared = chat.prepareConversationKeyChange({
+ senderId: myUserId, signingKeyVersion, publicKeys: participants,
+ });
+ // prepared.conversationKey — Uint8Array for encryptMessage
+ // prepared.participantKeys / prepared.actionSignatures — POST body fields
+
+ const extracted = chat.extractConversationKeys(keyChangeBlobs);
+ const raw = extracted.keys[extracted.latestVersion!];
+
+ const one = chat.decryptConversationKey(encryptedBlob);
+ ```
+
+
+ ```rust
+ let prepared = chat.prepare_conversation_key_change(
+ ConversationKeyChangeParams::new(&my_user_id, &signing_key_version, participants),
+ )?;
+ let extracted = chat.extract_conversation_keys(&key_change_blobs);
+ let latest = extracted.latest_version.as_deref().unwrap_or_default();
+ let raw = &extracted.keys[latest];
+ let one = chat.decrypt_conversation_key(&encrypted_blob)?;
+ ```
+
+
+ ```go
+ prepared, err := chat.PrepareConversationKeyChange(chatxdk.ConversationKeyChangeParams{
+ SenderID: myUserID, SigningKeyVersion: signingKeyVersion, PublicKeys: participants,
+ })
+ // prepared.ConversationKey feeds EncryptMessage
+ // prepared.ParticipantKeys / prepared.ActionSignatures — POST body fields
+ extracted, err := chat.ExtractConversationKeys(keyChangeBlobs)
+ one, err := chat.DecryptConversationKey(encryptedBlob)
+ _ = prepared
+ _ = extracted
+ _ = one
+ _ = err
+ ```
+
+
+ ```csharp
+ var prepared = chat.PrepareConversationKeyChange(new ConversationKeyChangeParams {
+ SenderId = myUserId, SigningKeyVersion = signingKeyVersion, PublicKeys = participants,
+ });
+ var extracted = chat.ExtractConversationKeys(keyChangeBlobs);
+ var raw = extracted.Keys[extracted.LatestVersion];
+ var one = chat.DecryptConversationKey(encryptedBlob);
+ ```
+
+
+ ```java
+ ConversationKeyChangeParams keyParams = new ConversationKeyChangeParams();
+ keyParams.senderId = myUserId;
+ keyParams.signingKeyVersion = signingKeyVersion;
+ keyParams.publicKeys = participants;
+ PreparedConversationChange prepared = chat.prepareConversationKeyChange(keyParams);
+ ConversationKeyBundle extracted = chat.extractConversationKeys(keyChangeBlobs);
+ byte[] raw = extracted.keys.get(extracted.latestVersion);
+ byte[] one = chat.decryptConversationKey(encryptedBlob);
+ ```
+
+
+
+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](/xchat/groups#create-the-group-and-establish-keys) 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 when you already have a key cache; it raises/throws on failure.
+
+Pass **signing keys** so the SDK can verify senders. Map API public-key fields into `SigningKeyEntry`: `public_key_version` → `public_key_version` (same name), `signing_public_key` → `public_key`, `public_key` → `identity_public_key`, plus `identity_public_key_signature` and `user_id`. Verification is mandatory by default: omitting or passing an empty signing-key list does **not** skip it—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).
+
+
+
+ ```python
+ signing_keys = [{
+ "user_id": uid,
+ "public_key_version": row["public_key_version"],
+ "public_key": row["signing_public_key"],
+ "identity_public_key": row["public_key"],
+ "identity_public_key_signature": row["identity_public_key_signature"],
+ } for row in api_public_keys]
+
+ result = chat.decrypt_events(raw_events, signing_keys)
+ for idx, msg in (result.get("errors") or {}).items():
+ log.warning("event %s failed: %s", idx, msg)
+ for dm in result["messages"]:
+ ev = dm["event"]
+ if ev.get("type") == "Message":
+ text = ev.get("content", {}).get("text")
+
+ cached = result["conversation_keys"]["keys"]
+ live = chat.decrypt_event(one_event_b64, cached, signing_keys_for_sender)
+ ```
+
+
+ ```typescript
+ const signingKeys = apiPublicKeys.map((row) => ({
+ userId: uid,
+ publicKeyVersion: row.public_key_version,
+ publicKey: row.signing_public_key,
+ identityPublicKey: row.public_key,
+ identityPublicKeySignature: row.identity_public_key_signature,
+ }));
+
+ const result = chat.decryptEvents(rawEvents, signingKeys);
+ for (const [idx, msg] of Object.entries(result.errors ?? {})) {
+ console.warn(`event ${idx} failed: ${msg}`);
+ }
+ const cached = result.conversationKeys.keys;
+ const live = chat.decryptEvent(oneEventB64, cached, signingKeysForSender);
+ ```
+
+
+ ```rust
+ let result = chat.decrypt_events(&raw_events, &signing_keys);
+ for (idx, msg) in &result.errors {
+ eprintln!("event {idx} failed: {msg}");
+ }
+ let cached = &result.conversation_keys.keys;
+ let live = chat.decrypt_event(one_event_b64, cached, &signing_keys_for_sender)?;
+ ```
+
+
+ ```go
+ result, err := chat.DecryptEvents(rawEvents, signingKeys)
+ for idx, msg := range result.Errors {
+ log.Printf("event %s failed: %s", idx, msg)
+ }
+ cached := result.ConversationKeys.Keys
+ live, err := chat.DecryptEvent(oneEventB64, cached, signingKeysForSender)
+ _ = live
+ _ = err
+ ```
+
+
+ ```csharp
+ var result = chat.DecryptEvents(rawEvents, signingKeys);
+ foreach (var kv in result.Errors) { /* kv.Key = event index, kv.Value = error */ }
+ var cached = result.ConversationKeys.Keys;
+ var live = chat.DecryptEvent(oneEventB64, cached, signingKeysForSender);
+ ```
+
+
+ ```java
+ DecryptEventsResult result = chat.decryptEvents(rawEvents, signingKeys);
+ Map cached = result.conversationKeys.keys;
+ JsonNode live = chat.decryptEvent(oneEventB64, cached, signingKeysForSender);
+ ```
+
+
+
+---
+
+## Encrypt and send helpers
+
+**`encrypt_message`** builds the signed ciphertext for a text message (optional entities, attachments via `media_hash_key`, TTL, notify flags). Map the returned payload into the send-message body: `encrypted_content` → **`encoded_message_create_event`**, `encoded_event_signature` → **`encoded_message_event_signature`**, plus your **`message_id`**.
+
+Use **`encrypt_reply`**, **`encrypt_add_reaction`**, and **`encrypt_remove_reaction`** for replies and reactions (`sequence_id` targets the parent). **`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](/xchat/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](#conversation-keys).
+
+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.
+
+
+
+ ```python
+ payload = chat.encrypt_message(
+ message_id, sender_id, conversation_id, raw_conversation_key, "Hello",
+ conversation_key_version, signing_key_version,
+ # Optional keyword args: entities, attachments, should_notify, ttl_msec
+ )
+ body = {
+ "message_id": message_id,
+ "encoded_message_create_event": payload["encrypted_content"],
+ "encoded_message_event_signature": payload["encoded_event_signature"],
+ }
+ # POST body to /2/chat/conversations/{id}/messages
+
+ reply = chat.encrypt_reply(
+ reply_message_id, sender_id, conversation_id, raw_conversation_key,
+ "Sounds good", conversation_key_version, signing_key_version,
+ parent_sequence_id, # reply_to_sequence_id — the message being replied to
+ )
+ name_ct = chat.encrypt("Group title", raw_conversation_key)
+ title = chat.decrypt(name_ct, raw_conversation_key)
+ ```
+
+
+ ```typescript
+ const payload = chat.encryptMessage({
+ messageId, senderId, conversationId, conversationKey: rawConversationKey, text: 'Hello',
+ conversationKeyVersion, signingKeyVersion,
+ });
+ const body = {
+ message_id: messageId,
+ encoded_message_create_event: payload.encryptedContent,
+ encoded_message_event_signature: payload.encodedEventSignature,
+ };
+
+ const reply = chat.encryptReply({
+ messageId: replyMessageId, senderId, conversationId, conversationKey: rawConversationKey,
+ text: 'Sounds good', conversationKeyVersion, signingKeyVersion,
+ replyToSequenceId: parentSequenceId, // the message being replied to
+ });
+ const nameCt = chat.encrypt('Group title', rawConversationKey);
+ const title = chat.decrypt(nameCt, rawConversationKey);
+ ```
+
+
+ ```rust
+ // conv_key: XChatConversationKey from extract_conversation_keys / decrypt_conversation_key
+ let payload = chat.encrypt_message(EncryptMessageParams::new(
+ &message_id, &sender_id, &conversation_id, conv_key.to_bytes(), "Hello",
+ &conversation_key_version, &signing_key_version,
+ ))?;
+ // Map payload fields into the send-message JSON body as above
+ let name_ct = chat.encrypt("Group title", &conv_key)?;
+ let title = chat.decrypt(&name_ct, &conv_key)?;
+ ```
+
+
+ ```go
+ payload, err := chat.EncryptMessage(chatxdk.EncryptMessageParams{
+ MessageID: messageID, SenderID: senderID, ConversationID: conversationID,
+ ConversationKey: rawKey, Text: "Hello",
+ ConversationKeyVersion: conversationKeyVersion, SigningKeyVersion: signingKeyVersion,
+ })
+ // body: message_id, encoded_message_create_event, encoded_message_event_signature
+ nameCt, err := chat.Encrypt("Group title", rawKey)
+ title, err := chat.Decrypt(nameCt, rawKey)
+ _ = payload
+ _ = title
+ _ = err
+ ```
+
+
+ ```csharp
+ var payload = chat.EncryptMessage(new EncryptMessageParams {
+ MessageId = messageId, SenderId = senderId, ConversationId = conversationId,
+ ConversationKey = rawKey, Text = "Hello",
+ ConversationKeyVersion = conversationKeyVersion, SigningKeyVersion = signingKeyVersion,
+ });
+ // Map EncryptedContent / EncodedEventSignature into the send-message body
+ var nameCt = chat.Encrypt("Group title", rawKey);
+ var title = chat.Decrypt(nameCt, rawKey);
+ ```
+
+
+ ```java
+ EncryptMessageParams params = new EncryptMessageParams();
+ params.messageId = messageId;
+ params.senderId = senderId;
+ params.conversationId = conversationId;
+ params.conversationKey = rawKey;
+ params.text = "Hello";
+ params.conversationKeyVersion = conversationKeyVersion;
+ params.signingKeyVersion = signingKeyVersion;
+ SendPayload payload = chat.encryptMessage(params);
+ // Map to encoded_message_create_event / encoded_message_event_signature on POST
+
+ String nameCt = chat.encrypt("Group title", rawKey);
+ String title = chat.decrypt(nameCt, rawKey);
+ ```
+
+
+
+---
+
+## 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](/xchat/media).
+
+
+
+ ```python
+ ciphertext = chat.encrypt_stream(file_bytes, raw_conversation_key)
+ # Upload `ciphertext`; the `media_hash_key` you attach on encrypt_message
+ # comes from the media-upload finalize step, not from encrypt_stream.
+
+ plain = chat.decrypt_stream(ciphertext, raw_conversation_key)
+ ```
+
+
+ ```typescript
+ const ciphertext = chat.encryptStream(fileBytes, rawConversationKey);
+ // Upload `ciphertext`; mediaHashKey comes from the upload finalize step.
+ const plain = chat.decryptStream(ciphertext, rawConversationKey);
+ ```
+
+
+ ```rust
+ // conv_key: &XChatConversationKey from extract_conversation_keys / decrypt_conversation_key
+ let ciphertext = chat.encrypt_stream(&file_bytes, &conv_key)?;
+ let plain = chat.decrypt_stream(&ciphertext, &conv_key)?;
+ ```
+
+
+ ```go
+ ciphertext, err := chat.EncryptStream(fileBytes, rawKey)
+ plain, err := chat.DecryptStream(ciphertext, rawKey)
+ _ = plain
+ _ = err
+ ```
+
+
+ ```csharp
+ var ciphertext = chat.EncryptStream(fileBytes, rawKey);
+ var plain = chat.DecryptStream(ciphertext, rawKey);
+ ```
+
+
+ ```java
+ byte[] ciphertext = chat.encryptStream(fileBytes, rawKey);
+ byte[] plain = chat.decryptStream(ciphertext, rawKey);
+ ```
+
+
+
+### 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).
+
+
+
+
+ ```python
+ enc = chat.stream_encryptor(raw_conversation_key)
+ chunks = [enc.push(chunk) for chunk in read_in_chunks(file_bytes, 1 << 20)]
+ chunks.append(enc.finish())
+ ciphertext = b"".join(chunks)
+
+ dec = chat.stream_decryptor(raw_conversation_key)
+ out = [dec.push(chunk) for chunk in read_in_chunks(ciphertext, 1 << 20)]
+ out.append(dec.finish()) # raises on truncation
+ plain = b"".join(out)
+ ```
+
+
+ ```typescript
+ const enc = chat.streamEncryptor(rawConversationKey);
+ const parts: Uint8Array[] = [];
+ try {
+ for (const chunk of readInChunks(fileBytes, 1 << 20)) parts.push(enc.push(chunk));
+ parts.push(enc.finish()); // consumes + frees enc — do not call enc.free() after this
+ } catch (e) {
+ enc.free(); // only when abandoning before finish()
+ throw e;
+ }
+ const ciphertext = concat(parts);
+ ```
+
+
+
+---
+
+## 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.
+
+
+
+ ```python
+ from chat_xdk import (
+ bytes_to_base64, base64_to_bytes, bytes_to_hex, hex_to_bytes,
+ detect_mime_type, detect_image_dimensions,
+ )
+
+ b64 = bytes_to_base64(raw)
+ raw2 = base64_to_bytes(b64)
+ hexed = bytes_to_hex(raw)
+ raw3 = hex_to_bytes(hexed)
+ mime = detect_mime_type(file_bytes)
+ w, h = detect_image_dimensions(file_bytes)
+ ```
+
+
+ ```typescript
+ import { bytesToBase64, base64ToBytes, bytesToHex, hexToBytes, detectMimeType, detectImageDimensions } from '@xdevplatform/chat-xdk';
+
+ const b64 = bytesToBase64(raw);
+ const raw2 = base64ToBytes(b64);
+ const hexed = bytesToHex(raw);
+ const raw3 = hexToBytes(hexed);
+ const mime = detectMimeType(fileBytes);
+ const dims = detectImageDimensions(fileBytes);
+ const width = dims?.width ?? 0;
+ const height = dims?.height ?? 0;
+ ```
+
+
+ ```rust
+ let b64 = chat_xdk_core::bytes_to_base64(&raw);
+ let raw2 = chat_xdk_core::base64_to_bytes(&b64)?;
+ let hexed = chat_xdk_core::bytes_to_hex(&raw);
+ let raw3 = chat_xdk_core::hex_to_bytes(&hexed);
+ let mime = chat_xdk_core::detect_mime_type(&file_bytes);
+ let dims = chat_xdk_core::detect_image_dimensions(&file_bytes);
+ let (w, h) = dims.map(|d| (d.width, d.height)).unwrap_or((0, 0));
+ ```
+
+
+ ```go
+ b64, _ := chatxdk.BytesToBase64(raw)
+ raw2, err := chatxdk.Base64ToBytes(b64)
+ hexed, err := chatxdk.BytesToHex(raw)
+ raw3, err := chatxdk.HexToBytes(hexed)
+ mime, _ := chatxdk.DetectMimeType(fileBytes)
+ dims, _ := chatxdk.DetectImageDimensions(fileBytes)
+ w, h := dims.Width, dims.Height
+ _ = b64
+ _ = raw2
+ _ = hexed
+ _ = raw3
+ _ = mime
+ _ = w
+ _ = h
+ _ = err
+ ```
+
+
+ ```csharp
+ var b64 = ChatXdkUtilities.BytesToBase64(raw);
+ var raw2 = ChatXdkUtilities.Base64ToBytes(b64);
+ var hexed = ChatXdkUtilities.BytesToHex(raw);
+ var raw3 = ChatXdkUtilities.HexToBytes(hexed);
+ var mime = ChatXdkUtilities.DetectMimeType(fileBytes);
+ var dims = ChatXdkUtilities.DetectImageDimensions(fileBytes);
+ var w = dims?.Width ?? 0;
+ var h = dims?.Height ?? 0;
+ ```
+
+
+ ```java
+ String b64 = ChatXdkUtilities.bytesToBase64(raw);
+ byte[] raw2 = ChatXdkUtilities.base64ToBytes(b64);
+ String hexed = ChatXdkUtilities.bytesToHex(raw);
+ byte[] raw3 = ChatXdkUtilities.hexToBytes(hexed);
+ String mime = ChatXdkUtilities.detectMimeType(fileBytes);
+ ImageDimensions wh = ChatXdkUtilities.detectImageDimensions(fileBytes);
+ long width = wh.width, height = wh.height;
+ ```
+
+
+
+---
+
+## 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 related encrypt helpers; 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.
+- **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`.
+
+For complete field lists, use language stubs in the [chat-xdk repo](https://github.com/xdevplatform/chat-xdk) (`docs/API.md`, `*.pyi`, `index.d.ts`).
+
+---
+
+## Errors
+
+Python typically raises **`ValueError`** with a descriptive message (for example an invalid PIN). 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
+
+
+
+ Wire Chat XDK to the Chat API
+
+
+ Stream encrypt and media REST
+
+
+ Webhooks and activity delivery
+
+
+ Common failures
+
+