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
| Property | Value |
|---|---|
| Account types | Any X account |
| Connection method | OAuth |
| Post types | text, image, multi_image, video |
| Max media per post | 4 |
| Text limit | 280 characters (weighted), or 25,000 with a paid X subscription |
| Threads | |
| Quotes | |
| Remote delete | Whole thread |
| Comments | |
| Likes | |
| Reposts | |
| Bookmarks | |
| Direct messages | No |
| Post analytics | |
| Account analytics | No |
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.
| Action | Price |
|---|---|
| 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
| Scope | Used for |
|---|---|
tweet.read | Reading posts, replies, reposters and metrics |
tweet.write | Posting, replying, quoting, reposting and deleting |
users.read | Name, handle, avatar and subscription |
media.write | Uploading images, GIFs and videos |
like.read, like.write | Likes |
bookmark.read, bookmark.write | Bookmarks |
offline.access | Staying 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 happens | What you see | What to do |
|---|---|---|
| X rejects access on a publish | The post fails with TOKEN_EXPIRED | Retry, then reconnect if it repeats |
| Access can no longer renew | status leaves active, posts fail with TOKEN_INVALID | Reconnect |
| You publish to an inactive account | 409 CONNECTION_INACTIVE | Reconnect |
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
mediaKind | Media |
|---|---|
text | None, text required |
image | 1 image or 1 GIF |
multi_image | 2 to 4 images, no GIF or video |
video | 1 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.
| Rule | Detail |
|---|---|
| Reply text | Empty only with media. Same limit as a post |
| Reply media | Up to 4 images, or 1 video or GIF |
| Checked first | All text and media, before anything goes live |
| Price | Each 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.
| Media | Formats | Max size |
|---|---|---|
| Images | JPEG, PNG, GIF and WebP | 5 MB, GIF 15 MB |
| Videos | MP4, MOV and WebM | 512 MB |
A file outside these formats and sizes is refused. See Platform Limits.
Images
| Property | Requirement |
|---|---|
| Per post | 1 (image) or 2 to 4 (multi_image) |
| Recommended | 1200 × 675 (16:9), 1200 × 1200 (1:1) or 1080 × 1350 (4:5) |
GIFs
| Property | Requirement |
|---|---|
| Max dimensions | 1280 × 1080 |
| Per post | 1, alone |
Videos
| Property | Requirement |
|---|---|
| Codec | H.264 video, AAC audio |
| Duration | 0.5 to 140 seconds |
| Per post | 1, alone |
| Recommended | 1280 × 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.
| Action | Supported | Notes |
|---|---|---|
| List comments | Yes | Last 7 days, charged per post X returns, and per author from November 22, 2026 |
| Comment or reply | Yes | Posted as a reply |
| Delete a comment | Yes | The account's own replies |
| Hide or like a comment | No | |
| Private reply | No | |
| Like, repost, bookmark | Yes | Any post. Bookmarks are private |
| List reposters | Yes | Your posts, charged per account X returns |
| Quote | Yes | See 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.
| Action | On X |
|---|---|
| List | Replies from the last 7 days, X's search window, without the post itself |
| Comment | A reply to the post, with a post's limit and price |
| Reply | A reply to a reply |
| Delete | The 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
| Field | X metric |
|---|---|
impressions | Impressions |
reach | Always null |
views | Impressions |
likes | Likes |
comments | Replies |
shares | Reposts plus quotes |
saves | Bookmarks |
engagement | likes + 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.
| Read | Charge |
|---|---|
| First read of the UTC day | The analytics price |
| Later reads that day | Free, same numbers |
| A read with no metrics | Free, the next call reads again |
| A due read the wallet cannot cover | 402 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
| Code | Cause | Fix |
|---|---|---|
POST_NOT_PUBLISHED | 409. The quoted pst_ post is not live | Wait, or use its X id |
TEXT_TOO_LONG | Over the account's limit at publish | Shorten the text |
INVALID_MEDIA | Too many files, mixed video, or an empty file | See Media Requirements |
MEDIA_PROCESSING_FAILED | X could not process the media | Re-encode the file |
MEDIA_PROCESSING_TIMEOUT | X took too long | Use a smaller or shorter file |
GEO_RESTRICTION_FAILED | X rejected the country list | Fix the codes, or drop them |
DUPLICATE_CONTENT | X saw the text as a duplicate | Change the text |
ACCOUNT_RESTRICTED | Suspended, locked or read-only | Fix it in the X app |
RATE_LIMITED | X throttled the account, or a publishing limit | Post again later |
INSUFFICIENT_WALLET | 402 on a post that publishes now or a delete, or a scheduled post the wallet does not cover at publish | Top up |
THREAD_DELETE_PARTIAL | Some replies were not deleted | Send the delete again |
PUBLISH_FAILED | X rejected the post | Read error.message |
Engagement Errors
| Code | Cause | Fix |
|---|---|---|
POST_ACCOUNT_MISMATCH | Another account's pst_ id | Use its X id |
NOT_A_COMMENT | The cmt_ id is your own post | Delete the post |
UNSUPPORTED_PLATFORM | Not on X, such as hiding a reply | Leave it out for X |
ENGAGEMENT_PERMISSION, REPOST_PERMISSION, UNREPOST_PERMISSION | X refused access | Reconnect and approve every permission |
REPOSTS_UNAUTHORIZED, COMMENTS_PERMISSION | X refused a list or a delete | Reconnect. For a delete, check the reply is yours |
DUPLICATE_CONTENT | A duplicate comment | Change the text |
INSUFFICIENT_WALLET | 402. The wallet cannot cover it | Top up |
Analytics Errors
| Code | Cause | Fix |
|---|---|---|
POST_NOT_FOUND | Deleted on X, or hidden from the account | Check the post on X |
TIMELINE_UNSUPPORTED | X has no daily timeline | Use Get Post Analytics |
INSUFFICIENT_WALLET | 402. The day's read is not covered | Top up |