PostSail API

PostSail sends one post to several social accounts with one request and tells you what happened on each one. This page takes you from an API key to a published post; the reference lists every endpoint.

Platform status

Today you can connect X and Bluesky accounts. Status updated 10 November 2026.

PlatformStatusNote
XAvailable
BlueskyAvailable
YouTube ShortsGoogle verification in progressOpens to everyone when Google approves our app. Until then, only our review workspace can connect YouTube.
TikTokIn TikTok reviewConnecting opens after TikTok approves our app. Until TikTok's content audit, posts are private (only you can see them).
LinkedInComing soon
Instagram ReelsLater
Facebook ReelsLater
ThreadsLater

Every workspace also has an account called sandbox. Posts to it go nowhere, but they run through the real queue, checks and retries, so you can test an integration before you connect anything.

Quickstart

  1. Create a workspace at postsail.app/app and start the free trial (7 days). A card is required; nothing is charged until the trial ends.
  2. Connect an X or Bluesky account under Accounts in the dashboard. Connecting needs a dashboard sign-in; an API key cannot connect accounts.
  3. Create an API key on the dashboard's API page. Keep it on your server, never in a browser or a mobile app. The examples read it from the POSTSAIL_API_KEY environment variable.

1. List the accounts the key can post to

curl
curl https://postsail.app/api/v1/accounts \
  -H "Authorization: Bearer $POSTSAIL_API_KEY"
Node.js
const res = await fetch("https://postsail.app/api/v1/accounts", {
  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/accounts",
    headers={
        "Authorization": f"Bearer {os.environ['POSTSAIL_API_KEY']}",
    },
)
print(res.status_code, res.json())

Each account has a slug (account), the platform, the handle and a status. Use the slug in targets. If live is false, every post to that account is a dry run.

2. Dry run: check everything, publish nothing

curl
curl -X POST https://postsail.app/api/v1/posts \
  -H "Authorization: Bearer $POSTSAIL_API_KEY" \
  -H "Idempotency-Key: hello-dry-1" \
  -H "Content-Type: application/json" \
  -d '{
    "dry_run": true,
    "external_id": "hello-dry-1",
    "targets": [
      {
        "account": "brand-x",
        "text": "Hello from the API"
      },
      {
        "account": "brand-bluesky",
        "text": "Hello from the API"
      }
    ]
  }'
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": "hello-dry-1",
  },
  body: JSON.stringify({
    "dry_run": true,
    "external_id": "hello-dry-1",
    "targets": [
      {
        "account": "brand-x",
        "text": "Hello from the API"
      },
      {
        "account": "brand-bluesky",
        "text": "Hello from the API"
      }
    ]
  }),
});
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": "hello-dry-1",
    },
    json={
        "dry_run": True,
        "external_id": "hello-dry-1",
        "targets": [
            {
                "account": "brand-x",
                "text": "Hello from the API",
            },
            {
                "account": "brand-bluesky",
                "text": "Hello from the API",
            },
        ],
    },
)
print(res.status_code, res.json())

The answer is the post (201). When the checks finish, each target is dry_done and dry_preview shows what would have been sent. Nothing is published and nothing counts toward plan limits.

3. Publish

curl
curl -X POST https://postsail.app/api/v1/posts \
  -H "Authorization: Bearer $POSTSAIL_API_KEY" \
  -H "Idempotency-Key: hello-1" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "hello-1",
    "targets": [
      {
        "account": "brand-x",
        "text": "Hello from the API"
      },
      {
        "account": "brand-bluesky",
        "text": "Hello from the API"
      }
    ]
  }'
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": "hello-1",
  },
  body: JSON.stringify({
    "external_id": "hello-1",
    "targets": [
      {
        "account": "brand-x",
        "text": "Hello from the API"
      },
      {
        "account": "brand-bluesky",
        "text": "Hello from the API"
      }
    ]
  }),
});
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": "hello-1",
    },
    json={
        "external_id": "hello-1",
        "targets": [
            {
                "account": "brand-x",
                "text": "Hello from the API",
            },
            {
                "account": "brand-bluesky",
                "text": "Hello from the API",
            },
        ],
    },
)
print(res.status_code, res.json())

Use a new external_id for the real post: an external_id is never reused, and a dry run already used hello-dry-1.

