---
title: X
description: Publish and read X posts with existing user authorization, explicit media limits, native references, and uncertain-write reconciliation.
---

The direct X adapter uses an existing bearer token and user ID. For a multi-user connection flow, use the server entrypoint's `xOAuth` with `ConnectionManager`; the adapter itself accepts the resulting credentials. Authorization does not transfer between backend instances. Configure the token on your server and treat the user ID as the account identity.

## Setup

1. **Get API access**

    Create an app in the X developer portal and confirm your API plan covers the operations you
    need. Request the `tweet.read`, `tweet.write`, `users.read`, `offline.access`, and `media.write`
    scopes for the implemented operations. Add `dm.read` to read messages and `dm.write` to send
    them. Recent post search and Direct Messages additionally require the access and plan tier that
    X currently assigns to your app.

2. **Store credentials server-side**

    Set `X_USER_ID` and `X_ACCESS_TOKEN` in server configuration. Never ship them to a browser
    bundle.

3. **Configure the adapter**

    Construct the backend with those credentials and select the account through a connected-account
    reference.

```ts
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { x } from "@opencoredev/social-sdk/x";

const social = createSocial({
  backend: x({
    auth: { userId: process.env.X_USER_ID!, accessToken: process.env.X_ACCESS_TOKEN! },
    // Required for full-archive search and the filtered stream.
    appBearerToken: process.env.X_APP_BEARER_TOKEN,
  }),
});

const account = connectedAccountRef({
  backend: "default",
  platform: "x",
  accountId: process.env.X_USER_ID!,
});
```

## Examples

### Publish a text post

Text uses X's weighted 280-character rules rather than JavaScript code-unit length.

```ts
const result = await social.posts.publish({
  targets: [{ account }],
  content: { text: "A server-side update" },
});

console.log(result.outcomes[0]?.state);
```

### Publish with an image

The normalized adapter covers text and static image targets. Images upload as blobs of at most 5 MiB, with up to four images per post.

```ts
const result = await social.posts.publish({
  targets: [{ account }],
  content: {
    text: "Release notes attached",
    media: [
      {
        kind: "image",
        source: { kind: "blob", blob, fingerprint: contentHash },
        mimeType: "image/png",
        byteSize: blob.size,
      },
    ],
  },
});
```

### Publish with a video or GIF

Video (`video/mp4`, up to 512 MiB) and GIF (`image/gif`, up to 15 MiB) upload through the
chunked `INIT → APPEND → FINALIZE → STATUS` flow with 1 MiB segments. Attach exactly one
video or GIF per post. The adapter polls `processing_info` honoring `check_after_secs`, and fails with `timeout`
instead of waiting past the operation budget. A `failed` processing state surfaces as a terminal
`media_error` and never creates a post. X checks video duration only when the post is created and
answers with HTTP 403, which the adapter reports as `missing_permission` with a message naming both
possible causes, because the status alone cannot tell a duration limit from a missing permission.

```ts
const result = await social.posts.publish({
  targets: [{ account }],
  content: {
    text: "Demo clip",
    media: [
      {
        kind: "video",
        source: { kind: "blob", blob: videoBlob, fingerprint: contentHash },
        mimeType: "video/mp4",
        filename: "demo.mp4",
        byteSize: videoBlob.size,
      },
    ],
  },
});
```

### Reconcile an uncertain outcome

Every target result is independent. A successful HTTP response is not treated as a publication unless X returns a tweet ID. A transport failure after dispatch is `unknown`; reconcile before retrying. The adapter does not fail over to another backend.

```ts
const outcome = result.outcomes[0];
if (outcome?.state === "unknown" && outcome.delivery) {
  const delivery = await social.posts.getDelivery(outcome.delivery);
  console.log(delivery.state); // published | failed | unknown
}
```

### Search recent posts

The normalized `search.posts` operation uses X's recent-search endpoint. It accepts a non-empty X
query and returns the provider's post objects in a paginated `Page<JsonObject>`:

