Posts
Publish text, images, video, GIFs, threads, and quotes on X with scheduling and remote delete.
Publish to X through the shared posts surface. Text, images, video, GIFs, threads, and quotes all go through the same endpoint.
Prepaid wallet required
Every X publish bills the wallet. See billing rules.
Quick Start
Post to X in under 60 seconds:
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "Ship day",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg"
],
"mediaKind": "image",
"destinations": [
{
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Ship day",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg"
],
"mediaKind": "image",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}The response is 202 Accepted with status: "queued". Poll GET /v1/posts/:id until the destination is published or failed, or receive webhooks instead of polling.
Content Types
mediaKind | Content | Media count | Limits | Notes |
|---|---|---|---|---|
text | Text-only post | No media | 280 weighted units (25,000 with X Premium) | mediaUrls must be empty |
image | Single image | Exactly 1 URL | 5 MB | JPEG, PNG, WebP |
multi-image | Image set | 2 to 4 URLs | 5 MB each | Images only |
video | Single video or GIF | Exactly 1 URL | 512 MB video, 15 MB GIF | MP4, MOV, WebM. A .gif URL uploads as a GIF |
stories | No | No | No | Not supported on X |
Tweet With Image
Attach up to 4 images:
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "Ship day in 4 frames",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg",
"https://cdn.onepostly.com/media/product-2.jpg",
"https://cdn.onepostly.com/media/product-3.jpg",
"https://cdn.onepostly.com/media/product-4.jpg"
],
"mediaKind": "multi-image",
"destinations": [
{
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Ship day in 4 frames",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg",
"https://cdn.onepostly.com/media/product-2.jpg",
"https://cdn.onepostly.com/media/product-3.jpg",
"https://cdn.onepostly.com/media/product-4.jpg"
],
"mediaKind": "multi-image",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}Tweet With Video
Attach a single video per tweet. MP4 and MOV formats, up to 512 MB:
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "Watch how it works",
"mediaUrls": [
"https://cdn.onepostly.com/media/demo.mp4"
],
"mediaKind": "video",
"destinations": [
{
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Watch how it works",
"mediaUrls": [
"https://cdn.onepostly.com/media/demo.mp4"
],
"mediaKind": "video",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}Tweet With GIF
Only 1 GIF per tweet. Max 15 MB. Animated GIFs auto-play in the timeline. Send the GIF URL with mediaKind: "video":
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "Friday mood",
"mediaUrls": [
"https://cdn.onepostly.com/media/celebrate.gif"
],
"mediaKind": "video",
"destinations": [
{
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5"
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Friday mood",
"mediaUrls": [
"https://cdn.onepostly.com/media/celebrate.gif"
],
"mediaKind": "video",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}Media Requirements
Images
| Property | Requirement |
|---|---|
| Max images | 4 per post |
| Formats | JPEG, PNG, WebP, GIF |
| Max file size | 5 MB (images), 15 MB (GIFs) |
| Recommended | 1200 × 675 px (16:9) |
Aspect ratios
| Type | Ratio | Dimensions |
|---|---|---|
| Landscape | 16:9 | 1200 × 675 px |
| Square | 1:1 | 1200 × 1200 px |
| Portrait | 4:5 | 1080 × 1350 px |
GIFs
| Property | Requirement |
|---|---|
| Max per post | 1 (consumes all 4 image slots) |
| Max file size | 15 MB |
| Max dimensions | 1280 × 1080 px |
| Behavior | Auto-plays in the timeline |
Videos
| Property | Requirement |
|---|---|
| Max videos | 1 per post |
| Formats | MP4, MOV, WebM |
| Max file size | 512 MB |
| Max duration | 140 seconds |
| Min duration | 0.5 seconds |
| Recommended | 1280 × 720 px, H.264, AAC audio, 30 fps |
Media URL Requirements
These do not work as media URLs:
- Google Drive returns an HTML download page, not the file
- Dropbox returns an HTML preview page
- OneDrive / SharePoint returns HTML
- iCloud returns HTML
Test your URL in an incognito browser window. If you see a webpage instead of the raw image or video, the URL will not work.
Media URLs must be publicly accessible, return actual media bytes with the correct Content-Type, and use HTTPS.
Destination Parameters
Every X destination accepts the following parameters.
Prop
Type
All other fields (text, mediaKind, mediaUrls) are shared across platforms and set at the top level of the request.
Thread
Top-level text with normal mediaKind and mediaUrls rules is always the root post. threadItems adds 1 to 25 replies under that root in order.
| Property | Type | Description |
|---|---|---|
threadItems | array | Replies under the root post in order (1 to 25) |
threadItems[].content | string | Tweet text content |
threadItems[].mediaItems | array | Media attachments for this reply |
When threadItems is present the root needs top-level text or mediaUrls or the request fails with 400. The response post echoes the root only and never echoes threadItems.
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "Here is everything you need to know about our API...",
"mediaUrls": [
"https://cdn.onepostly.com/media/cover.jpg"
],
"mediaKind": "image",
"destinations": [
{
"accountId": "YOUR_X_ACCOUNT_ID",
"geoRestriction": [
"US",
"ES"
]
}
],
"threadItems": [
{
"content": "1/ First, authentication is simple..."
},
{
"content": "2/ Next, create your first post...",
"mediaItems": [
"https://cdn.onepostly.com/media/step2.jpg"
]
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Here is everything you need to know about our API...",
"mediaUrls": [
"https://cdn.onepostly.com/media/cover.jpg"
],
"mediaKind": "image",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "YOUR_X_ACCOUNT_ID",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}- Each item's
contentmay be empty only when that item hasmediaItems(media-only tweet) - Up to 4 images per reply and a single video per reply with no mixing
- Each segment must stay within the account's limit (280 weighted units, or 25,000 with X Premium) and bills separately
geoRestrictionmust be uppercase ISO 3166-1 alpha-2 codes with a max of 25. It is applied to every media-bearing post via media metadata and text-only posts ignore it. A rejected restriction fails the publish withGEO_RESTRICTION_FAILEDbefore any post is created- All destinations in the request must be X, Threads, or Bluesky when
threadItemsis present
Quote
On Create
Pass quoteTweetId on the destination:
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.createPost({
createPostBody: {
"text": "My take",
"mediaUrls": [],
"mediaKind": "text",
"destinations": [
{
"accountId": "YOUR_X_ACCOUNT_ID",
"quoteTweetId": "2077337724349026673"
}
]
},
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "My take",
"mediaUrls": [],
"mediaKind": "text",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "YOUR_X_ACCOUNT_ID",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}From A Published Post
Quoting an already published post creates a new Onepostly post that shows up in your posts list and can be deleted like any other post. Pass post as described in Analytics.
import { Configuration, EngagementApi } from "@onepostly/sdk";
const engagementApi = new EngagementApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await engagementApi.quote({
quoteBody: {
"post": "1699f415-7fb6-43f4-9d2a-c447491f32a8",
"text": "Ship day"
},
});
console.log(response);{
"sourcePostId": "1699f415-7fb6-43f4-9d2a-c447491f32a8",
"sourceDestinationId": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Ship day",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg"
],
"mediaKind": "image",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}Schedule
Add scheduledFor (local wall time) and timezone to the same call. The request shape is otherwise identical to an immediate post.
timezonedefaults toUTC. Use an IANA name likeEurope/Istanbul- Do not append
Zor an offset toscheduledFor. It is local wall time - Scheduled posts return
201 Createdand publish automatically at the scheduled moment - Max 1 year ahead
- Cancel with
posts.cancelPost({ id })/posts.cancel_post(id=…), orDELETE /v1/posts/:id, any time before then
Remote Delete
Delete a published destination on X:
import { Configuration, PostsApi } from "@onepostly/sdk";
const postsApi = new PostsApi(
new Configuration({ apiKey: process.env.ONEPOSTLY_API_KEY })
);
const response = await postsApi.deletePostDestination({
destinationId: "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
});
console.log(response);{
"post": {
"id": "b3e1a7c2-8f4d-4e6a-9c1b-2d3e4f5a6b7c",
"text": "Ship day",
"mediaUrls": [
"https://cdn.onepostly.com/media/product.jpg"
],
"mediaKind": "image",
"status": "queued",
"scheduledFor": null,
"timezone": null,
"destinations": [
{
"id": "d4e5f6a7-8b9c-4d0e-8f1a-2b3c4d5e6f7a",
"accountId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5",
"platform": "x",
"status": "draft",
"externalPostId": "17895695668004550",
"externalUrl": "https://x.com/you/status/17895695668004550",
"errorCode": null,
"errorMessage": null,
"publishedAt": "2026-08-25T10:00:00.000Z",
"metrics": null
}
],
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}
}- Threads are deleted reply-first, then the root tweet
- Already-deleted tweets count as success
- If the root tweet cannot be deleted, the whole delete fails
- A root that deletes while some replies fail surfaces as
THREAD_DELETE_PARTIAL - Each deleted tweet bills a delete charge
Publish Errors
| Code | Cause | Fix |
|---|---|---|
TEXT_REQUIRED | Empty text on a text post | Add message text |
TEXT_TOO_LONG | Over the account's limit (280 weighted units, or 25,000 with X Premium) | Trim the text |
UNSUPPORTED_MEDIA_KIND | mediaKind: "stories" on an X destination | Use text, image, multi-image, or video |
INVALID_MEDIA | More than 4 images, or a video mixed with other media | Match the counts in content types. For threadItems, the message names the offending item |
GEO_RESTRICTION_FAILED | X rejected the geoRestriction media metadata | Check the country codes are supported by X; retry without geoRestriction to isolate |
MEDIA_UNFETCHABLE | X could not download the URL | Use a public HTTPS URL |
MEDIA_UPLOAD_FAILED | X rejected the upload | Retry. Check the file plays or opens |
TOKEN_INVALID | Connection token expired or revoked | Reconnect the account |
PERMISSION_DENIED | Connection lacks tweet.write | Reconnect the account |
X_OUT_OF_CREDITS | X developer account has no credits | Contact support |
INSUFFICIENT_WALLET | Wallet balance ran out | Top up in Settings → Billing |
DUPLICATE_CONTENT | X rejected the post as duplicate content | Change the text, even slightly, before posting again |
ACCOUNT_RESTRICTED | X suspended or temporarily locked the account | Resolve it in the X app before posting again |
RATE_LIMITED | X throttled the request, or the daily posting limit is spent | Wait out the window, then post again |
PLATFORM_UNAVAILABLE | X reported a transient server-side failure | Try again later |
MEDIA_PROCESSING_TIMEOUT | X did not finish processing the media in time | Try a smaller file, or post again |
X rejects duplicate posts. Modify the text, even slightly, before retrying.