Cross-posting

One call can publish to X and to Bluesky. Each network gets a native post through its own API, and the response tells you what happened on each one separately. Three things to know: the platforms parameter, the results array, and the auto cross-post setting that decides where a post with no platforms field goes.

Read this before you ship an integration

Omitting platforms is not the same as pinning a post to X. A post that leaves the field out follows the account's auto cross-post setting, and when that setting is on the post goes to every connected platform. If your agent has been writing X-only posts by omitting the field, turning on auto cross-post starts sending those posts to Bluesky as well.An explicit platforms value is always honoured exactly and is never expanded. So anyone with existing automation who wants to stay X-only should pass "platforms": ["x"] explicitly rather than relying on leaving the field out.

The platforms parameter

platforms is an optional array of network ids on POST /api/v1/posts. The valid ids are "x" and "bluesky". Any non-empty subset works, and unknown ids are dropped rather than erroring. In a bulk create you can set it per post, or once at the top level to apply to every post in the batch, the same way x_account_id works.

bash
curl -X POST https://opentweet.io/api/v1/posts \
  -H "Authorization: Bearer ot_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Shipped cross-posting today.",
    "platforms": ["x", "bluesky"],
    "publish_now": true
  }'

The three cases side by side. The third one is the one that catches people out.

json
// Pinned to X. Auto cross-post is ignored for this post.
{
  "text": "Long-form thought that would not fit in 300 characters ...",
  "platforms": ["x"],
  "scheduled_date": "2026-10-01T10:00:00Z"
}

// Bluesky only.
{
  "text": "Hello from atproto.",
  "platforms": ["bluesky"],
  "publish_now": true
}

// No platforms field. This does NOT mean X.
// It follows the account's auto cross-post setting.
{
  "text": "Where does this go? Depends on the account.",
  "publish_now": true
}

Targeting a specific connected account works per network: x_account_id selects the X account and bluesky_account_id selects the Bluesky one. Leave either out and the primary account for that network is used.

The auto cross-post setting

Auto cross-post is an account-level setting, not a per-request field. It answers one question: where does a post that names no networks of its own go?

  • On: a post with no platforms field goes to every connected platform.
  • Off: a post with no platforms field goes to X only.
  • Either way: a post that does pass platforms is honoured exactly, so ["x"] posts to X only even while the setting is on.

Because the setting can change without your code changing, read it rather than assuming it. GET /api/v1/cross-posting reports the setting, which networks are connected, and each network's current limits.

bash
curl https://opentweet.io/api/v1/cross-posting \
  -H "Authorization: Bearer ot_your_key"

{
  "auto_cross_post": true,
  "default_targets": ["x", "bluesky"],
  "deliverable_targets": ["x", "bluesky"],
  "platforms": [
    {
      "platform": "x",
      "label": "X",
      "connected": true,
      "usable": true,
      "needs_reconnect": false,
      "accounts": [
        {
          "account_id": "65f1a2b3c4d5e6f7a8b9c0d1",
          "handle": "@you",
          "display_name": "You",
          "is_primary": true,
          "disconnected": false
        }
      ],
      "limits": {
        "max_text_length": 280,
        "text_unit": "weighted",
        "max_images": 4,
        "max_thread_parts": 25
      }
    },
    {
      "platform": "bluesky",
      "label": "Bluesky",
      "connected": true,
      "usable": true,
      "needs_reconnect": false,
      "accounts": [
        {
          "account_id": "66a2b3c4d5e6f7a8b9c0d1e2",
          "handle": "you.bsky.social",
          "display_name": "You",
          "is_primary": true,
          "disconnected": false
        }
      ],
      "limits": {
        "max_text_length": 300,
        "text_unit": "grapheme",
        "max_images": 4,
        "max_image_bytes": 2097152,
        "max_videos": 0
      }
    }
  ]
}

Three fields do most of the work:

  • default_targets: where a post that omits platforms would go right now. This is the resolver's live answer, so read it rather than recomputing it from the setting.
  • deliverable_targets: the subset of those with a working account. A network in default_targets but not in deliverable_targets will be attempted and will fail until the user reconnects it, which needs_reconnect flags per network.
  • connected and usable: not the same thing, and conflating them is how a client tells a user something false. connected means an account row exists. usable means we believe we can publish to it right now. An account whose token has gone stale is connected: true and usable: false, so a UI that reads only connected will show a green tick over a network that is about to fail.
  • limits: per network, including max_text_length and text_unit. X counts weighted characters, where a URL costs 23 regardless of length. Bluesky counts graphemes, so an emoji or an accented letter costs one. There is no shared counter, which is why each network reports its own.