4. Follow each target

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 moves queued → publishing → published and then has a permalink. A failed target has status failed (or expired, blocked_content, needs_reauth) and an error with a code and a message. If some targets were published and some failed, the post is partial.

How posting works

  • Targets. One post goes to up to 10 accounts. Each target has its own text and options, and its own result.
  • Dry run. "dry_run": true runs every check (media download and probe, platform rules, blocked words) and stores what would be sent as dry_preview. No platform is called.
  • No duplicates. Send an Idempotency-Key header, an external_id, or both. The same request again returns the same post (200, header Idempotent-Replayed: true); the same key or id with a different body is rejected (409). Idempotency-Key records are kept 30 days, external_id records for good.
  • Retries that do not double-post. Temporary platform errors are retried automatically with backoff, up to 8 attempts. If a request reached the platform but the answer was lost, the next attempt checks the platform before posting again. Retry a failed target by hand with POST /v1/posts/{post_id}/targets/{account}/retry.
  • Scheduling. publish_at can be up to 30 days ahead. With publish_before, a target that cannot go out in time ends expired instead of publishing late.
  • Posts never change. To fix one, cancel it (POST /v1/posts/{post_id}/cancel) and create a new post with a new external_id. GET /v1/posts lists posts by status, account and time.

Video

One video file goes to every target. Upload it in three steps (MP4 or QuickTime, up to 100 MB):

  1. POST /v1/uploads with content_type and size_bytes. The answer has id, upload_url, method, headers and expires_at.
  2. Send the file to upload_url with that method and exactly those headers, before expires_at.
  3. Create the post with media_upload_id set to id.
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"])

media_url (a public HTTPS address instead of an upload) works only for addresses on your workspace's allow list. To add your media host, write to turgut.sonmez@t-apps.io.

Platform rules are checked before anything goes out. X: 0.5 to 140 seconds, H.264 video with AAC audio, aspect ratio between 1:3 and 3:1. Bluesky: up to 3 minutes and 100 MB.

Errors

Errors look like {"error": {"code": "…", "message": "…", "details": …}}. Branch on code; the message is for people.

CodeHTTPMeaning
invalid_idempotency_key400Malformed Idempotency-Key.
invalid_query400A query parameter is invalid; details lists each issue.
missing_external_id400A query parameter is invalid; details lists each issue.
unauthorized401Missing, unknown or revoked API key.
plan_limit_reached402Over the plan's monthly limit; details has limit, allowed, used, requested and resets_at. See GET /v1/usage.
subscription_required402The workspace has no active plan. Every endpoint answers this until a plan or trial is active.
account_not_allowed403The API key may not post to these accounts; details lists them.
account_not_found404Unknown account, or this key may not use it.
not_found404With external_id: no post with this external_id for this app, or not visible to this key.
account_needs_reauth409Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet.
account_not_connected409Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet.
external_id_exists409The same Idempotency-Key or external_id was used with a different body; details.post_id when visible.
idempotency_key_reused409The same Idempotency-Key or external_id was used with a different body; details.post_id when visible.
not_cancelable409Nothing left to cancel: every target was published, failed or is being published; details.status is the post's status.
not_retryable409This target cannot be retried (details has its status and error code), or the post was canceled.
not_tiktok409Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet.
platform_not_available409Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet.
post_canceled409This target cannot be retried (details has its status and error code), or the post was canceled.
validation_failed422The body failed validation; details lists each issue.
rate_limited429More than 30 new posts in this hour for this API key.
uploads_unavailable501Direct uploads are not available on this server.
tiktok_error502TikTok returned an error.
tiktok_unavailable503TikTok did not answer; retry after a minute.

Limits

  • 30 new posts per hour per API key (429 rate_limited with Retry-After).
  • 10 targets per post; publish_at at most 30 days ahead; video uploads up to 100 MB.
PlanPriceConnected accountsTeam membersX posts a monthX first comments with links a monthYouTube uploads a day (when YouTube opens)
Starter$19/month or $190/year101100105
Pro$49/month or $490/year50105004015

Plan limits count per target: a post to three accounts counts three. Dry runs and the sandbox account do not count. A first comment on X counts as one more X post. Over a limit the API answers 402 plan_limit_reached; GET /v1/usage shows every limit and this month's count.

Reference and OpenAPI

Every endpoint an API key can call, with its fields and answers: API reference. The same contract as a machine-readable OpenAPI 3.1 file: /api/v1/openapi.json.