Platforms

X (Twitter)

Connect an X account, publish posts, threads and quotes, like, repost, bookmark and reply to posts, and read post metrics, all paid from your wallet.

Publish posts and threads, engage and read metrics, all paid from your wallet.

Quick Reference

PropertyValue
Account typesAny X account
Connection methodOAuth
Post typestext, image, multi_image, video
Max media per post4
Text limit280 characters (weighted), or 25,000 with a paid X subscription
Threads
Quotes
Remote deleteWhole thread
Comments
Likes
Reposts
Bookmarks
Direct messagesNo
Post analytics
Account analyticsNo

X weighs text. CJK and emoji count as 2, a URL as 23, the rest as 1. Each account has its own limit, see X Subscriptions.

X Wallet

X charges for every API call, so X actions are paid from your prepaid wallet. Posting to any other platform never touches the wallet.

ActionPrice
Post, reply or quote$0.015, or $0.20 with a URL
Like or repost$0.015
Bookmark, or remove a bookmark$0.005
Unlike, undo a repost, or delete one post$0.01
Country restriction$0.005 per image or video
Post analytics$0.005 per post, once per UTC day
List replies, sync or list media$0.005 per post X returns
Reply authors, from November 22, 2026$0.01 per author X returns
List reposters$0.01 per account X returns

Each thread post is charged on its own. A low balance returns 402 INSUFFICIENT_WALLET and changes nothing. A call X refuses or fails is not charged. A request X answers is, even a like that did not take. The owner tops up under Settings → Wallet. See X Prices and X Wallet.

Connect

Any X account works. Connect it in Dashboard → Connections, or with a connect session for platform: "x". See Connect Accounts.

import { Configuration, ConnectApi } from "@onepostly/sdk";

const connectApi = new ConnectApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await connectApi.createConnectSession({
  createConnectSessionBody: {
    "platform": "x",
    "redirectUrl": "https://app.example.com/onepostly/connected"
  },
});
console.log(response);
{
  "url": "https://x.com/i/oauth2/authorize?response_type=code&client_id=bTJ4ZFhfY2xpZW50&scope=tweet.read%20tweet.write%20users.read%20media.write%20like.read%20like.write%20bookmark.read%20bookmark.write%20offline.access&state=st_9Kq2mXv7Lp&code_challenge=Yk3q9dW2nR8vL5xT0aZ7cB4mF1hJ6sP3uE8gN2kQ5wA&code_challenge_method=S256",
  "expiresAt": "2027-03-01T10:05:00.000Z"
}

The browser returns to redirectUrl with the accountId. Each X account also has xSubscriptionType, which sets its text limit.

Permissions

ScopeUsed for
tweet.readReading posts, replies, reposters and metrics
tweet.writePosting, replying, quoting, reposting and deleting
users.readName, handle, avatar and subscription
media.writeUploading images, GIFs and videos
like.read, like.writeLikes
bookmark.read, bookmark.writeBookmarks
offline.accessStaying connected without a new sign-in

Staying Connected

X access renews itself, so canAutoRenew is true. Access ends when the owner revokes the app or changes the account password.

What happensWhat you seeWhat to do
X rejects access on a publishThe post fails with TOKEN_EXPIREDRetry, then reconnect if it repeats
Access can no longer renewstatus leaves active, posts fail with TOKEN_INVALIDReconnect
You publish to an inactive account409 CONNECTION_INACTIVEReconnect

A reconnect keeps the account id, so scheduled posts keep working.

Publish

import { Configuration, PostsApi } from "@onepostly/sdk";

const postsApi = new PostsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await postsApi.createPost({
  createPostBody: {
    "text": "Version 2.4 is live. Faster sync and a new dashboard.",
    "accounts": [
      {
        "accountId": "acc_YOUR_X_ACCOUNT_ID"
      }
    ]
  },
});
console.log(response);
{
  "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
  "data": [
    {
      "id": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
      "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
      "accountId": "acc_YOUR_X_ACCOUNT_ID",
      "platform": "x",
      "source": "onepostly",
      "status": "publishing",
      "text": "Version 2.4 is live. Faster sync and a new dashboard.",
      "mediaKind": "text",
      "media": [],
      "thread": [],
      "scheduledFor": null,
      "timezone": null,
      "options": {},
      "platformPostId": null,
      "url": null,
      "error": null,
      "publishedAt": null,
      "metrics": null,
      "createdAt": "2027-03-15T09:00:00.000Z",
      "updatedAt": "2027-03-15T09:00:00.000Z"
    }
  ]
}