The safe default for an integration

If your code cares where a post lands, always send platforms explicitly. Then the account setting cannot move your posts, and a user who switches auto cross-post on later does not change what your integration publishes.

The results array

Once a post has been published, it carries a results array with one entry per network it targeted. Delivery to each network is independent, so a partial outcome is normal and is not an error. Note that v1 responses are snake_case throughout, including inside results.

json
{
  "success": true,
  "count": 1,
  "posts": [
    {
      "id": "65f1a2b3c4d5e6f7a8b9c0d1",
      "text": "Shipped cross-posting today.",
      "platforms": ["x", "bluesky"],
      "posted": true,
      "posted_date": "2026-09-05T09:00:04Z",
      "results": [
        {
          "platform": "x",
          "status": "published",
          "post_id": "1834000000000000000",
          "url": "https://x.com/you/status/1834000000000000000",
          "attempted_at": "2026-09-05T09:00:03Z"
        },
        {
          "platform": "bluesky",
          "status": "published",
          "post_id": "3l5x7k2qz3c2t",
          "uri": "at://did:plc:abc123/app.bsky.feed.post/3l5x7k2qz3c2t",
          "cid": "bafyreib2rxk3rh6kzwq...",
          "url": "https://bsky.app/profile/you.bsky.social/post/3l5x7k2qz3c2t",
          "attempted_at": "2026-09-05T09:00:04Z"
        }
      ]
    }
  ]
}

Each entry contains:

  • platform: "x" or "bluesky".
  • status: "published", "failed", or "skipped".
  • post_id: the native id. The tweet id on X, the atproto record key on Bluesky.
  • url: a permalink to the published post.
  • uri and cid: Bluesky only. The at:// URI and content hash needed to reply to or delete the record.
  • part_ids: the id of every part of a thread, in order. A single post carries one entry.
  • account_id: which connected account it went out as.
  • error: present when status is "failed".
  • skip_reason: present when status is "skipped".
  • attempted_at: when delivery to that network was attempted.

Why a network is skipped

A post that exceeds a platform's limit is skipped on that platform with a stated reason, and still goes out everywhere it fits. Nothing is truncated, and one network refusing a post does not fail the others.

json
{
  "results": [
    {
      "platform": "x",
      "status": "published",
      "post_id": "1834000000000000001",
      "url": "https://x.com/you/status/1834000000000000001"
    },
    {
      "platform": "bluesky",
      "status": "skipped",
      "skip_reason": "Text is 412 characters. Bluesky allows 300."
    }
  ]
}

The limits that produce a skip:

  • Text length. Bluesky allows 300 characters. X allows 280, or 25,000 on Premium. So a 400-character post publishes on a Premium X account and is skipped on Bluesky.
  • Images. Bluesky takes up to 4 images per post, 2MB each, with alt text on each.
  • Video. Video cross-posting to Bluesky is not supported.

Handling a partial result in code

Treat status per entry rather than reading the top-level posted boolean, which is a roll-up: it is true when any target published. If your agent needs both networks to carry a post, check that every entry in results is "published", then rewrite and repost the skipped one shorter.

Threads

Threads cross-post. Pass is_thread and thread_tweets alongside platforms and each network gets a real reply chain, with per-part text and images. Every part is subject to its own network's limit, so a thread whose parts are each under 280 characters lands on both. The part_ids field in each result carries the ids in order.

Over MCP

The MCP tools take the same platforms parameter and follow the same rule, so everything above applies to an agent as well. opentweet_list_platforms reports which networks are connected, whether auto cross-post is on, and where a post that omits platforms would actually go. Publishing tools render the per-network outcome back to the agent, so a skip is visible without a follow-up read.

text
// The same rule applies over MCP.
opentweet_create_tweet({
  text: "Shipped cross-posting today.",
  platforms: ["x", "bluesky"],
  publish_now: true
})

// Check where an unspecified post would land before you send one.
opentweet_list_platforms({})

Next steps