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.
| Platform | Status | Note |
|---|---|---|
| X | Available | |
| Bluesky | Available | |
| YouTube Shorts | Google verification in progress | Opens to everyone when Google approves our app. Until then, only our review workspace can connect YouTube. |
| TikTok | In TikTok review | Connecting opens after TikTok approves our app. Until TikTok's content audit, posts are private (only you can see them). |
| Coming soon | ||
| Instagram Reels | Later | |
| Facebook Reels | Later | |
| Threads | Later |
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
- 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.
- Connect an X or Bluesky account under Accounts in the dashboard. Connecting needs a dashboard sign-in; an API key cannot connect accounts.
- 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_KEYenvironment variable.
1. List the accounts the key can post to
curl https://postsail.app/api/v1/accounts \
-H "Authorization: Bearer $POSTSAIL_API_KEY"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());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 -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"
}
]
}'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());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 -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"
}
]
}'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());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 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 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": trueruns every check (media download and probe, platform rules, blocked words) and stores what would be sent asdry_preview. No platform is called. - No duplicates. Send an
Idempotency-Keyheader, anexternal_id, or both. The same request again returns the same post (200, headerIdempotent-Replayed: true); the same key or id with a different body is rejected (409).Idempotency-Keyrecords are kept 30 days,external_idrecords 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_atcan be up to 30 days ahead. Withpublish_before, a target that cannot go out in time endsexpiredinstead of publishing late. - Posts never change. To fix one, cancel it (
POST /v1/posts/{post_id}/cancel) and create a new post with a newexternal_id.GET /v1/postslists 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):
POST /v1/uploadswithcontent_typeandsize_bytes. The answer hasid,upload_url,method,headersandexpires_at.- Send the file to
upload_urlwith that method and exactly those headers, beforeexpires_at. - Create the post with
media_upload_idset toid.
# 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"])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.
| Code | HTTP | Meaning |
|---|---|---|
invalid_idempotency_key | 400 | Malformed Idempotency-Key. |
invalid_query | 400 | A query parameter is invalid; details lists each issue. |
missing_external_id | 400 | A query parameter is invalid; details lists each issue. |
unauthorized | 401 | Missing, unknown or revoked API key. |
plan_limit_reached | 402 | Over the plan's monthly limit; details has limit, allowed, used, requested and resets_at. See GET /v1/usage. |
subscription_required | 402 | The workspace has no active plan. Every endpoint answers this until a plan or trial is active. |
account_not_allowed | 403 | The API key may not post to these accounts; details lists them. |
account_not_found | 404 | Unknown account, or this key may not use it. |
not_found | 404 | With external_id: no post with this external_id for this app, or not visible to this key. |
account_needs_reauth | 409 | Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet. |
account_not_connected | 409 | Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet. |
external_id_exists | 409 | The same Idempotency-Key or external_id was used with a different body; details.post_id when visible. |
idempotency_key_reused | 409 | The same Idempotency-Key or external_id was used with a different body; details.post_id when visible. |
not_cancelable | 409 | Nothing left to cancel: every target was published, failed or is being published; details.status is the post's status. |
not_retryable | 409 | This target cannot be retried (details has its status and error code), or the post was canceled. |
not_tiktok | 409 | Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet. |
platform_not_available | 409 | Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet. |
post_canceled | 409 | This target cannot be retried (details has its status and error code), or the post was canceled. |
validation_failed | 422 | The body failed validation; details lists each issue. |
rate_limited | 429 | More than 30 new posts in this hour for this API key. |
uploads_unavailable | 501 | Direct uploads are not available on this server. |
tiktok_error | 502 | TikTok returned an error. |
tiktok_unavailable | 503 | TikTok did not answer; retry after a minute. |
Limits
- 30 new posts per hour per API key (
429 rate_limitedwithRetry-After). - 10 targets per post;
publish_atat most 30 days ahead; video uploads up to 100 MB.
| Plan | Price | Connected accounts | Team members | X posts a month | X first comments with links a month | YouTube uploads a day (when YouTube opens) |
|---|---|---|---|---|---|---|
| Starter | $19/month or $190/year | 10 | 1 | 100 | 10 | 5 |
| Pro | $49/month or $490/year | 50 | 10 | 500 | 40 | 15 |
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.