API reference
Endpoints, fields and answers, generated from the same OpenAPI file the server uses.
Base URL https://postsail.app/api; every path starts with /v1. Send the API key as Authorization: Bearer …. Generated from the OpenAPI file (/api/v1/openapi.json), version 1.0.0-beta.1.
Fields for YouTube, TikTok, Instagram and Facebook are accepted by the API, but connecting those platforms is not open yet. Today you can connect X and Bluesky accounts.
Managing API keys, members, invitations and workspaces needs a dashboard sign-in, so those endpoints are not listed here; they are in the OpenAPI file.
- Posts: POST /v1/posts, GET /v1/posts, GET /v1/posts/{post_id}, POST /v1/posts/{post_id}/cancel, POST /v1/posts/{post_id}/targets/{account}/retry, POST /v1/uploads
- Accounts: GET /v1/accounts, GET /v1/accounts/{account}/creator_info
- Usage: GET /v1/usage
- Schemas: Post, PostList, Target, TargetError, DryPreview, MediaSummary, Account, AccountList, Usage, UploadStart, TiktokCreatorInfo, Error, ValidationIssue
Posts
POST /v1/posts
Create a post
Creates one post with one target per account and returns it right away (status: queued). Publishing happens in the background at publish_at (now if omitted); poll GET /v1/posts/{post_id} for each target's result.
- Retrying this request is safe with
Idempotency-Keyorexternal_id(see the top of this document). dry_run: trueruns every check and storesdry_previewper target; nothing is published.publish_before: the post is dropped (expired) instead of going out late.- Text, audio name and media are checked against platform rules and blocked words before the queue.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Any unique string for this request (1-255 visible ASCII characters). Kept 30 days. |
Request body
| Field | Type | Description |
|---|---|---|
external_id | string | Your own id for this post. The same external_id never creates a second post. |
media_url | string | HTTPS URL of the video. Must start with one of the workspace's allowed prefixes. |
publish_at | string | When to publish (ISO 8601). Omit for now; at most 30 days ahead. |
publish_before | string | Drop the post (status expired) instead of publishing it after this time. |
dry_run | boolean | Run every check and store what would be sent, without calling any platform. |
media_upload_id | string | Id from POST /v1/uploads, after the file was uploaded (instead of media_url). |
media_sha256 | string | SHA-256 of the approved video: if the fetched video differs, no target is published. |
targets required | array of object | One entry per account; each account at most once. |
targets[].account required | string | Account slug from GET /v1/accounts. |
targets[].text | string | Caption or post text for this account. Required when the post has no media_url. On X, links are removed from this text (put them in first_comment). |
targets[].draft | boolean | Facebook only: create the reel as a draft that only page admins see. |
targets[].audio_name | string | Instagram Reels only: name of the reel's original audio. |
targets[].ai_generated | boolean | AI disclosure: YouTube containsSyntheticMedia, Instagram and Facebook is_ai_generated. |
targets[].first_comment | string | Comment written after publishing (YouTube, Instagram, Facebook, X). On X it is a reply to the post and may contain links; links in the main X text are removed. |
targets[].title | string | Video title: required on YouTube, optional on Facebook. |
targets[].cover_ms | integer | Instagram Reels only: cover frame, ms from the start of the video. |
targets[].visibility | string: public, unlisted, private | YouTube only: who can see the video (public, unlisted or private). Default public. |
targets[].made_for_kids | boolean | YouTube only: the video is made for kids (YouTube's "Made for Kids"). Default false. |
targets[].privacy_level | string: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY | TikTok only: who can view the video. Required for a direct post; pick one of the account's privacy_level_options (GET /v1/accounts/{account}/creator_info). There is no default. |
targets[].allow_comment | boolean | TikTok only: allow comments. Default false. |
targets[].allow_duet | boolean | TikTok only: allow Duets. Default false. |
targets[].allow_stitch | boolean | TikTok only: allow Stitches. Default false. |
targets[].brand_organic | boolean | TikTok only: the video promotes your own brand, product or service (labeled "Promotional content"). |
targets[].brand_content | boolean | TikTok only: paid partnership promoting another brand (labeled "Paid partnership"). Cannot be private. |
Responses
200The same request was sent before: the existing post is returned and nothing new is created. Returns Post.201Post created. Returns Post.400MalformedIdempotency-Key. Codes:invalid_idempotency_key. Returns Error.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.402Over the plan's monthly limit;detailshaslimit,allowed,used,requestedandresets_at. SeeGET /v1/usage. Codes:plan_limit_reached. Returns Error.403The API key may not post to these accounts;detailslists them. Codes:account_not_allowed. Returns Error.409The sameIdempotency-Keyorexternal_idwas used with a different body;details.post_idwhen visible. Codes:idempotency_key_reused,external_id_exists. Returns Error.422The body failed validation;detailslists each issue. Codes:validation_failed. Returns Error.429More than 30 new posts in this hour for this API key. Codes:rate_limited. Returns Error.
GET /v1/posts
List posts
Latest publish_at first. Filters combine. Follow next_cursor until it is null. A key limited to some accounts sees only posts whose every target it may use, so a page can be shorter than limit even when more pages follow.
With external_id this looks up one post instead (see the parameter).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string: queued, publishing, published, partial, failed, dry_done, canceled | Only posts with this status. |
account | query | string | Only posts with a target on this account. |
from | query | string | Only posts with publish_at at or after this time (ISO 8601). |
to | query | string | Only posts with publish_at before this time (ISO 8601). |
limit | query | integer | Page size, 1-100. |
cursor | query | string | next_cursor from the previous page. |
external_id | query | string | Look up the one post with this external_id instead of listing, e.g. after an unclear response to createPost. The response is then a single Post (or 404). Cannot be combined with the other parameters. |
Responses
200A page of posts; withexternal_id, the one matching post.400A query parameter is invalid;detailslists each issue. Codes:invalid_query,missing_external_id. Returns Error.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.403The API key may not use this account. Codes:account_not_allowed. Returns Error.404Withexternal_id: no post with this external_id for this app, or not visible to this key. Codes:not_found. Returns Error.
GET /v1/posts/{post_id}
Get a post and its targets
Parameters
| Name | In | Type | Description |
|---|---|---|---|
post_id required | path | string |
Responses
POST /v1/posts/{post_id}/cancel
Cancel a post
Stops every target that has not gone out yet: queued, needs_reauth and needs_approval become canceled. Posts never change; to fix one, cancel it and create a new post with a new external_id.
- A target being published right now cannot be stopped. If it fails temporarily, it is canceled before the next attempt.
- Published and failed targets stay as they are; failed targets of a canceled post can no longer be retried.
- Canceled targets do not count against the plan.
- Canceling again returns the same post.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
post_id required | path | string |
Responses
200Canceled; the post is returned. Returns Post.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.404Unknown post, or it targets an account this key may not use. Codes:not_found. Returns Error.409Nothing left to cancel: every target was published, failed or is being published;details.statusis the post's status. Codes:not_cancelable. Returns Error.
POST /v1/posts/{post_id}/targets/{account}/retry
Retry a failed target
Queues a failed or needs_reauth target again while publish_before has not passed. The attempt counter restarts; a half-finished upload continues where it stopped and is not posted twice. Not retryable: validation_failed, blocked_content, platform_not_supported, x_duplicate, media_required, stopped_by_operator, and posts whose video could not be fetched.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
post_id required | path | string | |
account required | path | string |
Responses
202Queued again; the post is returned. Returns Post.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.404Unknown post or target, or not visible to this key. Codes:not_found. Returns Error.409This target cannot be retried (detailshas its status and error code), or the post was canceled. Codes:not_retryable,post_canceled. Returns Error.
POST /v1/uploads
Upload a video file
Use this when your video is not at a public HTTPS URL. Three steps: 1. POST /v1/uploads with the file's type and size; you get upload_url and id. 2. Send the file to upload_url with the method and headers given, before expires_at. 3. Create the post with media_upload_id: id instead of media_url. The file is checked (length, size, format) like any other video when the post is prepared.
Request body
| Field | Type | Description |
|---|---|---|
content_type required | string: video/mp4, video/quicktime | The video's type: video/mp4 or video/quicktime. |
size_bytes required | integer | File size in bytes, at most 104857600. |
Responses
201Upload started. Returns UploadStart.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.422The type or size is not accepted. Codes:validation_failed. Returns Error.501Direct uploads are not available on this server. Codes:uploads_unavailable. Returns Error.
Accounts
GET /v1/accounts
List accounts this key can post to
Responses
200Accounts, sorted by slug. Returns AccountList.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.
GET /v1/accounts/{account}/creator_info
TikTok: the creator's posting options
TikTok is in review: connecting TikTok accounts is not open yet.
TikTok accounts only. Read this right before posting and show it to the user: the account's nickname and avatar, the privacy_level_options to choose from (no default), and whether comments, Duets or Stitches are off in the creator's TikTok settings. A video longer than max_video_post_duration_sec cannot be posted to this account.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
account required | path | string |
Responses
200The creator's posting options. Returns TiktokCreatorInfo.401Missing, unknown or revoked API key. Codes:unauthorized. Returns Error.404Unknown account, or this key may not use it. Codes:account_not_found. Returns Error.409Not a TikTok account, not connected, needs reconnecting, or TikTok is not available yet. Codes:not_tiktok,account_not_connected,account_needs_reauth,platform_not_available. Returns Error.502TikTok returned an error. Codes:tiktok_error. Returns Error.503TikTok did not answer; retry after a minute. Codes:tiktok_unavailable. Returns Error.
Usage
GET /v1/usage
Plan limits and this month's count
Posts count per target: a post to three accounts counts three. Dry runs and the sandbox account do not count; canceled targets are given back. The count belongs to the workspace, so every key sees the same numbers. A post over the limit gets 402 plan_limit_reached.
Responses
Schemas
Post
A post and the state of each target.
| Field | Type | Description |
|---|---|---|
id | string | pst_… |
external_id | string or null | |
status | string: queued, publishing, published, partial, failed, dry_done, canceled | Summary of the targets: partial when some targets were published and some failed. Canceled targets are left out of the summary; canceled when every target was canceled. |
dry_run | boolean | |
media_url | string or null | |
publish_at | string | |
publish_before | string or null | |
created_at | string | |
canceled_at | string or null | When the post was canceled (POST /v1/posts/{post_id}/cancel); null if it was not. |
targets | array of Target |
PostList
One page of posts.
| Field | Type | Description |
|---|---|---|
posts | array of Post | Latest publish_at first. |
next_cursor | string or null | Pass as cursor to get the next page; null on the last page. |
Target
One account of a post.
| Field | Type | Description |
|---|---|---|
account | string | Account slug in this workspace. |
platform | string: facebook, instagram, x, telegram, youtube, tiktok, linkedin, bluesky, sandbox | |
text | string or null | |
draft | boolean | |
audio_name | string or null | |
dry_run | boolean | true when this target never calls the platform: the post is a dry run or the account is not live. |
status | string: queued, publishing, published, failed, expired, needs_reauth, blocked_content, needs_approval, dry_done, canceled | queued → publishing → published. Dry run ends in dry_done. Failures: failed, expired (publish_before passed), blocked_content, needs_reauth (reconnect the account). canceled: the post was canceled before this target went out. |
platform_post_id | string or null | |
permalink | string or null | |
error | TargetError or null | |
dry_preview | DryPreview or null | Dry run only: what would have been sent. |
TargetError
Why a target failed or is being retried.
| Field | Type | Description |
|---|---|---|
code | string | Machine-readable reason, e.g. sandbox_rejected, blocked_content, attempts_exhausted:platform_unavailable. |
message | string | Human-readable detail. Never contains tokens. |
retryable | boolean | true while the error is temporary and the target is being retried automatically. |
DryPreview
What a dry-run target would have sent.
| Field | Type | Description |
|---|---|---|
text | string or null | |
audio_name | string or null | |
media | MediaSummary or null | |
at | string |
MediaSummary
| Field | Type | Description |
|---|---|---|
sha256 | string | |
size | integer | Bytes. |
durationSec | number or null | |
width | integer or null | |
height | integer or null | |
videoCodec | string or null | |
audioCodec | string or null |
Account
| Field | Type | Description |
|---|---|---|
account | string | Slug used in targets[].account. |
platform | string: facebook, instagram, x, telegram, youtube, tiktok, linkedin, bluesky, sandbox | |
handle | string | |
status | string: connected, needs_reauth, disabled, disconnected | |
live | boolean | false: every post to this account is a dry run. |
purge_at | string or null | disconnected accounts only: when the account's remaining data is deleted. |
AccountList
| Field | Type | Description |
|---|---|---|
accounts | array of Account |
Usage
The workspace's plan, its limits and this month's count.
| Field | Type | Description |
|---|---|---|
plan | string | Plan id, e.g. beta. |
period | object | The counting period: the current calendar month in UTC. |
period.start | string | |
period.end | string | |
limits | object | |
limits.accounts | integer or null | Connected accounts; the sandbox account and disabled accounts do not count. null: no limit. |
limits.posts_per_month | integer or null | Targets per period: a post to three accounts counts three. Dry runs and the sandbox account do not count; a canceled target is given back. null: no limit. |
limits.x_posts_per_month | integer or null | Of those, targets on X; a first comment on X (a reply) counts one more. null: no limit. |
limits.x_link_comments_per_month | integer or null | First comments with links on X (X bills a post with a link 13 times more). null: no limit. |
used | object | |
used.accounts | integer | |
used.posts | integer | |
used.x_posts | integer | |
used.x_link_comments | integer |
UploadStart
Where to upload the video file, and the id to use as media_upload_id.
| Field | Type | Description |
|---|---|---|
id | string | Pass this as media_upload_id when you create the post. |
upload_url | string | Upload the file here with the given method and headers, before expires_at. |
method | "PUT" | |
headers | object | Send exactly these headers with the upload. |
expires_at | string | ISO 8601 time when upload_url stops working. |
TiktokCreatorInfo
| Field | Type | Description |
|---|---|---|
account | string | |
platform | "tiktok" | |
username | string or null | The creator's TikTok username (without @). |
nickname | string or null | The creator's display name. Show it next to the post so the user knows which account posts. |
avatar_url | string or null | |
privacy_level_options | array of string: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY | What this account can use as privacy_level. There is no default: the user picks one. |
comment_disabled | boolean | Comments are off in the creator's TikTok settings: allow_comment cannot turn them on. |
duet_disabled | boolean | |
stitch_disabled | boolean | |
max_video_post_duration_sec | number or null | Longest video this account can post. |
Error
Every error response has this shape.
| Field | Type | Description |
|---|---|---|
error | object | |
error.code | string | |
error.message | string | |
error.details | array of ValidationIssue or array of string or object | validation_failed, invalid_query: list of issues. account_not_allowed: account slugs. Conflicts: {post_id}. plan_limit_reached: {limit, allowed, used, requested, resets_at}. |
ValidationIssue
| Field | Type | Description |
|---|---|---|
path | string | Field path, e.g. targets.0.text. |
message | string |