Once published, platformPostId is the numeric X post id and url its x.com link. See Post Status for every status a post can have.

Post Types

mediaKindMedia
textNone, text required
image1 image or 1 GIF
multi_image2 to 4 images, no GIF or video
video1 video

stories returns 400 VALIDATION_ERROR. Without mediaKind, the files decide. A GIF always goes alone, as image. A GIF mixed with other media passes when you create the post but fails at publish.

Threads

Add thread for 1 to 25 replies after the first post. The top-level text and media are the first post. See Threads, Replies and Quotes.

RuleDetail
Reply textEmpty only with media. Same limit as a post
Reply mediaUp to 4 images, or 1 video or GIF
Checked firstAll text and media, before anything goes live
PriceEach post is charged
import { Configuration, PostsApi } from "@onepostly/sdk";

const postsApi = new PostsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await postsApi.createPost({
  createPostBody: {
    "text": "How to ship your first integration in 3 steps.",
    "mediaKind": "image",
    "media": [
      {
        "url": "https://cdn.onepostly.com/media/cover.jpg"
      }
    ],
    "thread": [
      {
        "text": "1/ Create an API key in the dashboard."
      },
      {
        "text": "2/ Connect your X account.",
        "media": [
          {
            "url": "https://cdn.onepostly.com/media/connect.jpg"
          }
        ]
      },
      {
        "text": "3/ Send your first post."
      }
    ],
    "accounts": [
      {
        "accountId": "acc_YOUR_X_ACCOUNT_ID"
      }
    ]
  },
});
console.log(response);
{
  "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
  "data": [
    {
      "id": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
      "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
      "accountId": "acc_YOUR_X_ACCOUNT_ID",
      "platform": "x",
      "source": "onepostly",
      "status": "publishing",
      "text": "How to ship your first integration in 3 steps.",
      "mediaKind": "image",
      "media": [
        {
          "url": "https://cdn.onepostly.com/media/cover.jpg"
        }
      ],
      "thread": [
        {
          "text": "1/ Create an API key in the dashboard.",
          "media": []
        },
        {
          "text": "2/ Connect your X account.",
          "media": [
            {
              "url": "https://cdn.onepostly.com/media/connect.jpg"
            }
          ]
        },
        {
          "text": "3/ Send your first post.",
          "media": []
        }
      ],
      "scheduledFor": null,
      "timezone": null,
      "options": {},
      "platformPostId": null,
      "url": null,
      "error": null,
      "publishedAt": null,
      "metrics": null,
      "createdAt": "2027-03-15T09:00:00.000Z",
      "updatedAt": "2027-03-15T09:00:00.000Z"
    }
  ]
}

platformPostId and url point to the first post. If a reply fails, the live posts stay and the post ends failed. They stay charged, and the posts that never went out go back to the wallet. Deleting removes them.

Replies and Quotes

x.quotePostId quotes the pst_ id of your X post, or any numeric X id.

import { Configuration, PostsApi } from "@onepostly/sdk";

const postsApi = new PostsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await postsApi.createPost({
  createPostBody: {
    "text": "Worth reading if you run scheduled jobs.",
    "accounts": [
      {
        "accountId": "acc_YOUR_X_ACCOUNT_ID",
        "x": {
          "quotePostId": "1960956321831854280"
        }
      }
    ]
  },
});
console.log(response);
{
  "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
  "data": [
    {
      "id": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
      "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
      "accountId": "acc_YOUR_X_ACCOUNT_ID",
      "platform": "x",
      "source": "onepostly",
      "status": "publishing",
      "text": "Worth reading if you run scheduled jobs.",
      "mediaKind": "text",
      "media": [],
      "thread": [],
      "scheduledFor": null,
      "timezone": null,
      "options": {
        "x": {
          "quotePostId": "1960956321831854280"
        }
      },
      "platformPostId": null,
      "url": null,
      "error": null,
      "publishedAt": null,
      "metrics": null,
      "createdAt": "2027-03-15T09:00:00.000Z",
      "updatedAt": "2027-03-15T09:00:00.000Z"
    }
  ]
}

