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
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"
      }
    ]
  }'
Node.js
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());
Python
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

XBluesky
Length280 characters after links are removed; in first_comment a link counts as 23300 characters (graphemes) and 3,000 bytes
LinksRemoved from the main post; put them in first_commentStay in the post and become clickable
Video0.5 to 140 seconds, H.264 with AAC, aspect ratio 1:3 to 3:1Up to 3 minutes and 100 MB

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.

curl
# 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"}]}'
Node.js
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);
Python
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
curl https://postsail.app/api/v1/posts/pst_1a2b3c4d5e6f7g8h9i0j \
  -H "Authorization: Bearer $POSTSAIL_API_KEY"
Node.js
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());
Python
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.