Skip to content
Social SDK Cloud, the hosted SDK, is taking early-access signups.Join the list
Social SDK megaphone markSocial SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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, and mediaTaggedUsers.
  • Post for Me: replySettings, quotePostId, poll, and communityId.
  • PostFast: replySettings, repostUrl, and firstComment.
  • Postiz: replySettings, community, aiGenerated, paidPartnership, article, and the post-level thread.
  • Buffer: aiGenerated, retweet, and thread; no replySettings.

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.
  • limit must be from 10 through 100. Omit it to let X use its default of 10.
  • X allows 450 recent search requests per 15 minutes per app and 300 per user.
  • The call works with the user token (tweet.read and users.read) or, without one, the app bearer token.
  • Post reads count toward the app’s X API usage and billing.

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

Like and unlike posts

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

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: 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 }) requests non_public_metrics and organic_metrics for up to 100 posts from the last 30 days, such as url_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 }) calls GET /2/tweets/analytics for an engagement time series (hourly, daily, weekly, or total) with fields such as impressions, engagements, url_clicks, and user_profile_clicks.
  • mediaAnalytics({ account, mediaKeys, startTime, endTime, granularity, fields }) calls GET /2/media/analytics for video metrics such as video_views, playback_complete, and watch_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

  1. initializeChatGroup({ account }) reserves a group ID with POST /2/chat/conversations/group/initialize.
  2. createChatGroup({ account, group }) creates it with POST /2/chat/conversations/group: conversationId, conversationKeyVersion, participantKeys, memberIds, and optional adminIds, encrypted name, description, and avatarUrl, messageTtlMs, keyRotation, and actionSignatures.
  3. getChatConversation reads a conversation’s type, group fields, admin settings, invite, and mute, archive, and disappearing-message state (GET /2/chat/conversations/{id}). updateChatGroup changes the encrypted name, avatar, or description (PUT /2/chat/conversations/{id} with modified_fields), and deleteChatConversation removes 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 }) calls POST or DELETE /2/chat/conversations/{id}/archive.
  • setChatMuted({ muted }) calls POST or DELETE /2/chat/conversations/{id}/mute.
  • markChatRead({ read: true }) sends a read receipt with POST /2/chat/conversations/{id}/read (optionally signed); read: false calls /unread.
  • sendChatTyping sends a typing indicator with POST /2/chat/conversations/{id}/typing.
  • setChatMessageTtl({ ttlMs }) turns on disappearing messages with PUT .../message_ttl (message_ttl_ms); ttlMs: undefined turns them off with DELETE.
  • setChatScreenCapture({ blocking, detection }) blocks screenshots or notifies participants about them (PUT .../screen_capture). In groups only admins can change this.
  • chatPinnedMessages lists pinned messages as encoded events (GET .../pinned_messages); decode them with the XDK.
  • deleteChatMessages({ sequenceIds, scope, actionSignatures }) deletes messages with POST .../messages/delete, either delete_for_all or delete_for_self; mediaHashKeys also 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:

  • userBroadcasts and getUserBroadcast read GET /2/users/{id}/broadcasts and /2/users/{id}/broadcasts/{broadcast_id}; createUserBroadcast({ sourceId }) starts a broadcast on one of the account’s sources and returns it with its shareUrl; deleteUserBroadcast removes it.
  • setUserBroadcastState({ broadcastId, state }) calls PUT /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 }) (region atla or pdxa, X’s default), renameUserStreamSource, and deleteUserStreamSource manage /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:

  1. createComplianceJob({ account, type, name, resumable }) calls POST /2/compliance/jobs with type tweets or users. The job carries a signed uploadUrl (valid for 15 minutes) and a signed downloadUrl; treat both as secrets.
  2. uploadComplianceIds({ account, job, ids }) sends the IDs, one per line, as a PUT to job.uploadUrl, with the app bearer when the URL is on api.x.com (/2/compliance/jobs/{id}/upload) and without an Authorization header for a signed storage URL. X accepts up to 100 MB of IDs per job.
  3. Poll getComplianceJob({ account, jobId }) (GET /2/compliance/jobs/{id}) until status is complete or failed. complianceJobs({ account, type, status }) lists the app’s jobs.
  4. downloadComplianceResults({ account, job }) reads the newline-delimited JSON results and returns one object per affected ID, with id, action (delete, rehydrate to re-fetch an edited Post, or scrub_geo), and reason. IDs missing from the results need no action, and invalid input lines come back as { id, error: "invalid_id" }. The body is buffered up to maxBytes (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 with POST /2/notes/evaluate and returns X’s claimOpinionScore.
  • createCommunityNote({ account, note }) posts to POST /2/notes with the Post ID, text (X requires a URL in it), classification (misinformed_or_potentially_misleading or not_misleading), misleadingTags such as factual_error or missing_important_context, trustworthySources, optional isMediaNote, and testMode.
  • communityNotesWritten({ account, testMode }) lists the writer’s notes with their status and test results, and deleteCommunityNote({ 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:

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

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

The adapter never reconnects on its own. If you want a long-running consumer, write the reconnect loop yourself. 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/tweets publishes immediately, and posts.prepare rejects a request with schedule (x.operation) before any network call. X’s Ads API has scheduled Tweets at ads-api.x.com/12/accounts/:account_id/scheduled_tweets, but it needs Ads API approval, an ads account, and OAuth 1.0a-signed requests. This adapter only uses OAuth 2.0 bearer tokens. Those scheduled Tweets are promoted-only (nullcast=true) by default and stay off the public timeline, and only the ads account’s full promotable user can create organic ones. To publish at a set time, run your own job runner that calls posts.publish. (Checked 2026-09-24.)
  • The adapter cannot update the account profile. X API v2 (OpenAPI 2.169) has no profile write endpoint. docs.x.com no longer publishes the v1.1 POST account/update_profile reference, and v1.1 user writes require OAuth 1.0a. (Checked 2026-09-24.)
  • Edits other than text: updatePost sends only the new text with edit_options.previous_post_id. X’s Create or Edit Post reference accepts the other create fields alongside edit_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, send POST /2/tweets with edit_options and the fields through native.request, then read the post’s edit_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_latency on POST /2/users/{id}/broadcasts as ignored, telecast_id as deprecated and ignored, and roll_forward as deprecated (X rejects true), so the adapter does not send them.

Last updated on November 5, 2026