A pst_ id from another platform returns 400 VALIDATION_ERROR. One not yet live returns 409 POST_NOT_PUBLISHED.

X has no replyToPostId. Reply with the comment endpoints.

Options

X options go under x in each accounts entry.

Prop

Type

Geo Restriction

x.geoRestriction shows the media only in the listed countries, while the text stays visible. It covers every thread post with media. Each image or video it covers costs $0.005. A restriction X accepted stays charged if the post then fails.

Without any media, you get 400 VALIDATION_ERROR. If X rejects the list, the post fails with GEO_RESTRICTION_FAILED.

import { Configuration, PostsApi } from "@onepostly/sdk";

const postsApi = new PostsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await postsApi.createPost({
  createPostBody: {
    "text": "Spring sale, available in the US and Spain.",
    "mediaKind": "image",
    "media": [
      {
        "url": "https://cdn.onepostly.com/media/sale.jpg"
      }
    ],
    "accounts": [
      {
        "accountId": "acc_YOUR_X_ACCOUNT_ID",
        "x": {
          "geoRestriction": [
            "US",
            "ES"
          ]
        }
      }
    ]
  },
});
console.log(response);
{
  "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
  "data": [
    {
      "id": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
      "groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
      "accountId": "acc_YOUR_X_ACCOUNT_ID",
      "platform": "x",
      "source": "onepostly",
      "status": "publishing",
      "text": "Spring sale, available in the US and Spain.",
      "mediaKind": "image",
      "media": [
        {
          "url": "https://cdn.onepostly.com/media/sale.jpg"
        }
      ],
      "thread": [],
      "scheduledFor": null,
      "timezone": null,
      "options": {
        "x": {
          "geoRestriction": [
            "US",
            "ES"
          ]
        }
      },
      "platformPostId": null,
      "url": null,
      "error": null,
      "publishedAt": null,
      "metrics": null,
      "createdAt": "2027-03-15T09:00:00.000Z",
      "updatedAt": "2027-03-15T09:00:00.000Z"
    }
  ]
}

X Subscriptions

A paid X subscription raises the limit from 280 to 25,000 in posts, thread replies and comments. xSubscriptionType on the account is None, Basic, Premium or PremiumPlus, and anything but None gets 25,000.

A change can take a few hours, or apply now with a reconnect. Each account is checked alone, so one text can pass on one account and fail on another. If the subscription ends before publish, the post fails with TEXT_TOO_LONG.

Media Requirements

These are X's limits, on top of the Media URL Requirements.

MediaFormatsMax size
ImagesJPEG, PNG, GIF and WebP5 MB, GIF 15 MB
VideosMP4, MOV and WebM512 MB

A file outside these formats and sizes is refused. See Platform Limits.

Images

PropertyRequirement
Per post1 (image) or 2 to 4 (multi_image)
Recommended1200 × 675 (16:9), 1200 × 1200 (1:1) or 1080 × 1350 (4:5)

GIFs

PropertyRequirement
Max dimensions1280 × 1080
Per post1, alone

Videos

PropertyRequirement
CodecH.264 video, AAC audio
Duration0.5 to 140 seconds
Per post1, alone
Recommended1280 × 720, 30 fps

The content type, then the extension, marks a file as a video, GIF or image.

Scheduling, Editing and Deleting

Scheduling works as on any platform. A scheduled post is checked against the balance and charged at publish, so you can top up until then. Published posts cannot be edited.

Deleting a published post removes its whole thread, replies first, and is charged per post. A post already gone on X counts as deleted and is not charged.

A partial delete returns THREAD_DELETE_PARTIAL, so send it again. A post made outside Onepostly is deleted on its own, without its replies, and is charged as one post.

Engagement

Every action is paid from your wallet. See Comments and Likes and Reposts.

