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
Omittingplatforms 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.
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.
// 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
platformsfield goes to every connected platform. - • Off: a post with no
platformsfield goes to X only. - • Either way: a post that does pass
platformsis 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.
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 omitsplatformswould 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 indefault_targetsbut not indeliverable_targetswill be attempted and will fail until the user reconnects it, whichneeds_reconnectflags per network. - •
connectedandusable: not the same thing, and conflating them is how a client tells a user something false.connectedmeans an account row exists.usablemeans we believe we can publish to it right now. An account whose token has gone stale isconnected: trueandusable: false, so a UI that reads onlyconnectedwill show a green tick over a network that is about to fail. - •
limits: per network, includingmax_text_lengthandtext_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 sendplatforms 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.
{
"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. - •
uriandcid: Bluesky only. Theat://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 whenstatusis"failed". - •
skip_reason: present whenstatusis"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.
{
"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
Treatstatus 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.
// 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
- • REST API reference: every endpoint, including the full body and response for
POST /api/v1/postsandGET /api/v1/cross-posting. - • MCP setup: point an agent at the hosted server so it can cross-post too.
- • Cross-posting feature page: what each network gets, and the limits side by side.
- • How to cross-post to Bluesky: connecting the account and the two controls in the app.