```ts
const page = await social.search.posts(account, {
  query: "from:opencoredev has:links -is:retweet",
  limit: 25,
});

for (const post of page.items) console.log(post["id"], post["text"]);
```

Pass `page.nextCursor` unchanged with the same query, scope, and limit to continue. X validates the
query and limits each recent request to its supported `max_results` range (10–100); omit `limit` to
let X choose the default. Set `scope: "all"` only when the app's X plan exposes full-archive
search; that route permits the wider provider limit and query length. Recent search is the default
and covers X's recent index, not the complete historical archive. Retain the provider fields and
`created_at` value if you are building an application index.

Recent search availability, retention, rate limits, and monthly usage depend on the app's X API
plan and project permissions. The SDK does not grant access, widen the retention window, or retry
around a quota. A denied or unavailable route is surfaced as a structured provider error. Search
results are not proof that every matching post exists or remains publicly readable.

### Edit a recent post

The native `updatePost` method edits the text of a published post. It calls `POST /2/tweets` with `edit_options.previous_post_id`, which is X's documented edit path.

```ts
const native = social.native("default", { acknowledgeUnsafe: true });
const edited = await native.updatePost({
  account,
  postId: "1900000000000000001",
  text: "Corrected release notes",
  context,
});

console.log(edited.post.postId); // the new version's ID
console.log(edited.editHistoryPostIds); // oldest first, when X returns it
```

X gives every edited version a new post ID, so store `edited.post` as the current reference. The original ID stays in the edit chain, and deleting any version deletes the whole chain. The method validates the numeric ID and X's weighted text limit before sending, and it only changes text.