ActionSupportedNotes
List commentsYesLast 7 days, charged per post X returns, and per author from November 22, 2026
Comment or replyYesPosted as a reply
Delete a commentYesThe account's own replies
Hide or like a commentNo
Private replyNo
Like, repost, bookmarkYesAny post. Bookmarks are private
List repostersYesYour posts, charged per account X returns
QuoteYesSee Replies and Quotes

Name a post by its pst_ id or its numeric X id. The X id works for any post. Another account's pst_ id returns POST_ACCOUNT_MISMATCH.

Comments

On X, comments are replies, each with a cmt_ id.

ActionOn X
ListReplies from the last 7 days, X's search window, without the post itself
CommentA reply to the post, with a post's limit and price
ReplyA reply to a reply
DeleteThe account's own reply. Your own post returns NOT_A_COMMENT
import { Configuration, CommentsApi } from "@onepostly/sdk";

const commentsApi = new CommentsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await commentsApi.createComment({
  id: "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
  commentTextBody: {
    "text": "Thanks for the feedback, the fix ships Friday."
  },
});
console.log(response);
{
  "id": "cmt_6Hq2ZtW9xLmB4vRk8NpD3sYc7GfJ1aUe5TnQ0oKiXwVb2MzCr6PyLd9EhSj4AgFu8RtNk3WqZm7Yx1BvC5Ls0DpHi2OeJn6Ua9Kf4Tw",
  "postId": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
  "accountId": "acc_YOUR_X_ACCOUNT_ID",
  "platform": "x",
  "platformCommentId": "1900934567890123456",
  "parentId": null,
  "text": "Thanks for the feedback, the fix ships Friday.",
  "username": null,
  "likeCount": 0,
  "createdAt": "2027-03-15T10:00:00.000Z",
  "replies": []
}

Likes, Reposts and Bookmarks

Undo each with DELETE on the same path. For a repost, that path names the original post, so there is no repost id to track.

import { Configuration, EngagementApi } from "@onepostly/sdk";

const engagementApi = new EngagementApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await engagementApi.likePost({
  id: "acc_YOUR_X_ACCOUNT_ID",
  postId: "1960956321831854280",
});
console.log(response);
{
  "postId": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a02",
  "accountId": "acc_YOUR_X_ACCOUNT_ID",
  "platform": "x",
  "liked": true
}

Inbox

X has no inbox through Onepostly.

Analytics

Read metrics for any X post you published or synced.

Metrics

FieldX metric
impressionsImpressions
reachAlways null
viewsImpressions
likesLikes
commentsReplies
sharesReposts plus quotes
savesBookmarks
engagementlikes + comments + shares + saves

X gives detailed counts to the author only, for 30 days. After that, you get the public counts. A missing value is null.

Read Post Analytics

import { Configuration, AnalyticsApi } from "@onepostly/sdk";

const analyticsApi = new AnalyticsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await analyticsApi.getPostAnalytics({
  id: "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
});
console.log(response);
{
  "postId": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
  "accountId": "acc_YOUR_X_ACCOUNT_ID",
  "platform": "x",
  "status": "ready",
  "metrics": {
    "impressions": 1234,
    "reach": null,
    "likes": 123,
    "comments": 12,
    "shares": 4,
    "saves": 2,
    "views": 1234,
    "engagement": 141
  },
  "fetchedAt": null,
  "error": null
}

X has no daily timeline, so the timeline returns TIMELINE_UNSUPPORTED.

Freshness

X metrics are read live when you call, at most once per post per UTC day. fetchedAt is always null. See Freshness.

ReadCharge
First read of the UTC dayThe analytics price
Later reads that dayFree, same numbers
A read with no metricsFree, the next call reads again
A due read the wallet cannot cover402 INSUFFICIENT_WALLET. Posts already read today still answer

Recent Posts

List Account Media lists the account's recent image and video posts, newest first, with metrics. It includes reposts and skips replies. Each post X returns is charged, the text posts it leaves out too.

import { Configuration, AccountsApi } from "@onepostly/sdk";

const accountsApi = new AccountsApi(
  new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);

