Skip to main content
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:
Prerequisites
  • Developer account and an app configured for OAuth 2.0
  • User access token with dm.read, dm.write, tweet.read, and users.read

1. Install dependencies

The PyPI package is chatxdk; import it as chat_xdk. Requires Python 3.10+.
Create an API client with your user OAuth 2.0 access token:

2. Initialize the Chat XDK with existing keys

This step loads keys you already have—use it when this identity completed first-time setup before:
  • Secure key backup: construct the SDK with the juicebox_config from your public-key record, then unlock with your passcode to recover the private keys (for example, on a new device).
  • Key blob: import_keys with a blob you previously exported via export_keys, passing the registered key version alongside it (Rust and Go name this variant import_keys_with_version / ImportKeysWithVersion).
Then call set_identity(user_id, signing_key_version) once, with your user ID and your record’s public_key_version. This stores the session identity: every later encrypt and prepare call signs as this identity, so you never pass a sender ID or signing key version per call. Setting up for the first time? Construct the SDK the same way but skip unlock/import_keys, and continue to step 3 to create, back up, and register your keys.
Server and bot samples often use a key blob (export_keys / import_keys). Client apps often use secure key backup (setup / unlock with a passcode). See the Chat XDK reference for both paths.
Bringing your own keys? import_keys only accepts the opaque blob produced by export_keys from the Chat XDK—it is a versioned, private serialization of the full key state, not raw or PEM-encoded P-256 keys. You cannot construct this blob yourself: generate keys through generate_keypairs (step 3), export the blob once, and store it base64-encoded. Hand-crafted or modified blobs fail to import.

3. Create and register keys (first-time setup)

Skip this step if you loaded existing keys in step 2. Otherwise, one-time setup for a new identity does three things:
  1. Create the keypairsgenerate_keypairs produces the identity and signing keypairs.
  2. Store the private keyssetup with a passcode writes them to secure key backup (clients), or export_keys returns a key blob for you to store securely (servers and bots).
  3. Register the public keys — POST the registration payload to the add-public-key endpoint so others can encrypt to you and verify your signatures.
Finish by calling set_identity with the registration’s key version, so this session signs as the new identity.
Ready-to-run one-time registration scripts for every binding live under chat-xdk/examples (Python, TypeScript, Go, Rust, C#, and Java). Use them instead of hand-rolling the flow below when you just need to onboard a new identity.
Use a strong passcode for secure key backup. Losing the passcode or an unprotected key blob can prevent decrypting past messages.

4. Set up conversation keys

Call prepare_conversation_key_change with every participant’s identity public key; the sender identity comes from the session you set in step 2. 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.

5. Send a message

Encrypt with the raw conversation key from step 4. The SDK generates the message id (a UUID), embeds it in the signed event, and returns it on the payload—you never mint one yourself. On the send request, map: 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.
The snippets pass the conversation key explicitly because in this flow you just created it in step 4. Once the key cache is on and a decrypt_events pass has verified the conversation’s key (step 6), encrypt_message(conversation_id, text) alone is enough—the SDK fills in the latest verified key. Retries should resend the same encrypted payload, so an id is never minted twice.

6. Receive and decrypt

Use webhooks or the activity stream 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
  • Decrypting needs the senders’ signing keys so the SDK can verify who wrote each message. These are the other participants’ public keys — fetch them from the same public-keys endpoint you used in step 4 and map the fields into SigningKeyEntry (the snippets below include the mapping)
  • You can pass the signing keys (and, for decrypt_event, the conversation keys) on every call, or set two optional session stores once and use the short call forms. The snippets below use the stores: set_signing_keys(entries) holds the participants’ keys, and set_cache_keys(true) (off by default) keeps each conversation’s latest signature-verified key so later calls can omit key arguments. Both styles verify identically
  • JavaScript uses camelCase event types (message); other languages use "Message" and snake_case fields in JSON
Serverless or multi-instance? The signing-key store and key cache live in the SDK instance’s memory. Where that doesn’t fit—one invocation decrypts, another sends—pass keys explicitly instead: decrypt_events(events, signing_keys), decrypt_event(event_b64, conversation_keys, signing_keys), and the conversation_key/conversation_key_version overrides on the encrypt methods. Persist the conversation_keys returned by decrypt_events yourself and pass them back in.
Complete poll-and-reply bots for every language: chat-xdk/examples.

Best practices

  • Keep the signing-key store fresh: re-call set_signing_keys with the full participant set when a sender registers a new key version, and refresh on signature verification failures
  • Deduplicate live deliveries with event_uuid