X
Publish and read X posts with existing user authorization, explicit media limits, native references, and uncertain-write reconciliation.
To post to X from a server, call the X API v2 POST /2/tweets endpoint with an OAuth 2.0 user access token that has the tweet.write scope. An app-only bearer token cannot write. The Social SDK X adapter wraps that call, media uploads through /2/media/upload, and X’s weighted 280-character count behind social.posts.publish, which returns one typed outcome per post.
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.
For what posts and reads cost on X’s pay-per-use credits, free credits, and posting rate limits, read the X API pricing guide.
Setup
Register an app
Create an app in the X developer console, enable OAuth 2.0, and
register your server callback URL. API access uses pay-per-use credits or an Enterprise
agreement; endpoint eligibility and OAuth scopes are separate requirements. Request
tweet.read, tweet.write, users.read, offline.access, and media.write for publishing
with uploads. Request additional scopes only for the operations you use.
Authorize the user
Use xOAuth from @opencoredev/social-sdk/server with ConnectionManager for the
authorization-code flow with PKCE, state verification, and the registered callback. Exchange the
short-lived code immediately. Read GET /2/users/me with the resulting user token to discover
the numeric user ID. Store that ID and the tokens server-side. An app-only bearer identifies the
app and cannot authorize user writes or private reads.
Persist refresh results
OAuth 2.0 user
tokens normally
expire after two hours. Request offline.access for a refresh token and persist both rotated
tokens after refreshing. See the connection flow for callback and token
storage integration. Never ship tokens to a browser bundle.
The examples below share this server setup. All account references and native contexts use the
default backend registration. Native methods bypass the normalized client middleware; enforce
your application’s tenant grants before calling them.
import { createSocial, connectedAccountRef, platformPostRef } from "@opencoredev/social-sdk";
import type { AdapterOperationContext } from "@opencoredev/social-sdk";
import { x } from "@opencoredev/social-sdk/x";
const userId = process.env.X_USER_ID;
const accessToken = process.env.X_ACCESS_TOKEN;
if (!userId || !accessToken) throw new Error("Configure X_USER_ID and X_ACCESS_TOKEN");
const appBearerToken = process.env.X_APP_BEARER_TOKEN;
const social = createSocial({
backend: x({
auth: { userId, accessToken },
// Required for the filtered stream; a fallback for public reads and search.
...(appBearerToken ? { appBearerToken } : {}),
}),
});
const account = connectedAccountRef({ backend: "default", platform: "x", accountId: userId });
const native = social.native("default", { acknowledgeUnsafe: true });
if (!native) throw new Error("X native methods are unavailable");
// Create a fresh context for each operation, not a shared long-lived deadline.
function operationContext(maxElapsedMs = 30_000): AdapterOperationContext {
return {
backendInstance: "default",
correlationId: crypto.randomUUID(),
retryBudget: { maxAttempts: 1, maxElapsedMs },
};
}
Examples
Publish a text post
The adapter counts text the way X does, not by JavaScript string length: most Latin characters
weigh 1, CJK characters and emoji weigh 2, and every URL weighs 23, against a limit of 280.
X Premium authors can post long-form text of up to 25,000 characters. Set longform: true in the
target options to raise the local limit to 25,000 with the same weighting; X still rejects the post
if the author is not eligible.
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 accepts static JPEG, PNG, and WebP (image/webp) images, MP4, QuickTime, and WebM video, and GIF images as Blobs, matching the formats X’s media best practices list. Static images are at most 5 MiB each, with up to four per post. A post can instead attach one video or one GIF. The capability manifest lists text, image, and video; GIF is represented by the native image shape and is not a separate normalized format.
Set an image attachment’s altText to upload accessibility metadata before
creating the post. The adapter accepts up to 1,000 Unicode characters and stops
publication if the metadata upload fails.
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const imageBytes = await readFile("./release.png");
const blob = new Blob([imageBytes], { type: "image/png" });
const contentHash = createHash("sha256").update(imageBytes).digest("hex");
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 (kind: "video" with video/mp4, video/quicktime, or video/webm, sent as the INIT
media_type) and GIF (kind: "image", image/gif, up to 15 MiB) upload through the
chunked INIT → APPEND → FINALIZE → STATUS flow with 4 MiB segments. Attach exactly one
video or GIF per post. The local video upload ceiling is 17,179,869,184 bytes (16 GiB).
At that ceiling, the adapter sends 4,096 segments with indices 0–4095, within X’s
0–9999 APPEND index range. Each segment stays below the recommended 5 MB chunk limit.
X’s own size and duration limits depend on the account’s Premium or verified status; see
Media specifications. The SDK does not infer eligibility from a token. The adapter polls processing_info honoring check_after_secs, and stops
at the operation deadline or after 30 STATUS requests. A processing timeout carries the
upload ID in SocialError.details.mediaId and uses reconcile-first. Interrupted APPEND,
FINALIZE, and STATUS requests also retain the upload ID; normalized failed or unknown
publish outcomes expose it as mediaId. Check that upload’s
STATUS with the provider before starting another upload. A failed processing state surfaces as a terminal
media_error and never creates a post. X checks video duration when the post is created. A recognized video-duration problem
returns media_error. An unrecognized HTTP 403 remains missing_permission, with
guidance to check account attachment limits and post permissions.
The 16 GiB ceiling is a local size check, not a promise that an upload finishes within
the default 30-second elapsed budget. That budget covers INITIALIZE, every sequential
APPEND, FINALIZE, and processing. Set retryBudget.maxElapsedMs for the file size,
connection speed, and processing time before dispatching a large upload. The example
below allows 30 minutes; slower connections may need more. Native uploadVideo takes an optional mimeType
(default video/mp4). Native uploadVideo and uploadGif callers can pass context: operationContext(30 * 60_000) for the same budget.
Keep maxAttempts: 1 and reconcile interrupted uploads before starting another.
import { createReadStream, openAsBlob } from "node:fs";
import { createHash } from "node:crypto";
const videoBlob = await openAsBlob("./demo.mp4", { type: "video/mp4" });
const hash = createHash("sha256");
for await (const chunk of createReadStream("./demo.mp4")) hash.update(chunk);
const contentHash = hash.digest("hex");
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,
},
],
},
},
{ retryBudget: { maxAttempts: 1, maxElapsedMs: 30 * 60_000 } },
);
A GIF uses the image shape, with its own MIME type:
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const gifBytes = await readFile("./animation.gif");
const gif = new Blob([gifBytes], { type: "image/gif" });
await social.posts.publish({
targets: [{ account }],
content: {
media: [
{
kind: "image",
mimeType: "image/gif",
source: {
kind: "blob",
blob: gif,
fingerprint: createHash("sha256").update(gifBytes).digest("hex"),
},
},
],
},
});
Publish options
Pass X-only fields as the target’s options. Each one maps to a field of
POST /2/tweets. posts.prepare checks
types, ID formats, and combinations before any request; X still decides whether the account is
eligible and answers an ineligible request with an error, usually HTTP 403.
| Option | Wire field | What it does |
|---|---|---|
replySettings |
reply_settings |
Who can reply: everyone (the default, not sent), following, mentionedUsers, subscribers, or verified. |
longform |
(local only) | Allows up to 25,000 characters for an X Premium author. |
quotedPostId |
quote_tweet_id |
Quotes another post by its numeric ID. |
excludeReplyUserIds |
reply.exclude_reply_user_ids |
With replyTo, user IDs to drop from the reply’s automatic mention list (up to 100). |
autoPopulateReplyMetadata |
reply.auto_populate_reply_metadata |
With replyTo, sends the legacy flag. X OpenAPI 2.173 says X accepts it for compatibility and drops it, so it changes nothing today. |
communityId |
community_id |
Posts into an X Community the author belongs to. |
shareWithFollowers |
share_with_followers |
Shares an exclusive Super Follows post (forSuperFollowersOnly) with all followers. |
paidPartnership |
paid_partnership |
Labels the post as a paid promotion. Read it back with tweet.fields=paid_partnership. |
madeWithAi |
made_with_ai |
Discloses that the post contains AI-generated media. |
forSuperFollowersOnly |
for_super_followers_only |
Restricts the post to the author’s subscribers (Super Follows). |
placeId |
geo.place_id |
Geo-tags the post with an X place ID. |
directMessageDeepLink |
direct_message_deep_link |
Adds a button that opens a DM with the author. Must be an HTTPS link. |
cardUri |
card_uri |
Attaches a card created through the X Ads API. Cards need Ads API access. |
Media card fields apply to the attached media and need at least one media item. The adapter merges
them into the media object next to media_ids:
| Option | Wire field | What it does |
|---|---|---|
mediaTitle, mediaDescription |
media.title, media.description |
Card text X renders for video and Amplify media. |
previewMediaId |
media.preview_media_id |
ID of a separately uploaded image used as the preview. |
embeddable |
media.embeddable |
Keeps media asset URLs from expiring and allows syndicated playback. |
taggedUserIds |
media.tagged_user_ids |
Tags up to 10 users in the media. |
callToActions |
media.call_to_actions |
visitSite or watchNow with an HTTPS url, or appInstall with appStoreId, ipadAppStoreId, or playStoreId. |
const result = await social.posts.publish({
targets: [
{
account,
options: {
communityId: "1500000000000000001",
shareWithFollowers: true,
madeWithAi: true,
replySettings: "verified",
},
},
],
content: { text: "Weekly build notes" },
});
The options above describe the direct X adapter. Managed backends have their own X option names
and report any option they do not map as a preparation issue (options.unmapped):
- Zernio:
replySettings,quotePostId,poll,paidPartnership,madeWithAi,sensitiveMedia,geoRestriction,threadItems,longVideo, andmediaTaggedUsers. - Post for Me:
replySettings,quotePostId,poll, andcommunityId. - PostFast:
replySettings,repostUrl, andfirstComment. - Postiz:
replySettings,community,aiGenerated,paidPartnership,article, and the post-levelthread. - Buffer:
aiGenerated,retweet, andthread; noreplySettings.
Each backend page lists the values it accepts, such as Postiz’s extra subscribers and verified
reply settings.
Threads, polls, and quotes
To publish a thread of posts, call social.posts.publishSequence with replyToPrevious: true.
Each item becomes a self-reply to the post the same account created in the previous item. See
Publishing for idempotency keys and partial failures.
await social.posts.publishSequence({
idempotencyKey: "launch-thread-2026-11-04",
replyToPrevious: true,
items: [
{ targets: [{ account }], content: { text: "1/ We shipped long-form posts." } },
{ targets: [{ account }], content: { text: "2/ Set longform: true to use them." } },
],
});
native.createPoll({ account, text, options, durationMinutes, context }) sends poll.options
and poll.duration_minutes. X requires 2 to 4 options of 1 to 25 characters and a duration from
5 to 10,080 minutes (7 days); the adapter checks the option count and duration before sending.
Polls cannot be edited after they are posted.
await native.createPoll({
account,
text: "Which SDK target should we add next?",
options: ["Deno", "Cloudflare Workers"],
durationMinutes: 1440,
context: operationContext(),
});
native.quote({ account, text, quotedPostId, context }) sends quote_tweet_id, as does the
quotedPostId publish option. X’s April 20, 2026 pricing update removed Quote-Posts from all
self-serve tiers, so quoting needs Enterprise access.
await native.quote({
account,
text: "Worth reading",
quotedPostId: "1700000000000000000",
context: operationContext(),
});
Reconcile an uncertain outcome
Every target result is independent. A successful HTTP response confirms publication only when X
returns a post ID. A lost create response can leave an unknown outcome without a delivery
reference or post ID. This adapter has no delivery-status lookup. Inspect the acting account’s
timeline in X or through native.userPosts and compare the intended post and creation time before
deciding what to do. Absence from a page does not prove the write failed. A blind retry can create
a duplicate; the adapter does not fail over to another backend.
Read post and account metrics
The normalized analytics facade exposes both post metrics and account metrics for X. Post metrics
come from the post’s public_metrics; account metrics come from the acting user’s public_metrics
and preserve X’s tweet_count name. The adapter accepts both X’s newer post_count and older
tweet_count field spellings, preferring the newer field when both are present. These reads still
need a user-context token with the provider’s current read access, and the repository evidence is
offline transport coverage rather than a live X account or paid-plan verification.
const knownPost = platformPostRef({
backend: "default",
platform: "x",
accountId: account.accountId,
postId: "1900000000000000001", // Load the stored reference from your application.
});
const postMetrics = await social.analytics.getPostMetrics(knownPost);
const accountMetrics = await social.analytics.getAccountMetrics(account);
When you already know the post ID, read that account’s post directly:
const observed = await native.readPost(knownPost, operationContext());
console.log(observed["id"]);
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.
The SDK validates a 4,096-character query ceiling, without inferring a subscription tier.
X’s self-serve caps are 512 characters for recent search and 1,024 for archive search;
Enterprise can support 4,096. Set startTime explicitly for older archive history: omitting it
does not promise a query over the entire archive. Full-archive limits include one request per
second and 300 per 15 minutes; confirm your current app allocation before running a backfill.
The May 4, 2026 search-index change supports min_likes:, min_replies:, and min_reposts:.
Keyword-based searches no longer return reposts; filtered-stream matching is unchanged.
See the changelog for the dated changes.
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 edited = await native.updatePost({
account,
postId: "1900000000000000001",
text: "Corrected release notes",
context: operationContext(),
});
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({
backend: "default",
platform: "x",
accountId: account.accountId,
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 { xLike, xUnlike } from "@opencoredev/social-sdk/x";
const postId = "1900000000000000001";
const state = await xLike(
postId,
account,
{ userId, accessToken },
{
backendInstance: "default",
correlationId: "reaction",
retryBudget: { maxAttempts: 1, maxElapsedMs: 10_000 },
},
);
console.log(state.liked);
Both require Enterprise access under X’s April 2026 changelog, plus 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
access. Following, Likes, and Quote-Posts were removed from self-serve access effective
April 20, 2026; use these methods only with eligible Enterprise access. Block and unblock writes
are also Enterprise-only. Follower/following/muted/blocked pages accept limits from 1 through 1,000.
Muted and blocked reads require a user token; public lookup and permitted public graph reads can
use the app bearer. Graph scopes include tweet.read and users.read plus the relationship’s
follows.read, mute.read, or block.read scope. Writes use the matching write scope.
Pending follow requests do not count as completed relationships: unconfirmed writes raise
ambiguous_outcome with reconcile-first. 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 a read of the full pinned set, plus direct-message operations for an existing conversation and for creating a group conversation:
await native.pinList({ account, listId: "123", context: operationContext() });
const pinned = await native.pinnedLists({ account, context: operationContext() });
await native.sendConversationMessage({
account,
conversationId: "456",
text: "Hello",
context: operationContext(),
});
await native.createGroupConversation({
account,
participantIds: ["1900000000000000002", "1900000000000000003"],
message: "Welcome",
context: operationContext(),
});
Pinned lists require user context, tweet.read, users.read, and list.read or list.write.
pinnedLists has no pagination: omit limit and cursor or the SDK rejects them locally.
Conversation and group sends require dm.write, users.read, and tweet.read, with endpoint access.
sendConversationMessage accepts nonempty text or one attachment { media_id: "123" }; it rejects
missing/invalid media IDs and multiple attachments. listDirectMessages returns the standard Page shape, including
nextCursor when X returns meta.next_token, plus sender fields and metadata.includes for user expansions.
Look up Spaces
The native X module exposes the official Spaces lookup and search endpoints without inventing a
normalized Space record. These reads use the configured user token when present and otherwise the
app bearer fallback, and preserve X’s response fields and metadata.includes expansions:
const space = await native.getSpace({
account,
spaceId: "1DXxyRYNejbKM",
options: { spaceFields: ["title", "state"], expansions: ["host_ids"] },
context: operationContext(),
});
const upcoming = await native.getSpacesByCreators({
account,
creatorIds: ["123"],
context: operationContext(),
});
const matches = await native.searchSpaces({
account,
search: { query: "AI", state: "live", limit: 10 },
context: operationContext(),
});
getSpace calls GET /2/spaces/:id; getSpaces calls GET /2/spaces for up to 100 IDs;
getSpacesByCreators calls GET /2/spaces/by/creator_ids for up to 100 numeric user IDs; and
searchSpaces calls GET /2/spaces/search for live or scheduled Spaces. X’s current search
contract is bounded and does not expose a pagination token. These endpoints require the app’s approved space.read,
tweet.read, and users.read access where X requires them, and ended Spaces may no longer be
retrievable after the provider removes them. The SDK does not expose recordings because the
current official lookup documentation does not define a recording endpoint or stable recording
contract.
spaceFields accepts the OpenAPI Space field selectors, including created_at, lang,
subscriber_count, and updated_at. Request creator_id, host_ids, invited_user_ids,
speaker_ids, and topic_ids through expansions. The adapter preserves expanded users and topics;
X determines which requested data is available to the configured token.
This contract was checked against X API v2 OpenAPI 2.172 and the Spaces lookup documentation on 2026-10-18. The SDK records only offline contract evidence; endpoint approval, plan eligibility, and live responses remain provider-dependent.
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. See Process webhooks.
Register the endpoint with the native webhook methods, which call /2/webhooks.
createWebhook({ account, url }) registers an HTTPS URL of up to 200 characters; X sends a CRC
check before it accepts it. listWebhooks returns { id, url, valid, createdAt } for each config,
validateWebhook asks X to run the CRC check again and re-enable a failed webhook, and
deleteWebhook removes it. replayWebhook({ account, webhookId, from, to }) asks X to re-deliver
events from a past window through POST /2/webhooks/replay; the adapter converts the ISO times to
X’s yyyymmddhhmm UTC format. These methods use appBearerToken when it is set (replay requires
it) and otherwise the user token.
const webhook = await native.createWebhook({
account,
url: "https://api.example.com/webhooks/x",
context: operationContext(),
});
X Activity API
The X Activity API delivers per-user events to a registered webhook. Subscribe with
createActivitySubscription({ account, eventType, filter, webhookId, tag, expiresAt }), which
calls POST /2/activity/subscriptions; list, retag, and delete subscriptions with
listActivitySubscriptions, updateActivitySubscription, and deleteActivitySubscriptions
(one ID uses DELETE /2/activity/subscriptions/{id}, several use the bulk route with
appBearerToken). eventType is one of X’s dot-notation types, such as post.reply.create,
post.mention.create, like.create, follow.follow, dm.received, or profile.update.bio.
filter.userId names the user to follow, and filter.qualifiers takes up to 8 predicates such as
post_id or conversation_type. Private events (likes, DMs, mentions, replies, quotes, reposts,
mutes, blocks, and subscriptions) need that user’s OAuth 2.0 token with the matching read scope,
so the adapter sends the user token when one is configured.
await native.createActivitySubscription({
account,
eventType: "post.reply.create",
filter: { userId: account.accountId },
webhookId: webhook.id,
tag: "replies",
context: operationContext(),
});
Deliveries arrive as { data: { event_type, filter, payload } } and use the same signature and
CRC as other X webhooks. post.reply.create decodes to comment.received, dm.received to
message.received, post.delete to post.removed, and oauth.revoke to account.updated. The
event’s accountIds holds filter.user_id (for oauth.revoke, the payload’s user_id). Other
types, including mentions, quotes, likes, and follows, decode as unknown with the X event type
in originalType.
Instead of a webhook, the app can hold the X Activity API’s persistent stream open.
activityStream({ account, backfillMinutes, startTime, endTime }) connects to
GET /2/activity/stream with appBearerToken and yields { kind: "data", data } events, where
data is X’s event object with event_type, filter, and payload. It delivers events for the
app’s existing subscriptions, so create them first. backfillMinutes (0 to 5) replays a short gap
after a disconnect, and startTime/endTime request a recovery window. Like the filtered
stream, each iteration opens one connection, stallTimeoutMs (default 20,000) bounds the wait for
data or a keep-alive, and the adapter never reconnects on its own.
for await (const event of native.activityStream({ account, context: operationContext() })) {
// The payload can hold private messages; log only the event type.
if (event.kind === "data") console.log(event.data["event_type"]);
}
Account Activity API (deprecated)
The adapter verifies and decodes Account Activity deliveries but cannot manage their subscriptions
(see Not supported): direct_message_events decode to message.received,
tweet_delete_events to post.removed, and user_event.revoke to account.updated. Every other
container, including tweet_create_events, favorite_events, follow_events,
block_events, and mute_events, decodes as unknown with the container name in
originalType. A tweet_create_events delivery mixes the user’s own posts, replies, and
mentions, so the adapter does not guess which of them is a comment.
Migrate OAuth 1.0a users
exchangeXOAuth1Token from @opencoredev/social-sdk/server exchanges a stored OAuth 1.0a access
token and secret for OAuth 2.0 tokens without sending the user through consent again. It posts
grant_type=urn:ietf:params:oauth:grant-type:token-exchange to POST /2/oauth2/token with the
token as subject_token and the secret as oauth_token_secret. Pass the same clientId (and
clientSecret for confidential clients, sent with HTTP Basic) that you give xOAuth. X derives
the scopes from the user’s original OAuth 1.0a permissions and always adds offline.access;
scopes can only narrow them. An invalid_grant answer means X rejected the OAuth 1.0a
credentials, so do not retry it. X allows 10,000 exchanges per 15 minutes per app and source IP
(guide).
import { exchangeXOAuth1Token } from "@opencoredev/social-sdk/server";
const tokens = await exchangeXOAuth1Token(
{ clientId: process.env.X_CLIENT_ID ?? "", clientSecret: process.env.X_CLIENT_SECRET },
{ token: storedOAuth1Token, tokenSecret: storedOAuth1Secret },
);
// Persist tokens.accessToken and tokens.refreshToken, then drop the OAuth 1.0a pair.
Revoke tokens
revokeXOAuthToken(options, { token }) from @opencoredev/social-sdk/server invalidates an
access or refresh token with POST https://api.x.com/2/oauth2/revoke, for a disconnect or log-out
flow. It sends the token as a form field; confidential clients (with clientSecret) authenticate
with HTTP Basic and public clients send client_id. It returns "revoked" unless X answers
revoked: false, so it can back grants.revoke in the connect handlers. Users can also remove
the app themselves at X’s connected apps page
(guide).
import { revokeXOAuthToken } from "@opencoredev/social-sdk/server";
await revokeXOAuthToken(
{ clientId: process.env.X_CLIENT_ID ?? "", clientSecret: process.env.X_CLIENT_SECRET },
{ token: storedRefreshToken },
);
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.
deletePost({ account, postId, 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 data.deleted: true, including through social.posts.removeFromPlatform. Unconfirmed deletion requires reconciliation before retrying.
Recent and full-archive search (searchAllPosts) prefer the configured user access token and
fall back to appBearerToken when there is no user token. With neither credential, search raises
missing_permission locally. X’s full-archive guide describes app-only authentication, while
OpenAPI 2.169 also lists user-context authentication. The SDK follows that declared option; live
user-context archive access has not been verified here. The filtered stream always uses the app bearer.
User timeline, mentions, and liked-post reads accept limits from 5 through 100. Home timeline,
list posts, and liking-user reads accept 1 through 100. Native timeline/list pages retain author,
reference, and media expansions in metadata.includes. Metrics decode both post_count/tweet_count
and repost_count/retweet_count, preferring the newer spelling when both exist. Normalized metric
names remain tweet_count for account totals and reposts for post totals. Reply references accept
referenced_posts and referenced_tweets and expose referenced_tweets for compatibility. bookmarks returns X’s raw response for one
page rather than an SDK Page. Bookmark reads/writes need bookmark.read/bookmark.write
with tweet.read and users.read; repost writes need tweet.write, tweet.read, and users.read.
Self-serve API replies require the parent Post’s author to have summoned the replying account by
mentioning it or quoting one of its Posts. Enterprise is exempt under X’s February 23, 2026
changelog. comments.write requires tweet.write, tweet.read, and users.read. The adapter
leaves access decisions to X and keeps the methods available for eligible callers.
Look up posts, users, and media
getPosts({ account, postIds, fields, context }) reads up to 100 posts in one
GET /2/tweets?ids= request. Posts X cannot return (deleted, protected, or suspended) are listed in
metadata.errors instead of failing the call. getUsers (GET /2/users?ids=) and
getUsersByUsernames (GET /2/users/by?usernames=) do the same for up to 100 users, and
getMedia (GET /2/media?media_keys=) for up to 100 media keys such as 3_1700000000000000000.
These reads use the user token when configured and fall back to appBearerToken.
const posts = await native.getPosts({
account,
postIds: ["1700000000000000000", "1700000000000000001"],
fields: { tweetFields: ["id", "text", "note_tweet", "edit_controls", "paid_partnership"] },
context: operationContext(),
});
for (const error of posts.metadata?.["errors"] ?? []) console.warn(error);
fields chooses what X returns. tweetFields is sent as tweet.fields, as X’s
fields guide shows; OpenAPI 2.173 calls the same
parameter post.fields. Newer post fields include note_tweet (the full text of a long-form
post), edit_controls (remaining edits and the edit deadline), paid_partnership,
community_id, article, card_uri, and reply_settings. non_public_metrics and
organic_metrics need the author’s user token and only cover posts from the last 30 days.
Without fields, the adapter asks for id, text, author, creation time, conversation, public
metrics, references, and attachments, with author, referenced-post, and media expansions in
metadata.includes.
User search (searchUsers, GET /2/users/search) takes a 1-50 character query of letters,
digits, underscores, apostrophes, and spaces, returns up to 1,000 users per page, and needs a user
token.
Quotes and reposts
quotePosts({ account, postId, exclude, limit }) reads the quote posts of a post through
GET /2/tweets/{id}/quote_tweets, 10 to 100 per page; exclude can drop replies or
retweets. repostedBy lists the users who reposted a post (GET /2/tweets/{id}/retweeted_by),
and reposts returns the repost posts themselves (GET /2/tweets/{id}/retweets).
repostsOfMe reads reposts of the authenticated user’s posts (GET /2/users/reposts_of_me) and
needs a user token with timeline.read.
const quotes = await native.quotePosts({
account,
postId: "1700000000000000000",
exclude: ["retweets"],
limit: 100,
context: operationContext(),
});
const reposters = await native.repostedBy({
account,
postId: "1700000000000000000",
context: operationContext(),
});
To repost or undo a repost, call repost({ account, postId, context }) and
undoRepost({ account, postId, context }). Both raise ambiguous_outcome unless X confirms the
new retweeted state.
await native.repost({ account, postId: "1700000000000000000", context: operationContext() });
await native.undoRepost({ account, postId: "1700000000000000000", context: operationContext() });
Post counts
Post counts report how many posts match a search query per time bucket, without reading the posts.
countRecentPosts calls GET /2/tweets/counts/recent (last 7 days, user or app token) and
countAllPosts calls GET /2/tweets/counts/all (full archive, appBearerToken and an eligible
plan). Set granularity to minute, hour (X’s default), or day. The result has buckets
with start, end, and count, plus totalCount and nextCursor. The adapter reads both
post_count and the older tweet_count names.
const counts = await native.countRecentPosts({
account,
counts: { query: "from:opencoredev", granularity: "day" },
context: operationContext(),
});
console.log(counts.totalCount, counts.buckets.at(-1));
Likes and bookmarks
likingUsers({ account, postId }) lists users who liked a post and likedPosts({ account })
lists posts the account liked. Both return SDK pages.
const likers = await native.likingUsers({
account,
postId: "1700000000000000000",
limit: 100,
context: operationContext(),
});
const liked = await native.likedPosts({ account, context: operationContext() });
bookmarks({ account, cursor, maxResults }) returns X’s raw response for one page; pass
meta.next_token back as cursor. bookmark and removeBookmark add and remove one post.
Bookmark folders have their own methods: bookmarkFolders lists the account’s folders
(GET /2/users/{id}/bookmarks/folders), createBookmarkFolder creates one with a 1-25
character name, and bookmarksByFolder returns the post IDs saved in a folder. Folder reads need
bookmark.read; creating a folder needs bookmark.write.
await native.bookmark({ account, postId: "1700000000000000000", context: operationContext() });
const folder = await native.createBookmarkFolder({
account,
name: "Read later",
context: operationContext(),
});
const saved = await native.bookmarksByFolder({
account,
folderId: folder.id,
context: operationContext(),
});
await native.removeBookmark({
account,
postId: "1700000000000000000",
context: operationContext(),
});
bookmarkPosts({ account, postIds, folderId }) bookmarks 1 to 25 posts in one
POST /2/users/{id}/bookmarks request by sending tweet_ids. With folderId, X also files them
in that bookmark folder (folder_id); without it, they go to the top-level bookmarks only. X
reports per-post failures in errors while bookmarking the rest, so the result lists the
bookmarked IDs and the errors separately. It needs bookmark.write.
const result = await native.bookmarkPosts({
account,
postIds: ["1700000000000000000", "1700000000000000001"],
folderId: folder.id,
context: operationContext(),
});
for (const error of result.errors) console.warn(error["tweet_id"], error["title"]);
Lists
createList, updateList, and deleteList manage the account’s Lists; addListMember and
removeListMember change members; followList, unfollowList, pinList, and unpinList
change the account’s relationship to a List. Reads are getList, ownedLists, pinnedLists,
listMembers, listPosts, listFollowers (GET /2/lists/{id}/followers), and
listMemberships, which returns the Lists a user belongs to through
GET /2/users/{id}/list_memberships. List writes need list.write; reads need list.read.
const created = await native.createList({
account,
name: "TypeScript SDKs",
isPrivate: true,
context: operationContext(),
});
console.log(created["data"]); // { id, name }
const listId = "1800000000000000000"; // the id from created.data
await native.addListMember({ account, listId, userId: "2244994945", context: operationContext() });
const members = await native.listMembers({ account, listId, context: operationContext() });
const posts = await native.listPosts({ account, listId, limit: 50, context: operationContext() });
const owned = await native.ownedLists({ account, context: operationContext() });
Timelines, affiliates, and hidden replies
homeTimeline({ account }) reads the account’s reverse-chronological home timeline and needs a
user token. userPosts({ account, userId }) reads one user’s posts.
const home = await native.homeTimeline({ account, limit: 50, context: operationContext() });
affiliates({ account, userId }) lists accounts affiliated with a verified organization through
GET /2/users/{id}/affiliates, up to 1,000 per page.
hideReply({ account, replyId, hidden, context }) hides or unhides a reply in a conversation the
account started, through PUT /2/tweets/{id}/hidden. It needs tweet.moderate.write and returns
the hidden state X reports.
await native.hideReply({
account,
replyId: "1700000000000000002",
hidden: true,
context: operationContext(),
});
Communities, trends, and Space Posts
Communities: getCommunity({ account, communityId, communityFields }) reads one Community through
GET /2/communities/{id}, and searchCommunities({ account, query }) searches them by keyword
(10 to 100 per page, user token). Post into a Community with the communityId publish option.
Trends: trends({ account, woeid, maxTrends }) reads up to 50 trends for a Yahoo! WOEID location
(1 is worldwide) and returns { name, postCount } entries. personalizedTrends({ account })
returns the trends X personalizes for the authenticated user, with category, postCount as X’s
display text, and trendingSince.
Spaces: spacePosts({ account, spaceId }) reads posts shared in a Space, and
spaceBuyers({ account, spaceId }) lists ticket buyers of the account’s ticketed Space and needs
the host’s user token.
Usage and credit balance
creditBalance({ account }) reads GET /2/usage/credits and returns the remaining pay-per-use
credit balance in USD as totalBalance, prepaidBalance, freeBalance, and freeGrants with
their expiry. postUsage({ account, days }) reads the project’s post consumption for the last 1 to
90 days through GET /2/usage/tweets and needs appBearerToken. Use them to budget reads before a
large job; the SDK does not stop requests when credit runs low.
const balance = await native.creditBalance({ account, context: operationContext() });
if (balance.totalBalance < 1) console.warn("X credit balance is low");
Subtitles and shared media
Add captions to a video in three steps: upload the video, upload an SRT or WebVTT file with
uploadSubtitles (chunked upload with media_category=subtitles and media_type text/srt or
text/vtt), then link them with attachSubtitles, which calls POST /2/media/subtitles. The
language code is two uppercase letters such as EN; mediaCategory is TweetVideo (default) or
AmplifyVideo. deleteSubtitles removes one language and raises ambiguous_outcome unless X
confirms deleted: true. All three need media.write. Attach the video to a post as usual with
its media ID.
import { openAsBlob } from "node:fs";
const video = await native.uploadVideo({
account,
video: await openAsBlob("./demo.mp4", { type: "video/mp4" }),
context: operationContext(10 * 60_000),
});
const track = await native.uploadSubtitles({
account,
file: await openAsBlob("./demo.en.srt"),
format: "srt",
context: operationContext(),
});
await native.attachSubtitles({
account,
videoMediaId: video.mediaId,
subtitleMediaId: track.mediaId,
languageCode: "EN",
displayName: "English",
context: operationContext(),
});
uploadVideo, uploadGif, and uploadDmMedia accept additionalOwners, up to 100 user IDs sent
as the INIT additional_owners field. Those accounts may attach the same media ID to their own
posts or messages, for example when one service uploads for several brand accounts.
Media metadata
setMediaMetadata({ account, mediaId, metadata }) attaches metadata to uploaded media with
POST /2/media/metadata and media.write,
and returns the associated_metadata X reports. Each field fills one entry of X’s metadata
object:
| Field | Wire field | What it does |
|---|---|---|
altText |
alt_text.text |
Alt text, up to 1,000 characters. |
allowDownload |
allow_download_status.allow_download |
Whether viewers may download the media. |
contentExpiresAt |
content_expiration.timestamp_sec |
ISO time after which the media expires, sent as Unix seconds. |
domainAllowlist |
domain_restrictions.whitelist |
Domains allowed to embed the media. |
foundMediaOrigin |
found_media_origin.provider, .id |
Credits media found through a provider such as a GIF library. |
managed, shared |
management_info.managed, shared_info.shared |
Management and sharing flags. |
uploadSource |
upload_source.upload_source |
Labels where the upload came from. |
sensitiveMediaWarning |
sensitive_media_warning |
Flags sensitive media, such as adult content or graphic violence. |
geoRestrictions |
geo_restrictions |
Limits where the media can be seen. |
audiencePolicy |
audience_policy |
Limits who can see the media. |
X’s OpenAPI 2.173 types sensitive_media_warning, geo_restrictions, and audience_policy as
objects without listing their fields, so the adapter forwards those objects unchanged and X
validates them. Earlier X media metadata used the sensitive-media categories adult_content,
graphic_violence, and other. The adapter checks the media ID, the alt-text length, the
expiration time, and the domain format, and raises ambiguous_outcome when X does not echo the
media ID.
await native.setMediaMetadata({
account,
mediaId: uploaded.mediaId,
metadata: {
altText: "Weekly signups chart",
allowDownload: false,
contentExpiresAt: "2026-12-01T00:00:00Z",
},
context: operationContext(),
});
Direct Message media, events, and blocks
uploadDmMedia({ account, media, kind }) uploads a Direct Message attachment through the
chunked flow with media_category dm_image, dm_gif, or dm_video. X allows images up to
5 MB, GIFs up to 15 MB, and DM video up to 512 MB and 140 seconds (1 GB and 10 minutes for X
Premium); the adapter checks the type and the larger size ceiling, and X checks the rest. Send the
returned ID as attachments: [{ media_id }] with sendConversationMessage.
const photo = await native.uploadDmMedia({
account,
media: await openAsBlob("./receipt.png", { type: "image/png" }),
kind: "image",
context: operationContext(),
});
await native.sendConversationMessage({
account,
conversationId: "2244994945-1500000000000000000",
text: "Here is your receipt",
attachments: [{ media_id: photo.mediaId }],
context: operationContext(),
});
listDirectMessages accepts eventTypes, sent as X’s event_types filter on all three DM event
reads (/2/dm_events, /2/dm_conversations/{id}/dm_events, and
/2/dm_conversations/with/{participant_id}/dm_events). Pass ["MessageCreate"] to leave out the
ParticipantsJoin and ParticipantsLeave events of group conversations; X returns all three
types when it is omitted.
getDirectMessage({ account, eventId }) reads one event with GET /2/dm_events/{event_id}, and
deleteDirectMessage deletes an event the account sent with DELETE /2/dm_events/{event_id}
(needs dm.write and dm.read; X must confirm deleted: true).
downloadDmMedia({ account, dmId, mediaId, resourceId }) downloads the bytes of legacy DM media
with GET /2/dm_conversations/media/{dm_id}/{media_id}/{resource_id} and dm.read. X’s reference
does not say where the three IDs come from, so pass the values X gives you. The adapter buffers the
body in memory up to maxBytes (default 15 MiB, at most 1 GiB) and raises media_error beyond
it. It does not follow redirects, so a redirect surfaces as an upstream_failure.
blockDms({ account, userId }) blocks Direct Messages to and from a user through
POST /2/users/{id}/dm/block, and unblockDms lifts the block through /dm/unblock. Both need
dm.write and require X to report the new blocked state.
Direct Message refusals
When X refuses a Direct Message it answers HTTP 403 with a problem type such as
https://api.x.com/2/problems/dm-recipient-not-accepting (X changelog, October 26, 2026). The
DM write methods (sendDirectMessage, sendConversationMessage, createGroupConversation, and
social.messages.send) put the part after problems/ in SocialError.upstreamCode, for example
dm-recipient-not-accepting, dm-sender-dms-closed, or dm-text-too-long, and never retry.
The SocialError code is invalid_input for content problems (dm-text-*, dm-bad-*,
dm-too-*, dm-duplicate, dm-unsupported-message, dm-conversation-name-too-long),
not_found for dm-*-not-found, rate_limited for dm-rate-limited, and
missing_permission for every other refusal. X’s detail text is dropped because it can describe
the recipient. A refusal X cannot explain stays about:blank and surfaces as
missing_permission without an upstreamCode.
// Refusals arrive as SocialError with the problem type in upstreamCode.
import { SocialError } from "@opencoredev/social-sdk";
try {
await native.sendDirectMessage({
account,
participantId: "2244994945",
text: "Your order shipped",
context: operationContext(),
});
} catch (error) {
if (error instanceof SocialError && error.upstreamCode === "dm-recipient-not-accepting")
console.warn("This user does not accept DMs from the account");
else throw error;
}
Post and media analytics
The normalized analytics.getPostMetrics returns public counts. For the account’s own posts, X
has three richer reads, all with the user token:
privatePostMetrics({ account, postIds })requestsnon_public_metricsandorganic_metricsfor up to 100 posts from the last 30 days, such asurl_link_clicks,user_profile_clicks, and organic impressions. It returns{ postId, nonPublic, organic }per post and rejects a post owned by another account.postAnalytics({ account, postIds, startTime, endTime, granularity, fields })callsGET /2/tweets/analyticsfor an engagement time series (hourly,daily,weekly, ortotal) with fields such asimpressions,engagements,url_clicks, anduser_profile_clicks.mediaAnalytics({ account, mediaKeys, startTime, endTime, granularity, fields })callsGET /2/media/analyticsfor video metrics such asvideo_views,playback_complete, andwatch_time_ms.
const analytics = await native.postAnalytics({
account,
postIds: ["1700000000000000000"],
startTime: "2026-11-01T00:00:00Z",
endTime: "2026-11-04T00:00:00Z",
granularity: "daily",
fields: ["impressions", "engagements", "url_clicks"],
context: operationContext(),
});
Articles
X Articles are long-form posts with headings, lists, quotes, links, embedded posts, and images.
The Articles API works in two steps, both with a user token that has tweet.read, tweet.write,
and users.read. createArticleDraft({ account, title, blocks, entities, coverMedia }) sends
POST /2/articles/draft. Each block has text and a type (unstyled, header-one to
header-three, list items, blockquote, or atomic for one embed). inlineStyles marks bold,
italic, or strikethrough ranges, and entityRanges point at entities by index. Entities are
links, embedded posts, images (uploaded media IDs), Markdown (code and tables), dividers, and LaTeX;
the adapter writes X’s DraftJS content_state and the mutability X recommends for each. Ranges
count UTF-16 code units and are checked against the block text before sending.
publishArticle({ account, articleId }) calls POST /2/articles/{id}/publish and returns the new
post’s reference; a response without post_id raises ambiguous_outcome.
const draft = await native.createArticleDraft({
account,
title: "What shipped in November",
blocks: [
{ text: "Highlights", type: "header-one" },
{
text: "Long-form posts and WebP images.",
type: "unstyled",
inlineStyles: [{ offset: 0, length: 15, style: "bold" }],
},
{ text: " ", type: "atomic", entityRanges: [{ entity: 0, offset: 0, length: 1 }] },
],
entities: [{ kind: "post", postId: "1700000000000000000" }],
context: operationContext(),
});
const post = await native.publishArticle({
account,
articleId: draft.articleId,
context: operationContext(),
});
X Chat (beta)
X Chat is X’s end-to-end encrypted messaging, separate from the legacy Direct Messages above. The
adapter carries X Chat requests but does not encrypt or decrypt anything.
chatConversations({ account, unreadOnly, type }) lists the inbox through
GET /2/chat/conversations, and chatEvents({ account, conversationId }) returns a
conversation’s events; encrypted events keep their encoded_event as X sends it, so decode them
with X’s Chat XDK. sendChatMessage({ account, conversationId, messageId, message }) calls
POST /2/chat/conversations/{id}/messages with either { kind: "text", text }, which X sends
unencrypted, or { kind: "encrypted", encodedMessageCreateEvent, encodedMessageEventSignature, xdkVersion } produced by the Chat XDK. Read the recipient’s receives_your_chat user field
first: unencrypted takes text, encrypted needs the XDK event, and none cannot be messaged
(X answers HTTP 403). These methods need dm.read or dm.write with users.read and
tweet.read.
sendChatMessage also takes { kind: "reaction", eventId, emoji }, sent as
content.reaction_add on the message event eventId; add remove: true to send
content.reaction_remove. Pass conversationToken to forward X’s conversation_token.
chatConversations filters the inbox with participantQuery (participant_query, up to 50
characters matched against participant names), participantIds (1 to 10 users), archived, and
muted.
Encryption boundary
Most X Chat writes change encrypted state, so X expects values that only the
Chat XDK can build: per-participant conversation keys,
encrypted group names, key rotations, and action_signatures. The adapter takes those values as
typed inputs (XChatParticipantKey, XChatActionSignature, keyRotation), validates IDs and
limits, and forwards them unchanged. It never sees plaintext keys and does not sign anything.
Every write raises ambiguous_outcome unless X confirms the change (for example archived: true
or deleted: true).
Groups
initializeChatGroup({ account })reserves a group ID withPOST /2/chat/conversations/group/initialize.createChatGroup({ account, group })creates it withPOST /2/chat/conversations/group:conversationId,conversationKeyVersion,participantKeys,memberIds, and optionaladminIds, encryptedname,description, andavatarUrl,messageTtlMs,keyRotation, andactionSignatures.getChatConversationreads a conversation’s type, group fields, admin settings, invite, and mute, archive, and disappearing-message state (GET /2/chat/conversations/{id}).updateChatGroupchanges the encrypted name, avatar, or description (PUT /2/chat/conversations/{id}withmodified_fields), anddeleteChatConversationremoves a conversation for the account.
Members and admins: addChatMembers (POST .../members, with the rotated keys the XDK produced),
removeChatMembers (POST .../members/remove), and setChatAdmins({ admin })
(POST .../admins or .../admins/remove). A member asks for admin rights with
requestChatAdmin (POST .../admin_requests); admins list requests with chatAdminRequests and
answer with resolveChatAdminRequest({ userId, approve }). updateChatAdminSettings restricts
group actions to admins: addMember, blockScreenCapture, editGroupInfo, editMessageTtl,
sendMessage, and startCall (PUT .../admin_settings).
Invites: enableChatInvite({ invite }) turns on a link (POST .../invite) with an optional
inviteUrl of the form https://x.com/i/chat/group_join/<id>/<token>, validForMs or
expiresAt, welcomeMessage and preview title (up to 1,024 characters each), avatarUrl, and
affiliateUserId. getChatInvite({ token }) reads a link, disableChatInvite turns it off,
requestToJoinChat({ token }) asks to join through it (POST .../join_requests), and
rejectChatJoinRequests({ userIds }) declines pending requests.
Inbox state and conversation settings
setChatArchived({ archived })callsPOSTorDELETE /2/chat/conversations/{id}/archive.setChatMuted({ muted })callsPOSTorDELETE /2/chat/conversations/{id}/mute.markChatRead({ read: true })sends a read receipt withPOST /2/chat/conversations/{id}/read(optionally signed);read: falsecalls/unread.sendChatTypingsends a typing indicator withPOST /2/chat/conversations/{id}/typing.setChatMessageTtl({ ttlMs })turns on disappearing messages withPUT .../message_ttl(message_ttl_ms);ttlMs: undefinedturns them off withDELETE.setChatScreenCapture({ blocking, detection })blocks screenshots or notifies participants about them (PUT .../screen_capture). In groups only admins can change this.chatPinnedMessageslists pinned messages as encoded events (GET .../pinned_messages); decode them with the XDK.deleteChatMessages({ sequenceIds, scope, actionSignatures })deletes messages withPOST .../messages/delete, eitherdelete_for_allordelete_for_self;mediaHashKeysalso deletes attached media for everyone.
await native.setChatMessageTtl({
account,
conversationId: "g1500000000000000000",
ttlMs: 7 * 24 * 60 * 60 * 1000,
context: operationContext(),
});
await native.markChatRead({
account,
conversationId: "g1500000000000000000",
read: true,
context: operationContext(),
});
Keys, media, message requests, and settings
chatPublicKeys({ userIds }) reads users’ public keys and Juicebox configuration, which the XDK
needs to encrypt for them: one user through GET /2/users/{id}/public_keys, up to 100 through
GET /2/users/public_keys?ids=. registerChatPublicKey({ publicKey }) registers the account’s
key object built by the XDK (POST /2/users/{id}/public_keys), and
addChatConversationKeys adds or rotates a conversation’s keys
(POST /2/chat/conversations/{id}/keys).
uploadChatMedia({ conversationId, media }) uploads media the XDK already encrypted: it calls
POST /2/chat/media/upload/initialize with the byte count, appends 4 MiB multipart segments to
/2/chat/media/upload/{id}/append, and finalizes with num_parts (plus optional messageId and
ttlMs). It returns the mediaHashKey to reference in the encrypted message and needs
media.write. downloadChatMedia({ conversationId, mediaHashKey }) downloads the encrypted bytes
from GET /2/chat/media/{id}/{media_hash_key} into memory up to maxBytes (default 15 MiB).
Message requests are chats from people the account does not follow.
chatMessageRequests({ inboxType, limit }) lists them (GET /2/chat/message_requests, limit 1
to 50, inboxType you_may_know or low_quality), getChatMessageRequest reads one, and
acceptChatMessageRequest moves it to the inbox. chatSettings({ account }) reads
GET /2/chat/settings; pass settings to update allowMessagesFrom (all, verified, or
following), alwaysAllowSubscribers, qualityFilter, readReceipts, or callSettings with
PUT /2/chat/settings.
Live broadcasts (beta)
The broadcast methods manage X live video for the account (scopes broadcast.read and
broadcast.write). Create an RTMP/RTMPS ingest point with
createStreamSource({ account, name, region }) or list them with streamSources; each source
includes its secret stream key, so keep the response server-side. scheduleBroadcast binds a
source to a start time (and optional end, title, replay, and chat settings) through
POST /2/broadcasts/scheduled. With manualPublish: true, call goLive when the encoder is
ready; otherwise X publishes at the start time. broadcasts and getBroadcast read the
account’s broadcasts, endBroadcast ends a running one, and deleteBroadcast and
deleteScheduledBroadcast remove them.
For chat, broadcastChat reads up to 200 messages per page, sendBroadcastChat posts up to 140
characters, and removeBroadcastChatMessage deletes one. setChatModerator adds or removes a
persistent moderator, and setChatMute mutes a user in the host’s chats, as a timeout when
endAt is set. Writes raise ambiguous_outcome unless X confirms the new state.
const source = await native.createStreamSource({
account,
name: "Studio",
region: "us-east-1",
context: operationContext(),
});
const scheduled = await native.scheduleBroadcast({
account,
broadcast: {
sourceId: String(source["id"]),
startAt: "2026-11-05T18:00:00Z",
title: "Launch stream",
manualPublish: true,
},
context: operationContext(),
});
ingestRegion({ account }) reads GET /2/region, which returns the region X assigns to the
caller (us-east-1 or us-west-2); pass it to createStreamSource instead of hard-coding one.
Scheduled broadcasts take more options: isLocked (is_locked), thumbnailMediaId
(thumbnail_media_id, an uploaded image shown before the stream starts), and, on create only,
recurrence: { frequency: "Daily" | "Weekly", repeats } for a recurring schedule. X notes that
recurring schedules are being retired, so check the result. scheduledBroadcasts filters by
start time with oldestStartAt and newestStartAt (oldest_start_time, newest_start_time).
getScheduledBroadcast reads one, and updateScheduledBroadcast({ broadcastId, scheduledBroadcastId, broadcast }) replaces its settings with PUT /2/broadcasts/scheduled/{id},
which needs both start and end times.
setBroadcastChatMute({ broadcastId, userId, muted, endAt, removeMessageId }) is the
per-broadcast mute: it calls POST /2/broadcasts/{id}/chat/mutes (optionally removing one chat
message) or DELETE /2/broadcasts/{id}/chat/mutes/{user_id}, while setChatMute mutes across the
host’s chats.
The user-scoped routes manage broadcasts and sources of the authenticated user directly:
userBroadcastsandgetUserBroadcastreadGET /2/users/{id}/broadcastsand/2/users/{id}/broadcasts/{broadcast_id};createUserBroadcast({ sourceId })starts a broadcast on one of the account’s sources and returns it with itsshareUrl;deleteUserBroadcastremoves it.setUserBroadcastState({ broadcastId, state })callsPUT /2/users/{id}/broadcasts/{broadcast_id}/state.{ kind: "publish", title, locale, chatOption, shouldNotTweet }publishes it;shouldNotTweet: true(should_not_tweet) goes live without posting the announcing Post.{ kind: "end" }ends it.userStreamSources,getUserStreamSource,createUserStreamSource({ name, region })(regionatlaorpdxa, X’s default),renameUserStreamSource, anddeleteUserStreamSourcemanage/2/users/{id}/sources. Sources include their stream keys, so keep them server-side.
await native.setUserBroadcastState({
account,
broadcastId: "1yNGaZbBzRjKj",
state: { kind: "publish", title: "Office hours", shouldNotTweet: true },
context: operationContext(),
});
The adapter does not send three broadcast fields X ignores or deprecated; see Not supported.
Calls, bots, and developer account (beta)
These routes are in X’s OpenAPI 2.173 but not yet in the docs.x.com reference pages, so treat them as beta and expect changes.
X Calls are audio and video calls, standalone or inside an X Chat conversation.
createCall({ conversationId, title, joinPolicy, audioOnly }) starts one hosted by the account
with POST /2/calls (calls.write); joinPolicy is open (anyone with the link), closed (the
host admits each person), or trusted. The call starts empty. joinCall({ callId, audio })
(POST /2/calls/{id}/join, calls.join) starts a media session and returns a media_url and a
short-lived media_token for a WebSocket that exchanges PCM16 audio; keep the token secret.
getCall reads a call and leaveCall ends the session.
Scheduled calls use /2/calls/scheduled: scheduleCall({ call }) takes an RFC 3339
startTime, title (up to 100 characters), description (up to 1,000), durationMinutes
(1 to 44,640), up to 25 inviteeIds, notifyInvitees, an RFC 5545 recurrence such as
FREQ=WEEKLY;BYDAY=MO, and a timeZone. scheduledCalls, getScheduledCall,
updateScheduledCall, cancelScheduledCall, and rsvpScheduledCall({ response }) (accepted,
declined, or tentative) manage them; for a recurring call, scope (all, this, or
this_and_following) and occurrenceStartTime choose the occurrences.
Bot accounts belong to the app’s project. createBot({ handle, displayName, orgOnly, scopes })
calls POST /2/bots and returns the bot with a new bearer token; repeating it for an existing
handle re-mints the token and revokes the old one. An orgOnly bot has no public profile and only
members of the developer account can add it to group chats. bots lists them,
updateBot({ handle, displayName, dmPermission }) changes them (dmPermission everyone,
premium, or no_one), rotateBotToken and revokeBotToken manage /2/bots/{id}/token, and
deleteBot removes one. Listing, updating, deleting, and revoking need appBearerToken; create and
rotate prefer it and fall back to the user token. Store returned bot tokens like any other
secret.
developerAccount({ account }) reads the user’s X Developer Platform account with
GET /2/account (developer.read), and ensureDeveloperAccount({ metadata }) creates one if it
is missing with POST /2/account (developer.write), returning created: false when it already
existed.
Batch compliance jobs
X’s Developer Agreement requires apps that store Posts or users to remove content that was
deleted, protected, or suspended. Batch compliance
checks up to millions of stored IDs at once, and every call uses appBearerToken:
createComplianceJob({ account, type, name, resumable })callsPOST /2/compliance/jobswithtypetweetsorusers. The job carries a signeduploadUrl(valid for 15 minutes) and a signeddownloadUrl; treat both as secrets.uploadComplianceIds({ account, job, ids })sends the IDs, one per line, as aPUTtojob.uploadUrl, with the app bearer when the URL is onapi.x.com(/2/compliance/jobs/{id}/upload) and without anAuthorizationheader for a signed storage URL. X accepts up to 100 MB of IDs per job.- Poll
getComplianceJob({ account, jobId })(GET /2/compliance/jobs/{id}) untilstatusiscompleteorfailed.complianceJobs({ account, type, status })lists the app’s jobs. downloadComplianceResults({ account, job })reads the newline-delimited JSON results and returns one object per affected ID, withid,action(delete,rehydrateto re-fetch an edited Post, orscrub_geo), andreason. IDs missing from the results need no action, and invalid input lines come back as{ id, error: "invalid_id" }. The body is buffered up tomaxBytes(default 64 MiB). Results stay available for about 7 days.
cancelComplianceJob({ account, jobId }) cancels a job with DELETE /2/compliance/jobs/{id}.
const job = await native.createComplianceJob({
account,
type: "tweets",
context: operationContext(),
});
await native.uploadComplianceIds({ account, job, ids: storedPostIds, context: operationContext() });
// Later, once getComplianceJob reports status "complete":
const results = await native.downloadComplianceResults({
account,
job,
context: operationContext(),
});
for (const row of results)
if (row["action"] === "delete") await deleteStoredPost(String(row["id"]));
Community Notes
Community Notes endpoints let an
approved AI Note Writer propose notes. They use the user token (tweet.write for writes,
tweet.read for searches), and every call states testMode; while the writer is in test mode,
notes are not publicly visible.
postsEligibleForNotes({ account, testMode, postSelection })lists Posts that can get a note (GET /2/notes/search/posts_eligible_for_notes).evaluateCommunityNote({ account, postId, text })scores draft text withPOST /2/notes/evaluateand returns X’sclaimOpinionScore.createCommunityNote({ account, note })posts toPOST /2/noteswith the Post ID,text(X requires a URL in it),classification(misinformed_or_potentially_misleadingornot_misleading),misleadingTagssuch asfactual_errorormissing_important_context,trustworthySources, optionalisMediaNote, andtestMode.communityNotesWritten({ account, testMode })lists the writer’s notes with their status and test results, anddeleteCommunityNote({ account, noteId })deletes one.
const note = await native.createCommunityNote({
account,
note: {
postId: "1700000000000000000",
text: "The figure is from 2019. https://example.com/report",
classification: "misinformed_or_potentially_misleading",
misleadingTags: ["outdated_information"],
trustworthySources: true,
testMode: true,
},
context: operationContext(),
});
News
searchNews({ account, query, maxAgeHours, limit, newsFields }) searches the stories X
summarizes from trending conversation with GET /2/news/search; maxAgeHours is 1 to 720 (X
defaults to 168) and limit 1 to 100. getNews({ account, newsId }) reads one story with
GET /2/news/{id}, for example after a news.new Activity event. Both request the story’s name,
category, summary, hook, keywords, contexts, disclaimer, and update time unless you pass
newsFields, and both use the user token or fall back to appBearerToken
(reference).
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 update = await native.addStreamRules({
account,
rules: [{ value: "from:opencoredev -is:retweet", tag: "announcements" }],
context: operationContext(),
});
for (const error of update.errors) console.warn(error["title"], error["value"]);
const rules = await native.listStreamRules({ account, context: operationContext() });
await native.deleteStreamRules({
account,
ids: rules.items.map((rule) => rule.id),
context: operationContext(),
});
deleteAllStreamRules({ account, dryRun }) clears the whole rule set in one request by calling
POST /2/tweets/search/stream/rules?delete_all=true, which X accepts instead of an add or
delete body. Combine it with dryRun: true to see the summary without deleting anything.
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. Stop on billing_required, authorization failures, or terminal retry dispositions.
For failures that permit a retry, 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;
const transientConnectionFailure =
error.code === "timeout" ||
(error.code === "upstream_failure" &&
(error.upstreamCode === "stream-connection-interrupted" ||
(error.upstreamStatus !== undefined && error.upstreamStatus >= 500)));
if (
error.code === "billing_required" ||
(error.retryDisposition.kind === "never" && !transientConnectionFailure) ||
error.retryDisposition.kind === "after-reconnect" ||
error.retryDisposition.kind === "reconcile-first"
)
throw error;
if (error.code === "rate_limited")
delayMs = Math.max(
60_000,
delayMs * 2,
error.retryDisposition.kind === "after-delay" ? error.retryDisposition.delayMs : 0,
);
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 loop explicitly reconnects after timeouts, dropped connections, and HTTP 5xx responses.
A headerless HTTP 429 carries a one-minute after-delay disposition; the loop waits at
least that long before reconnecting. Close competing connections if X reports a connection limit.
Connection interruptions carry upstreamCode: "stream-connection-interrupted". Invalid JSON,
invalid UTF-8, and oversized messages keep a terminal disposition and stop the loop.
The stream opens one connection per call; it does not reconnect internally.
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.
streamRuleCounts({ account }) reads GET /2/tweets/search/stream/rules/counts and returns the
project’s and client app’s rule counts with their caps, so you can check headroom before adding
rules. Enterprise apps can deliver matching posts to a registered webhook instead of holding a
connection: linkStreamWebhook({ account, webhookId }) calls
POST /2/tweets/search/webhooks/{webhook_id}, unlinkStreamWebhook removes the link, and
streamWebhooks lists the current links. Rules stay on the same /2/tweets/search/stream/rules
endpoints.
The Connections API helps recover from a connection-limit error. streamConnections({ account, status, endpoints }) reads GET /2/connections (active by default) with each connection’s ID,
endpoint, client IP, and connect and disconnect times. terminateStreamConnections closes
connections by UUID ({ kind: "uuids", uuids }), for one endpoint
({ kind: "endpoint", endpoint: "filtered_stream" }), or all of them ({ kind: "all" }), and
returns the successful and failed counts. All of these use appBearerToken.
const counts = await native.streamRuleCounts({ account, context: operationContext() });
console.log(`${counts.projectRulesCount} of ${counts.capPerProject} rules used`);
const active = await native.streamConnections({ account, context: operationContext() });
if (active.items.length > 0)
await native.terminateStreamConnections({
account,
target: { kind: "endpoint", endpoint: "filtered_stream" },
context: operationContext(),
});
See the X documentation for the filtered stream, rule management, disconnections and backoff, and recovery and redundancy.
Volume and compliance streams
Enterprise apps can also stream every public Post, a sample, or compliance events. Both methods
use appBearerToken, open one connection per iteration, never reconnect on their own, and yield
{ kind: "data", data, includes } events with X’s data object unchanged, plus the same error
and other events as the filtered stream. Errors, stall timeouts, and cancellation behave as
described above, so the same reconnect loop works.
volumeStream({ account, stream, partition }) connects to one of X’s
volume streams:
stream |
Endpoint | partition |
|---|---|---|
sample |
GET /2/tweets/sample/stream (1% sampled stream) |
none |
sample10 |
GET /2/tweets/sample10/stream (10%) |
1-2 |
firehose |
GET /2/tweets/firehose/stream |
1-20 |
firehose_en |
GET /2/tweets/firehose/stream/lang/en |
1-8 |
firehose_ja, firehose_ko, firehose_pt |
GET /2/tweets/firehose/stream/lang/{lang} |
1-2 |
likes_firehose |
GET /2/likes/firehose/stream |
1-20 |
likes_sample10 |
GET /2/likes/sample10/stream |
1-2 |
X splits the firehose and sampled streams into partitions; connect once per partition to receive
all of it. Every volume stream accepts tweetFields, expansions, userFields, and
mediaFields, but the Likes streams have their own expansions: attachments.media_keys,
liked_tweet_author_id, and liked_tweet_id. The adapter rejects a Post-only expansion such as
author_id on a Likes stream, and a Like expansion on a Post stream, before connecting.
backfillMinutes (0 to 5) works on every volume stream, and startTime/endTime
on all of them except sample.
complianceStream({ account, stream, partition }) connects to a
compliance stream, which reports
deletions, protections, suspensions, and similar events you must apply to stored data:
posts (GET /2/tweets/compliance/stream, partition 1-4), users
(GET /2/users/compliance/stream, partition 1-4), likes (GET /2/likes/compliance/stream), and
labels (GET /2/tweets/label/stream, Post labels). All four accept backfillMinutes and
startTime/endTime. The adapter checks partitions, windows, and options before connecting.
for await (const event of native.complianceStream({
account,
stream: "posts",
partition: 1,
context: operationContext(),
})) {
if (event.kind === "data" && "delete" in event.data) console.log("Remove", event.data["delete"]);
}
Write confirmation
A native write counts as done only when X answers with the success shape its OpenAPI 2.173 entry documents: the state flag set to the requested value (liked, following, is_member, pinned, deleted, hidden, and so on), or the created object with a non-empty ID (a numeric post ID for posts, polls, quotes, and published Articles; both IDs for a Direct Message). Any other 2xx answer, a lost response, a timeout, or a cancellation after the request was sent is ambiguous_outcome with reconcile-first. Media upload steps stay media_error because no post exists yet.
Raw requests
native.request(input, context) sends one request to an X API v2 endpoint the SDK does not wrap. It uses the user access token, or appBearerToken when no user token is configured, and checks input.account against the configured user and context.backendInstance.
The only host is api (https://api.x.com), which also serves media upload. Paths include the version, such as /2/users/me.
// `native` is social.native("default", { acknowledgeUnsafe: true }); `account` is the
// connected account and `context` an AdapterOperationContext for its backend instance.
const response = await native.request(
{
account,
method: "GET",
path: `/2/users/${account.accountId}/followed_lists`,
query: { "list.fields": "follower_count,member_count" },
},
context,
);
if (response.responseType === "json") console.log(response.status, response.body);
path is relative to the host’s base URL and starts with /; put parameters in query. Before any request, the adapter rejects absolute URLs, // paths, .. segments, encoded separators, ? or # in the path, and unknown host names. It also rejects credential and framing headers such as Authorization, Cookie and Content-Type. It does not follow redirects. A body has a kind of json, text, bytes, form or urlencoded, and its kind sets Content-Type; text and bytes also take a contentType. Set responseType to "text" or "bytes" for a body that is not JSON. Response bodies are capped at 2 MiB, and maxResponseBytes raises the cap to at most 16 MiB. The result has status, responseType, body and lowercase headers. Name extra headers in responseHeaders; set-cookie is never returned. A non-2xx response rejects with a SocialError mapped like the adapter’s other calls.
A raw request makes one attempt. It adds no idempotency key and records no outcome, so you reconcile the writes you send. A write rejects with ambiguous_outcome when it fails without a definitive answer: a timeout, a cancellation after dispatch, a 5xx, or a 2xx body that cannot be decoded. Like other native calls it skips the client’s tenant authorization, so check the tenant’s grant for the account first. Prefer a typed method when one exists.
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.
- 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. See also chunked uploads and media best practices.
The links above are the provenance for the provider limits and access requirements described here; they were checked on 2026-10-11. The repository evidence remains deterministic contract tests, not a live X account, paid-plan check, or claim of provider approval.
Media specifications
posts.publish checks the attachment count, the declared MIME type, and the Blob size before it uploads anything. It cannot read codecs, dimensions, frame rate, or duration from the bytes, so X checks those during upload processing or when the post is created.
| Media | Requirement | X limit | Checked by the SDK before sending |
|---|---|---|---|
| Post | Attachments | Up to 4 images, 1 animated GIF, or 1 video | Yes: more than four images, or a GIF or video with any other media, fails as x.media_count. |
| Image | Format | JPG, PNG, GIF, WEBP | Declared MIME type must be image/jpeg, image/png, or image/webp, else x.image; image/gif is uploaded as a GIF. Bytes are not inspected. |
| Image | File size | 5 MB maximum | Yes: a Blob over 5 MiB (5,242,880 bytes) fails as x.image_size. X can still reject a file between 5 MB and 5 MiB. |
| Animated GIF | File size | 15 MB maximum | Yes: an empty Blob or one over 15 MiB fails as x.gif_size. |
| Animated GIF | Resolution and frames | Recommended 1,280 x 1,080 px or smaller, 350 frames or fewer, 300 million pixels in all | No, X only. A GIF outside these can fail when the post is created. |
| Video | Format | X names no container; H.264 High Profile recommended, YUV 4:2:0, progressive scan, no open GOP | Declared MIME type must be video/mp4, video/quicktime, or video/webm, else x.video. Codecs and scan type are not read. |
| Video | Audio | AAC Low Complexity, mono or stereo; High-Efficiency AAC is not accepted | No, X only |
| Video | Frame rate | 60 fps maximum | No, X only |
| Video | Dimensions | 32 x 32 to 1,280 x 1,024 px | No, X only |
| Video | Aspect ratio | 1:3 to 3:1, with 1:1 pixel aspect ratio | No, X only |
| Video | Duration | 0.5 seconds to 20 minutes; 125 minutes for Premium or verified accounts | No. X checks duration when the post is created and answers 403; the adapter reports a recognized duration refusal as media_error. |
| Video | File size | 8 GB; 16 GB for Premium or verified accounts | Yes: an empty Blob or one over 16 GiB (17,179,869,184 bytes) fails as x.video_size. The SDK does not know the account’s tier. |
Source: X’s media best practices page, size and duration limits and image, GIF, and video specifications (no update date shown), checked 2026-11-04.
Not supported
- 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.169) 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.) - Edits other than text:
updatePostsends only the new text withedit_options.previous_post_id. X’s Create or Edit Post reference accepts the other create fields alongsideedit_options, but neither it nor the edit posts guide says which of them an edit may change, so the SDK does not offer media, poll, or reply-setting edits. To try one, sendPOST /2/tweetswithedit_optionsand the fields throughnative.request, then read the post’sedit_history_tweet_ids. (Checked 2026-11-05.) - Account Activity subscriptions: X is deprecating the Account Activity API in favour of the X Activity API, so the adapter has no methods to create or list Account Activity subscriptions. It still verifies and decodes their deliveries; see Account Activity API (deprecated).
- Three live broadcast fields: X’s OpenAPI 2.173 marks
is_low_latencyonPOST /2/users/{id}/broadcastsas ignored,telecast_idas deprecated and ignored, androll_forwardas deprecated (X rejectstrue), so the adapter does not send them.