const response = await accountsApi.listAccountMedia({
  id: "acc_YOUR_X_ACCOUNT_ID",
});
console.log(response);
{
  "data": [
    {
      "id": "1900912345678901234",
      "title": "Watch the 90-second walkthrough. https://t.co/Xk4p9QmZ2v",
      "description": "Watch the 90-second walkthrough. https://t.co/Xk4p9QmZ2v",
      "coverUrl": "https://pbs.twimg.com/amplify_video_thumb/1900912345678901234/img/Qm3nX8kP2rT5vW7y.jpg",
      "url": "https://x.com/yourbrand/status/1900912345678901234",
      "createdAt": "2027-03-14T16:30:12.000Z",
      "durationSec": 90,
      "metrics": {
        "likes": 86,
        "comments": 9,
        "shares": 14,
        "views": 5210
      }
    },
    {
      "id": "1900893456789012345",
      "title": "Ship day in 4 frames. https://t.co/Rb7nL2wQ8s",
      "description": "Ship day in 4 frames. https://t.co/Rb7nL2wQ8s",
      "coverUrl": "https://pbs.twimg.com/media/GkT3bQxWcAAz9Lp.jpg",
      "url": "https://x.com/yourbrand/status/1900893456789012345",
      "createdAt": "2027-03-14T11:05:47.000Z",
      "durationSec": null,
      "metrics": {
        "likes": 42,
        "comments": 3,
        "shares": 5,
        "views": 2318
      }
    }
  ],
  "nextCursor": "7140dibdnow9c7btw4b0ld1ak2pzz6c1c2zuh0xb5c9yd",
  "hasMore": true
}

A post deleted on X, or hidden from the account, reads unavailable with POST_NOT_FOUND. See Analytics Status for the other statuses.

Limitations

Not available for X through Onepostly.

  • Polls
  • Editing a published post
  • replyToPostId. Use comments instead
  • Choosing who can reply
  • Hiding or liking replies
  • Direct messages
  • Account stats and insights
  • Daily analytics timelines
  • Stories

Errors

Breaking a rule on this page returns 400 VALIDATION_ERROR and creates nothing. A file outside the formats and sizes is refused as Platform Limits describes. X's other media limits are checked at publish, and fail the post with INVALID_MEDIA. Every code is in Errors.

Publish Errors

CodeCauseFix
POST_NOT_PUBLISHED409. The quoted pst_ post is not liveWait, or use its X id
TEXT_TOO_LONGOver the account's limit at publishShorten the text
INVALID_MEDIAToo many files, mixed video, or an empty fileSee Media Requirements
MEDIA_PROCESSING_FAILEDX could not process the mediaRe-encode the file
MEDIA_PROCESSING_TIMEOUTX took too longUse a smaller or shorter file
GEO_RESTRICTION_FAILEDX rejected the country listFix the codes, or drop them
DUPLICATE_CONTENTX saw the text as a duplicateChange the text
ACCOUNT_RESTRICTEDSuspended, locked or read-onlyFix it in the X app
RATE_LIMITEDX throttled the account, or a publishing limitPost again later
INSUFFICIENT_WALLET402 on a post that publishes now or a delete, or a scheduled post the wallet does not cover at publishTop up
THREAD_DELETE_PARTIALSome replies were not deletedSend the delete again
PUBLISH_FAILEDX rejected the postRead error.message

Engagement Errors

CodeCauseFix
POST_ACCOUNT_MISMATCHAnother account's pst_ idUse its X id
NOT_A_COMMENTThe cmt_ id is your own postDelete the post
UNSUPPORTED_PLATFORMNot on X, such as hiding a replyLeave it out for X
ENGAGEMENT_PERMISSION, REPOST_PERMISSION, UNREPOST_PERMISSIONX refused accessReconnect and approve every permission
REPOSTS_UNAUTHORIZED, COMMENTS_PERMISSIONX refused a list or a deleteReconnect. For a delete, check the reply is yours
DUPLICATE_CONTENTA duplicate commentChange the text
INSUFFICIENT_WALLET402. The wallet cannot cover itTop up

Analytics Errors

CodeCauseFix
POST_NOT_FOUNDDeleted on X, or hidden from the accountCheck the post on X
TIMELINE_UNSUPPORTEDX has no daily timelineUse Get Post Analytics
INSUFFICIENT_WALLET402. The day's read is not coveredTop up