---
title: XChat
description: XChat (encrypted messaging) adapter using the X API v2 chat endpoints and the X Activity API.
tagline: Hold encrypted 1:1 and group conversations on XChat, with all cryptography handled inside the adapter.
package: @chat-adapter/x
---

# XChat



## Create a new bot

Scaffold a Next.js bot with the XChat adapter using [`create-chat-sdk`](/docs/create-chat-sdk):

<CreateChatSdk adapters="xchat memory" />

The generated app uses the [Memory state adapter](/adapters/official/memory) for local development. To add the adapter to an existing app, start at [Install](#install).

## Install

<PackageInstall package="@chat-adapter/x @chat-adapter/state-memory" />

## Quick start

<Callout type="info">
  The adapter auto-detects `XCHAT_BOT_TOKEN`, `XCHAT_PIN`, and `X_CONSUMER_SECRET` from the environment. The bot's user id and @handle are resolved from `GET /2/users/me` at startup.
</Callout>

```typescript title="lib/bot.ts" lineNumbers
import { Chat } from "chat";
import { createXchatAdapter } from "@chat-adapter/x/chat";
import { createMemoryState } from "@chat-adapter/state-memory";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    xchat: createXchatAdapter(),
  },
  state: createMemoryState(),
});

bot.onDirectMessage(async (thread, message) => {
  await thread.post(`You said: ${message.text}`);
});

bot.onNewMention(async (thread, message) => {
  await thread.post(`You said: ${message.text}`);
});
```

The memory state adapter keeps subscriptions and locks in process memory, which suits local development. Use [Redis](/adapters/official/redis) or [PostgreSQL](/adapters/official/postgres) in production.

Route both GET and POST through the adapter. See [Webhooks](#webhooks) for what each method handles.

```typescript title="app/api/webhooks/xchat/route.ts" lineNumbers
import { after } from "next/server";
import { bot } from "@/lib/bot";

export async function GET(request: Request) {
  return bot.webhooks.xchat(request);
}

export async function POST(request: Request) {
  return bot.webhooks.xchat(request, {
    waitUntil: (task) => after(() => task),
  });
}
```

## XChat adapter vs X adapter

The [`@chat-adapter/x`](https://www.npmjs.com/package/@chat-adapter/x) package contains two adapters for different X surfaces. This page covers the XChat adapter. For public posts, mentions, and classic DMs, use the [X adapter](/adapters/official/x).

|                                     | X adapter                                                                                             | XChat adapter                                                               |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Factory                             | `createXAdapter` from `@chat-adapter/x`                                                               | `createXchatAdapter` from `@chat-adapter/x/chat`                            |
| Conversations                       | Public timeline mentions and replies (`post.mention.create`), and classic unencrypted direct messages | Encrypted XChat 1:1 and group conversations                                 |
| Top-level posts                     | Posts from the bot account to the public timeline (`x:public`)                                        | Not supported                                                               |
| Reactions                           | Likes only                                                                                            | Emoji reactions                                                             |
| Typing indicators and read receipts | Not supported                                                                                         | Supported                                                                   |
| Encryption                          | None                                                                                                  | Handled inside the adapter: Juicebox PIN, key exchange, and signed messages |

You can register both adapters on the same `Chat` instance if your bot needs public posts and DMs as well as encrypted XChat.

## Platform setup

<div className="fd-steps">
  <div className="fd-step">
    ### Register encryption keys

    The bot account needs registered public keys and a Juicebox-backed private-key store before it can participate in encrypted conversations:

    1. Generate keypairs with [`chat-xdk`](https://github.com/xdevplatform/chat-xdk) (`generateKeypairs()`).
    2. Register them via `POST /2/users/{id}/public_keys`.
    3. Store the private keys in Juicebox with a PIN (`chat.setup(pin)`).

    The adapter unlocks the keys at startup with the same PIN (`XCHAT_PIN`). Registration is a one-time step per account.
  </div>

  <div className="fd-step">
    ### Create a webhook

    Register a webhook URL so the [X Activity API](https://docs.x.com/x-api/activity/introduction) can deliver events:

    ```bash
    curl -X POST "https://api.x.com/2/webhooks" \
      -H "Authorization: Bearer $APP_BEARER_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"url": "https://your-domain.com/api/webhooks/xchat"}'
    ```

    X validates the URL with a CRC challenge, so the endpoint must be live before you create the webhook. Webhooks and activity subscriptions can also be managed in the [X Developer Portal](https://developer.x.com/en/portal/dashboard).
  </div>

  <div className="fd-step">
    ### Subscribe to chat events

    Create [activity subscriptions](https://docs.x.com/x-api/activity/create-x-activity-subscription) for the bot user with the bot's OAuth 2.0 user token:

    ```bash
    curl -X POST "https://api.x.com/2/activity/subscriptions" \
      -H "Authorization: Bearer $BOT_USER_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "event_type": "chat.received",
        "filter": {"user_id": "YOUR_BOT_USER_ID"},
        "tag": "bot-chat-received",
        "webhook_id": "YOUR_WEBHOOK_ID"
      }'
    ```

    Subscribe to `chat.conversation_join` as well if you want the bot to post a welcome message when it is added to a group.
  </div>
</div>

## Configuration

<TypeTable
  type={{
  botToken: {
    type: "string",
    description:
      "OAuth 2.0 user access token for the bot account (also accepted as `accessToken`). Auto-detected from `XCHAT_BOT_TOKEN` or `X_ACCESS_TOKEN`.",
  },
  pin: {
    type: "string",
    description:
      "Juicebox PIN. When set, the bot's private keys unlock automatically during `initialize()`. Auto-detected from `XCHAT_PIN`.",
  },
  consumerSecret: {
    type: "string",
    description:
      "App consumer secret for webhook signature verification. Auto-detected from `X_CONSUMER_SECRET`. Required to receive webhooks: X signs every POST, so without it every POST is rejected with 401. Polling deployments do not need it.",
  },
  disableWebhookVerification: {
    type: "boolean",
    description:
      "Accept webhook POSTs without verifying their HMAC. Auto-detected from `X_DISABLE_WEBHOOK_VERIFICATION`. Only use it when an upstream layer verifies the signature. Not recommended in production.",
  },
  editSafetyDelayMs: {
    type: "number",
    default: "5000",
    description:
      "Minimum age (ms) a freshly posted message must reach before its first edit is sent, so receiving clients have stored the original the edit targets. `0` disables the wait.",
  },
  sendReadReceipts: {
    type: "boolean",
    default: "true",
    description:
      "Send a read receipt for each delivered inbound message before handlers run.",
  },
  userName: {
    type: "string",
    description:
      "Bot @handle for mention detection. Auto-detected from `X_BOT_USERNAME`, otherwise resolved from `GET /2/users/me`.",
  },
  welcomeMessage: {
    type: "string | false",
    description:
      "Message posted when the bot joins a group. `false` disables it; when omitted, the adapter posts a default that tells members to @mention the bot for a reply.",
  },
  verifySignatures: {
    type: "boolean",
    default: "true",
    description:
      "Require verifiable signatures on incoming messages. `X_VERIFY_SIGNATURES=false` opts out.",
  },
  signingKeyVersion: {
    type: "string",
    description:
      "Signing key version override. Normally fetched during `initialize()`. Auto-detected from `X_SIGNING_KEY_VERSION`.",
  },
  apiBaseUrl: {
    type: "string",
    default: '"https://api.x.com"',
    description: "Base URL for media REST calls.",
  },
}}
/>

### Environment variables

| Variable                         | Required                            | Description                                                                        |
| -------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------- |
| `XCHAT_BOT_TOKEN`                | Yes, unless `X_ACCESS_TOKEN` is set | OAuth 2.0 user token for the bot account                                           |
| `X_ACCESS_TOKEN`                 | No                                  | Fallback name for the bot token                                                    |
| `XCHAT_PIN`                      | No                                  | Juicebox PIN that unlocks the bot's private keys at startup                        |
| `X_CONSUMER_SECRET`              | To receive webhooks                 | App secret for CRC responses and webhook signature verification                    |
| `X_BOT_USERNAME`                 | No                                  | @handle override. Resolved from `GET /2/users/me` when unset                       |
| `X_VERIFY_SIGNATURES`            | No                                  | Set to `false` to accept messages with unverifiable signatures                     |
| `X_DISABLE_WEBHOOK_VERIFICATION` | No                                  | Set to `true` to skip webhook HMAC verification when an upstream layer verifies it |
| `X_SIGNING_KEY_VERSION`          | No                                  | Signing key version override                                                       |

## Webhooks

The route handles two kinds of request from X:

* GET CRC challenge: the adapter answers with an HMAC-SHA256 of the token keyed by your app's consumer secret. Routing GET through the adapter lets it constrain CRC tokens before signing them.
* POST event delivery: the adapter verifies the `x-twitter-webhooks-signature` header against `X_CONSUMER_SECRET`.

## Encryption

Every XChat conversation is encrypted, and the adapter handles the whole crypto lifecycle: conversation-key extraction and caching, message decryption and signature verification, and encryption and signing on send. Conversation keys arrive through `KeyChange` events and are cached per conversation and key version. Incoming message signatures are verified against participants' signing keys; with `verifySignatures: true` (the default), unverifiable messages are dropped. Media is encrypted separately from message text using streaming encryption.

## Mention behavior in groups

`bot.onNewMention(handler)` fires for group messages that mention the bot. A group message counts as a mention when its rich-text mention entities include the bot's @handle or user ID, when it is a swipe-reply to one of the bot's own messages, or when its plain text contains `@handle` (fallback when no entities are present). To also reply to group messages that don't mention the bot, register `bot.onNewMessage(handler)` without a pattern.

## Open DM

`openDM(userId)` reuses an existing conversation or runs a fresh key exchange so the bot can message first. It requires the recipient to have encrypted chat set up, and the server requires the recipient to trust the bot (for example, follow it) before the first message is accepted.

## Formatting

XChat clients do not render markdown, so outgoing markdown appears literally as plain text. The adapter detects URLs and @mentions in outgoing text and renders them as tappable links and mention pills. Tables render as ASCII code blocks. Cards degrade to text with URL and mention entities plus a URL preview attachment: the first `x.com/.../status/...` URL in outgoing text auto-attaches as a post card.

## Read receipts and typing

A read receipt is sent for each delivered inbound message before handlers run unless `sendReadReceipts: false`. To control the timing yourself, disable automatic receipts and call `thread.markAsRead()` in the handler. XChat advances the conversation's read watermark through the target message sequence. The typing indicator is re-sent every 3 seconds while a handler runs.

```typescript
const xchat = createXchatAdapter({ sendReadReceipts: false });

const bot = new Chat({
  userName: "mybot",
  adapters: { xchat },
});

bot.onDirectMessage(async (thread) => {
  await thread.markAsRead();
});
```

Automatic receipts never interrupt a handler, since a failure is logged and swallowed. A `thread.markAsRead()` call you make yourself rejects on failure, so await it or attach a `.catch()`.

When a delivered message has no sequence id, such as an event the adapter could not decrypt, the watermark advances to the latest event in the conversation. When only an explicit message id is available, the adapter replays recent history and rejects if it cannot resolve that exact message rather than advancing through newer messages.

## Edits, deletes, and streaming

The adapter can edit and delete only the bot's own messages. The first edit of a fresh message is held until the message is `editSafetyDelayMs` old (default 5000ms), so receiving clients have stored the original before the edit arrives. Deletes are delete-for-all. Streaming works through message edits, but the age gate makes rapid token-by-token updates coarse.

## Thread IDs

```
xchat:{conversationId}
```

* 1:1 conversations: `xchat:1234567890-9876543210` (both participant IDs)
* Group conversations: `xchat:g123456789` (opaque `g`-prefixed ID)

REST paths use the other participant's user ID for 1:1 conversations and the `g...` ID for groups; the adapter converts automatically.

## Feature support

<FeatureSupport />
