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

mediaKindContentMedia countLimitsNotes
textText-only postNo media280 weighted units (25,000 with X Premium)mediaUrls must be empty
imageSingle imageExactly 1 URL5 MBJPEG, PNG, WebP
multi-imageImage set2 to 4 URLs5 MB eachImages only
videoSingle video or GIFExactly 1 URL512 MB video, 15 MB GIFMP4, MOV, WebM. A .gif URL uploads as a GIF
storiesNoNoNoNot 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

PropertyRequirement
Max images4 per post
FormatsJPEG, PNG, WebP, GIF
Max file size5 MB (images), 15 MB (GIFs)
Recommended1200 × 675 px (16:9)

Aspect ratios

TypeRatioDimensions
Landscape16:91200 × 675 px
Square1:11200 × 1200 px
Portrait4:51080 × 1350 px

GIFs

PropertyRequirement
Max per post1 (consumes all 4 image slots)
Max file size15 MB
Max dimensions1280 × 1080 px
BehaviorAuto-plays in the timeline

Videos

PropertyRequirement
Max videos1 per post
FormatsMP4, MOV, WebM
Max file size512 MB
Max duration140 seconds
Min duration0.5 seconds
Recommended1280 × 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.

PropertyTypeDescription
threadItemsarrayReplies under the root post in order (1 to 25)
threadItems[].contentstringTweet text content
threadItems[].mediaItemsarrayMedia 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 content may be empty only when that item has mediaItems (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
  • geoRestriction must 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 with GEO_RESTRICTION_FAILED before any post is created
  • All destinations in the request must be X, Threads, or Bluesky when threadItems is 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.

  • timezone defaults to UTC. Use an IANA name like Europe/Istanbul
  • Do not append Z or an offset to scheduledFor. It is local wall time
  • Scheduled posts return 201 Created and publish automatically at the scheduled moment
  • Max 1 year ahead
  • Cancel with posts.cancelPost({ id }) / posts.cancel_post(id=…), or DELETE /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

CodeCauseFix
TEXT_REQUIREDEmpty text on a text postAdd message text
TEXT_TOO_LONGOver the account's limit (280 weighted units, or 25,000 with X Premium)Trim the text
UNSUPPORTED_MEDIA_KINDmediaKind: "stories" on an X destinationUse text, image, multi-image, or video
INVALID_MEDIAMore than 4 images, or a video mixed with other mediaMatch the counts in content types. For threadItems, the message names the offending item
GEO_RESTRICTION_FAILEDX rejected the geoRestriction media metadataCheck the country codes are supported by X; retry without geoRestriction to isolate
MEDIA_UNFETCHABLEX could not download the URLUse a public HTTPS URL
MEDIA_UPLOAD_FAILEDX rejected the uploadRetry. Check the file plays or opens
TOKEN_INVALIDConnection token expired or revokedReconnect the account
PERMISSION_DENIEDConnection lacks tweet.writeReconnect the account
X_OUT_OF_CREDITSX developer account has no creditsContact support
INSUFFICIENT_WALLETWallet balance ran outTop up in Settings → Billing
DUPLICATE_CONTENTX rejected the post as duplicate contentChange the text, even slightly, before posting again
ACCOUNT_RESTRICTEDX suspended or temporarily locked the accountResolve it in the X app before posting again
RATE_LIMITEDX throttled the request, or the daily posting limit is spentWait out the window, then post again
PLATFORM_UNAVAILABLEX reported a transient server-side failureTry again later
MEDIA_PROCESSING_TIMEOUTX did not finish processing the media in timeTry a smaller file, or post again

X rejects duplicate posts. Modify the text, even slightly, before retrying.

See Also