X
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
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.
Store credentials server-side
Set X_USER_ID and X_ACCESS_TOKEN in server configuration. Never ship them to a browser
bundle.
Configure the adapter
Construct the backend with those credentials and select the account through a connected-account reference.
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.
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.
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.
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.
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>:
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.
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 (October 3, 2025) requires X Premium, the account’s own post, and a post created within the last hour. The edit posts guide 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.
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 for reading a conversation.
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.
limitmust 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.readandusers.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.
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, unlike a 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.
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:
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:
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.
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:
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:
postcarries the post, thematchingRulesthat selected it, and anyincludesfor requested expansions.errorcarries an error-only message from X, such as an operational disconnect notice.othercarries 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.
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, rule management, disconnections and backoff, and recovery and redundancy.
Limits and 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 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/tweetspublishes immediately, andposts.preparerejects a request withschedule(x.operation) before any network call. X’s Ads API has scheduled Tweets atads-api.x.com/12/accounts/:account_id/scheduled_tweets, but it needs Ads API approval, an ads account, and OAuth 1.0a-signed 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 callsposts.publish. (Checked 2026-09-24.) - The adapter cannot update the account profile. X API v2 (OpenAPI 2.168) has no profile write endpoint. docs.x.com no longer publishes the v1.1
POST account/update_profilereference, 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, posts, delete post, and media for current upstream access and limits.