Post to X and Bluesky with one API request
Send one request with two targets, one per account. Each gets text that fits its network, and each reports its own result.
One request, two targets
curl -X POST https://postsail.app/api/v1/posts \
-H "Authorization: Bearer $POSTSAIL_API_KEY" \
-H "Idempotency-Key: clip-launch-1" \
-H "Content-Type: application/json" \
-d '{
"external_id": "clip-launch-1",
"targets": [
{
"account": "brand-x",
"text": "Our new clip is out.",
"first_comment": "Watch it here: https://example.com/clip"
},
{
"account": "brand-bluesky",
"text": "Our new clip is out. Watch it here: https://example.com/clip"
}
]
}'const res = await fetch("https://postsail.app/api/v1/posts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POSTSAIL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "clip-launch-1",
},
body: JSON.stringify({
"external_id": "clip-launch-1",
"targets": [
{
"account": "brand-x",
"text": "Our new clip is out.",
"first_comment": "Watch it here: https://example.com/clip"
},
{
"account": "brand-bluesky",
"text": "Our new clip is out. Watch it here: https://example.com/clip"
}
]
}),
});
console.log(res.status, await res.json());import os
import requests
res = requests.post(
"https://postsail.app/api/v1/posts",
headers={
"Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}",
"Idempotency-Key": "clip-launch-1",
},
json={
"external_id": "clip-launch-1",
"targets": [
{
"account": "brand-x",
"text": "Our new clip is out.",
"first_comment": "Watch it here: https://example.com/clip",
},
{
"account": "brand-bluesky",
"text": "Our new clip is out. Watch it here: https://example.com/clip",
},
],
},
)
print(res.status_code, res.json())Each target has its own text, so each network gets a post that fits it. Each target also has its own result: a problem on one network does not block the other.
Text rules
| X | Bluesky | |
|---|---|---|
| Length | 280 characters after links are removed; in first_comment a link counts as 23 | 300 characters (graphemes) and 3,000 bytes |
| Links | Removed from the main post; put them in first_comment | Stay in the post and become clickable |
| Video | 0.5 to 140 seconds, H.264 with AAC, aspect ratio 1:3 to 3:1 | Up to 3 minutes and 100 MB |
Links on X
- PostSail removes links from the main X post. Put the link in
first_comment: it is posted as a reply right after the post. - A post that is only a link needs a video, or the link goes in
first_comment; otherwise nothing would be left to post. - Why: X's API pricing page charges $0.015 per Post: Create request and $0.200 when the post contains a URL (checked 11 November 2026, docs.x.com).
- Plan limits: X posts a month, Starter 100, Pro 500; a first comment counts as one more. First comments with links a month: Starter 10, Pro 40.
One video for both
Upload the file once and use the same media_upload_id for both targets. Each platform's rules are checked before anything goes out; a dry run shows the result without publishing.
# 1. Start the upload: you get id, upload_url, method, headers and expires_at
curl -X POST https://postsail.app/api/v1/uploads \
-H "Authorization: Bearer $POSTSAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content_type": "video/mp4", "size_bytes": 7340032}'
# 2. Send the file to upload_url (method PUT, the Content-Type from headers)
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @clip.mp4
# 3. Create the post; replace UPLOAD_ID with the id from step 1
curl -X POST https://postsail.app/api/v1/posts \
-H "Authorization: Bearer $POSTSAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: clip-2026-11-20" \
-d '{"external_id":"clip-2026-11-20","media_upload_id":"UPLOAD_ID","targets":[{"account":"brand-x","text":"New clip"},{"account":"brand-bluesky","text":"New clip"}]}'import {readFile, stat} from "node:fs/promises";
const auth = {Authorization: `Bearer ${process.env.POSTSAIL_API_KEY}`};
const file = "clip.mp4";
// 1. Start the upload
const start = await fetch("https://postsail.app/api/v1/uploads", {
method: "POST",
headers: {...auth, "Content-Type": "application/json"},
body: JSON.stringify({content_type: "video/mp4", size_bytes: (await stat(file)).size}),
}).then((r) => r.json());
// 2. Send the file with the given method and headers
const put = await fetch(start.upload_url, {method: start.method, headers: start.headers, body: await readFile(file)});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
// 3. Create the post with the upload id
const post = await fetch("https://postsail.app/api/v1/posts", {
method: "POST",
headers: {...auth, "Content-Type": "application/json", "Idempotency-Key": "clip-2026-11-20"},
body: JSON.stringify({external_id: "clip-2026-11-20", media_upload_id: start.id, targets: [{"account":"brand-x","text":"New clip"},{"account":"brand-bluesky","text":"New clip"}]}),
}).then((r) => r.json());
console.log(post.id, post.status);import os
import requests
API = "https://postsail.app/api"
auth = {"Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}"}
path = "clip.mp4"
# 1. Start the upload
start = requests.post(f"{API}/v1/uploads", headers=auth,
json={"content_type": "video/mp4", "size_bytes": os.path.getsize(path)}).json()
# 2. Send the file with the given method and headers
with open(path, "rb") as f:
requests.request(start["method"], start["upload_url"], headers=start["headers"], data=f).raise_for_status()
# 3. Create the post with the upload id
post = requests.post(f"{API}/v1/posts", headers={**auth, "Idempotency-Key": "clip-2026-11-20"}, json={
"external_id": "clip-2026-11-20",
"media_upload_id": start["id"],
"targets": [{"account":"brand-x","text":"New clip"},{"account":"brand-bluesky","text":"New clip"}],
}).json()
print(post["id"], post["status"])Results
curl https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j \
-H "Authorization: Bearer $POSTSAIL_API_KEY"const res = await fetch("https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j", {
headers: {
Authorization: `Bearer ${process.env.POSTSAIL_API_KEY}`,
},
});
console.log(res.status, await res.json());import os
import requests
res = requests.get(
"https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j",
headers={
"Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}",
},
)
print(res.status_code, res.json())Each target reports status and, once published, permalink. If one target fails and the other is published, the post is partial; retry the failed target with POST /v1/posts/{post_id}/targets/{account}/retry. Temporary errors are retried automatically first.