X decides eligibility. Its [changelog](https://docs.x.com/changelog) (October 3, 2025) requires X Premium, the account's own post, and a post created within the last hour. The [edit posts guide](https://docs.x.com/x-api/fundamentals/edit-posts) describes a 30-minute window and at most five edits, so read `edit_controls` if you need the current limit for a post. Promoted posts, polls, replies to other accounts, reposts, community posts, collaborative posts, and scheduled posts cannot be edited. A rejected edit surfaces as the mapped provider error, usually `missing_permission` for HTTP 403.

The edit is sent once. A lost response, a server failure after dispatch, or a success response without a new ID raises `ambiguous_outcome` with `reconcile-first`. Read the post and its `edit_history_tweet_ids` before trying again. See [Create or Edit Post](https://docs.x.com/x-api/posts/create-post).

### List replies to a post

`comments.list` returns replies to one of the account's posts. X has no replies endpoint, so the
adapter runs a recent search for `conversation_id:<postId>`, which is the method
[X documents](https://docs.x.com/x-api/fundamentals/conversation-id) for reading a conversation.

```ts
const post = platformPostRef({ ...account, postId: "1700000000000000000" });
const replies = await social.comments.list(post, { limit: 25 });

for (const reply of replies.items) console.log(reply["id"], reply["author_id"], reply["text"]);
```

The page includes nested replies, not only direct ones. Each item keeps `in_reply_to_user_id` and
`referenced_tweets`, so you can rebuild the tree from the `replied_to` references. The root post is
left out. Pass the conversation's first post; a reply's own ID matches nothing because X stores the
root ID as every reply's `conversation_id`.

Recent search sets the limits:

- Only replies from the last 7 days are returned. Older replies are missing, not an error.
- `limit` must be from 10 through 100. Omit it to let X use its default of 10.
- X allows 450 recent search requests per 15 minutes per app and 300 per user.
- The call works with the user token (`tweet.read` and `users.read`) or, without one, the app
  bearer token.
- Post reads count toward the app's X API usage and billing.

The adapter does not fall back to full-archive search and does not retry on a rate limit.

### Like and unlike posts

The X platform entrypoint exports `xLike` and `xUnlike` for credential-ready server code. They require an acting connected-account reference, a native numeric post ID, server-side credentials, and an operation context whose `backendInstance` matches the account. These helpers sit outside `createSocial` authorization and concurrency middleware: authorize the acting account in your application before calling them.

```ts
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { xLike, xUnlike } from "@opencoredev/social-sdk/x";

const account = connectedAccountRef({
  backend: "direct",
  platform: "x",
  accountId: userId,
});

const state = await xLike(
  postId,
  account,
  { userId, accessToken },
  {
    backendInstance: "direct",
    correlationId: "reaction",
    retryBudget: { maxAttempts: 1, maxElapsedMs: 10_000 },
  },
);
console.log(state.liked);
```

Both require the app's current API access and `like.write`, `tweet.read`, and `users.read` scopes. They make one mutation request and return the provider-confirmed `liked` state. A lost response, malformed confirmation, or uncertain server failure throws `ambiguous_outcome`; inspect current reaction state before deciding whether to retry. Unliking removes the acting user's reaction, not the target post. Request contracts: [like a post](https://docs.x.com/x-api/users/like-post), [unlike a post](https://docs.x.com/x-api/users/unlike-post).

### Read profiles and relationships

The normalized graph facade resolves an X profile by ID or handle, lists followers, following,
muted users, and blocked users, and supports follow, unfollow, mute, unmute, block, and unblock mutations. Profile
and relationship records retain the X user ID in `ref.profileId`.

```ts
const profile = await social.graph.getProfile(account, { handle: "alice" });
const following = await social.graph.listRelationships(account, {
  kind: "following",
  limit: 25,
});

if (following.nextCursor) {
  const next = await social.graph.listRelationships(account, {
    kind: "following",
    cursor: following.nextCursor,
  });
}

await social.graph.follow(profile.ref);
await social.graph.mute(profile.ref);
```

These operations require user-context OAuth scopes such as `users.read`, `follows.read`,
`follows.write`, `mute.read`, `mute.write`, and `block.write`, subject to the app's current X API
tier. Block and unblock writes are Enterprise-only in X's API. `kind: "blocked"` remains
available for blocked-list reads when the account has the required `block.read` access.

### Pin lists and send direct messages

The native X module exposes pinned-list mutations and paginated reads, plus direct-message
operations for an existing conversation and for creating a group conversation:

```ts
const native = social.native("default", { acknowledgeUnsafe: true });
await native.pinList({ account, listId: "123", context });
const pinned = await native.pinnedLists({ account, limit: 20, context });
await native.sendConversationMessage({ account, conversationId: "456", text: "Hello", context });
await native.createGroupConversation({
  account,
  participantIds: ["u2", "u3"],
  message: "Welcome",
  context,
});
```

These methods require the corresponding `list.write`, `list.read`, or `dm.write` scope and the
X plan access for the endpoint. `listDirectMessages` returns the standard `Page` shape, including
`nextCursor` when X returns `meta.next_token`.

### Use normalized messages

The normalized messages facade uses X's Direct Messages endpoints with the configured user token:

```ts
const conversations = await social.messages.listConversations(account, { limit: 50 });
const conversationId = conversations.items[0]?.["dm_conversation_id"];
if (typeof conversationId !== "string") throw new Error("No conversation selected");

const conversation = {
  kind: "conversation" as const,
  version: 1 as const,
  backend: account.backend,
  platform: "x" as const,
  accountId: account.accountId,
  conversationId,
};

const messages = await social.messages.listMessages(conversation, { limit: 50 });
await social.messages.send(conversation, { text: "Hello" });
```

X has no conversation-list endpoint. `listConversations` reads `GET /2/dm_events`, keeps the
latest event for each distinct `dm_conversation_id`, and skips conversations that earlier pages
already returned. A walk ends after 1,200 distinct conversations, and X only serves DM events
from the last 30 days. `listMessages` reads `GET /2/dm_conversations/{dm_conversation_id}/dm_events`, and `send`
uses `POST /2/dm_conversations/{dm_conversation_id}/messages`. These methods require a user-context
token with `dm.read`, `users.read`, and `tweet.read`; sending also requires `dm.write`. Availability
still depends on the X API tier and Direct Messages access for the app.

### Receive webhooks

Pass the OAuth 2.0 client secret, or the legacy consumer secret, as `webhookSecret`. `adapter.webhooks.verify` checks `X-Twitter-Webhooks-Signature-OAuth2` when X sends it and falls back to `X-Twitter-Webhooks-Signature` only when that header is absent, so use the OAuth 2.0 client secret once your app has one. Answer the CRC GET with `answerXWebhookChallenge` from `@opencoredev/social-sdk/server`, using the same secret. Account Activity direct messages decode to `message.received`, post deletions to `post.removed`, and app revocations to `account.updated`. See [Process webhooks](/events).

## Native operations

The typed native module also exposes `uploadVideo` and `uploadGif`, which run the same chunked upload and return an attachable `mediaId`, plus user lookup, followers and following, follow and unfollow, likes, mute controls, blocked and muted lists, lists, user and home timelines, mentions, retweet undo, direct-message send and reads, polls, quotes, text edits, deletion, bookmarks, and the filtered stream. Paginated methods return the SDK `Page` shape, so pass `nextCursor` back as `cursor`.

`deleteComment({ account, commentId, context })` deletes a reply post with `DELETE /2/tweets/{id}`. X only allows the authenticated user to delete posts they authored, so it cannot remove replies from other accounts. The adapter raises `ambiguous_outcome` unless X returns `deleted: true`.

Full-archive search (`searchAllPosts`) requires an app-only bearer token. Configure `appBearerToken`; the adapter raises a `missing_permission` `SocialError` before making a request when it is absent. User-scoped methods continue to use the configured OAuth user token.

### Filtered stream

The filtered stream delivers posts that match rules stored on your X app. Rules and the stream
connection both use the app-only `appBearerToken`, so they belong to the app rather than to the
connected account. The `account` argument still passes through your authorization policy.

Manage rules with `listStreamRules`, `addStreamRules`, and `deleteStreamRules`. Pass
`dryRun: true` to have X validate rules without saving them. X reports rejected rules, such as
duplicates or invalid syntax, in `errors` while accepting the rest, so check both `rules` and
`errors` in the result:

```ts
const native = social.native("default", { acknowledgeUnsafe: true });

const update = await native.addStreamRules({
  account,
  rules: [{ value: "from:opencoredev -is:retweet", tag: "announcements" }],
  context,
});
for (const error of update.errors) console.warn(error["title"], error["value"]);

const rules = await native.listStreamRules({ account, context });
await native.deleteStreamRules({ account, ids: rules.items.map((rule) => rule.id), context });
```

`stream` returns an async iterable. Nothing connects until you start iterating, and each iteration
opens exactly one connection. Every event has a `kind`:

- `post` carries the post, the `matchingRules` that selected it, and any `includes` for requested
  expansions.
- `error` carries an error-only message from X, such as an operational disconnect notice.
- `other` carries any other JSON object X sends, unchanged.

Stop the stream with `break` or by aborting `context.signal`. Both close the HTTP connection. An
abort raises a `cancelled` `SocialError`. X sends a keep-alive at least every 20 seconds, so if
no data or keep-alive arrives within `stallTimeoutMs` (default 20,000), the iterator raises a
`timeout` error. When X ends the connection, the loop finishes normally.

The adapter never reconnects on its own. If you want a long-running consumer, write the reconnect
loop yourself and follow X's backoff guidance: after network errors, back off linearly by 250 ms up
to 16 seconds; after HTTP errors, back off exponentially from 5 seconds up to 320 seconds; after a
`rate_limited` error, wait exponentially from 1 minute. Use a fresh operation context for each
connection attempt, because the retry budget's deadline applies to the whole context.

```ts
import { SocialError } from "@opencoredev/social-sdk";

const controller = new AbortController();
let delayMs = 0;

while (!controller.signal.aborted) {
  if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
  const context = {
    backendInstance: "default",
    correlationId: `x-stream-${Date.now()}`,
    retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
    signal: controller.signal,
  };

  try {
    for await (const event of native.stream({
      account,
      context,
      tweetFields: ["created_at", "author_id"],
      expansions: ["author_id"],
    })) {
      delayMs = 0;
      if (event.kind === "post") console.log(event.matchingRules, event.post["text"]);
    }
  } catch (error) {
    if (!(error instanceof SocialError) || error.code === "cancelled") throw error;
    if (error.code === "reconnect_required" || error.code === "missing_permission") throw error;
    if (error.code === "rate_limited") delayMs = Math.max(60_000, delayMs * 2);
    else if (error.code === "timeout" || error.upstreamStatus === undefined)
      delayMs = Math.min(16_000, delayMs + 250);
    else delayMs = Math.min(320_000, Math.max(5_000, delayMs * 2));
  }
}
```

The stream needs X API pay-per-use or Enterprise access. Pay-per-use allows one connection and
1,000 rules of up to 1,024 characters each. Enterprise allows more connections and 25,000 or more
rules of up to 2,048 characters. A second connection on pay-per-use is rejected by X. The
`backfillMinutes` option (1 to 5; 0 or omitted means no backfill) and the `startTime` and `endTime` recovery window (within the last
24 hours) require Enterprise access. Streamed posts count toward your monthly post read usage. The
SDK does not reconnect, deduplicate across reconnects, or recover missed posts for you.

See the X documentation for the [filtered stream](https://docs.x.com/x-api/posts/filtered-stream/introduction),
[rule management](https://docs.x.com/x-api/stream/update-stream-rules),
[disconnections and backoff](https://docs.x.com/x-api/fundamentals/handling-disconnections), and
[recovery and redundancy](https://docs.x.com/x-api/fundamentals/recovery-and-redundancy).

## Limits and requirements

:::warning
X access depends on the account's current API plan and OAuth2 scopes. API usage can incur upstream charges. API plan access, billing, and app approval remain upstream requirements.
:::

- The adapter's account listing is credential-scoped and returns the configured user. It does not prove that a frontend-selected handle belongs to the current tenant; resolve membership in your application before calling a mutation.
- Use the documented [connection flow](/authentication) for callbacks and token persistence.
- Keep access tokens server-side and supply an application authorization policy before publishing for multiple tenants.
- The adapter cannot schedule posts. X API v2 has no scheduling field, so `POST /2/tweets` publishes immediately, and `posts.prepare` rejects a request with `schedule` (`x.operation`) before any network call. X's Ads API has [scheduled Tweets](https://docs.x.com/x-ads-api/creatives) at `ads-api.x.com/12/accounts/:account_id/scheduled_tweets`, but it needs Ads API approval, an ads account, and [OAuth 1.0a-signed requests](https://docs.x.com/x-ads-api/fundamentals/making-authenticated-requests). This adapter only uses OAuth 2.0 bearer tokens. Those scheduled Tweets are promoted-only (`nullcast=true`) by default and stay off the public timeline, and only the ads account's full promotable user can create organic ones. To publish at a set time, run your own job runner that calls `posts.publish`. (Checked 2026-09-24.)
- The adapter cannot update the account profile. X API v2 ([OpenAPI 2.168](https://docs.x.com/openapi.json)) has no profile write endpoint. docs.x.com no longer publishes the v1.1 `POST account/update_profile` reference, and v1.1 user writes require OAuth 1.0a. (Checked 2026-09-24.)
- Current repository evidence is contract and mocked transport coverage; no live X account or paid API plan has been verified here.

See [X API pricing](https://docs.x.com/x-api/getting-started/pricing), [posts](https://docs.x.com/x-api/posts/creating-a-post), [delete post](https://docs.x.com/x-api/posts/delete-post), and [media](https://developer.x.com/en/docs/x-api/v1.1/media/upload-media/api-reference/post-media-upload) for current upstream access and limits.
