Reference
Upload a video, write a post, schedule it for every network at once, read its figures, take it down again — over HTTP, from your own server.
https://preflight.social/v1
Every request carries an API key as a bearer token. Create one under Settings → API keys; the Pro and Studio plans include the API, and a key issued on them keeps working if the workspace later moves down a plan. The key is shown once and stored only as a hash, so a lost key is replaced rather than recovered. A key belongs to one workspace and lasts a year unless you choose another expiry.
curl https://preflight.social/v1/posts \ -H "Authorization: Bearer sk_live_…"
A key carries scopes: posts:read, posts:write, posts:publish (publishing through the MCP server; over this API posts:write is enough), media:read, media:write, webhooks:read, webhooks:write, or * for everything. Give a key the narrowest set that does its job.
GET /v1/platforms needs no key: it is the capability sheet — each network's character limit, maxMediaPerPost, what it requires and what it supports. Networks are named youtube, tiktok, instagram, facebook, threads, twitter (X), linkedin, pinterest, bluesky, mastodon, telegram and discord.
A file is uploaded first and named in a post by its id. A 600 MB video never passes through this API: either hand over a URL and the server fetches it, or upload straight to storage with signed links.
Best when your generator already produces a downloadable link. The fetch is queued immediately, because generated URLs tend to expire, and is retried three times. The address must be public (no private networks, no credentials in it; up to five redirects are followed).
POST /v1/media
{ "kind": "video",
"sourceUrl": "https://your-generator.example/render/abc.mp4",
"filename": "weekly-mix.mp4" }
→ 202 { "id": "9f3c…", "status": "ingesting", … }Poll GET /v1/media/{id} until status is ready, then create the post. A fetch that cannot be completed ends as failed with the reason in error.
Declare the file with its sizeBytes and mimeType (image/* or video/*, matching kind; SVG is not taken). Up to 16 MB you get one URL to PUT the whole file to, with the Content-Type header it names:
POST /v1/media
{ "kind": "image", "sizeBytes": 2400000, "mimeType": "image/jpeg", "filename": "cover.jpg" }
→ 200 { "id": "4a71…", "status": "awaiting_upload",
"upload": { "type": "single", "url": "https://…", "method": "PUT",
"headers": { "Content-Type": "image/jpeg" } } }PUT the bytes there, then call POST /v1/media/{id}/uploaded. Larger, and you get 16 MB parts:
POST /v1/media
{ "kind": "video", "sizeBytes": 629145600, "mimeType": "video/mp4",
"durationSeconds": 3600, "width": 1920, "height": 1080 }
→ 200 { "id": "9f3c…",
"status": "awaiting_upload",
"upload": { "type": "multipart",
"partSize": 16777216,
"parts": [ { "partNumber": 1, "url": "https://…" }, … ],
"completeUrl": "/v1/media/9f3c…/complete" } }PUT each part, keep the ETag from each response, then finish. Parts may go up in parallel. Upload links last an hour, and a file still not uploaded after a day is marked failed.
POST /v1/media/9f3c…/complete
{ "parts": [ { "partNumber": 1, "etag": "\"a1b2…\"" }, … ] }durationSeconds, width, height (and fps if you have it) it cannot warn you that a 40-minute video will be refused by TikTok before it tries.The largest file is 200 MB on Free, 1 GB on Starter and 2 GB on Pro and Studio; a larger one is refused with 402 file_too_large. Once a post has gone out, its files are removed after 7, 14, 30 or 90 days by plan, and the media then reads as deleted. Creating media is limited to 60 calls a minute.
Create the media with sourceUrl set to exactly https://www.googleapis.com/drive/v3/files/FILE_ID?alt=media and the user's Google access token as sourceAccessToken (the drive.file scope is enough for a file they picked). The token is accepted only for a Drive file address, is kept encrypted on the fetch job rather than on the media, and is not forwarded if Google redirects the download to another host.
{ "id": "9f3c…", "kind": "video", "status": "ready",
"url": "https://…", // signed preview, 1 hour; null until ready
"filename": "weekly-mix.mp4", "mimeType": "video/mp4", "sizeBytes": 629145600,
"durationSeconds": 3600, "fps": 30, "width": 1920, "height": 1080,
"error": null, "createdAt": "2026-09-21T10:00:00Z" }status is awaiting_upload, ingesting, ready, failed or deleted.
One call, as many networks as you name. Each is delivered independently: one failing does not hold up the rest.
POST /v1/posts
Idempotency-Key: 7d0c2c9e-…
{ "content": "This week's mix is up.",
"mediaIds": ["9f3c…"],
"scheduledAt": "2026-10-05T09:00:00Z",
"targets": [
{ "platform": "youtube",
"options": { "title": "Weekly Mix #14", "privacy": "public" } },
{ "platform": "instagram" },
{ "platform": "bluesky" }
] }
→ 201 { "id": "…", "status": "scheduled", "targets": [ … ], "issues": [ … ] }Exactly one of these:
scheduledAt — an ISO instant, the same moment on every network. A time already past publishes at once.scheduledLocal — "2026-10-05T14:00", a wall-clock time read in each channel's own zone (the timezone of each account), so two in the afternoon in New York and in Prague."queue": true — each channel takes its own next free slot from its posting schedule; a channel without one is refused with 400 no_slots."publishNow": true — straight out. It wins over a time sent with it."saveAsDraft": true — parked without a time. A draft may be incomplete: the networks' rules and the plan's allowance are checked when it is scheduled.content (up to 64,000 characters; each network's own limit is checked), mediaIds (1–35 file ids, in order; mediaId still works for one), altTexts and linkUrl — an http(s) link used by Facebook text posts, LinkedIn's article card and as a Pinterest pin's destination. Each target names a platform, an accountId and its options.
The answer is 201 with the post, its targets and issues — warnings that did not stop it, such as files a network will not take. Anything that would stop a network is a 422 validation_failed with the same issues, and nothing is created.
Name the network once per account. Each target is delivered on its own and carries its own status, link and figures, so one Page failing does not affect the other four. Without accountId the first connected account of that network is used.
"targets": [
{ "platform": "facebook", "accountId": "b4b5…" },
{ "platform": "facebook", "accountId": "7c19…" }
]Account ids, and each account's timezone, come from GET /v1/oauth/accounts (posts:read).
caption in a target's options replaces content for that network only: a shorter version for Bluesky, one without hashtags for LinkedIn. It is checked against that network's own limit. An empty caption falls back to content.
"targets": [
{ "platform": "facebook" },
{ "platform": "bluesky", "options": { "caption": "Weekly Mix #14 is up — deep house, live from Brno." } }
]Several mediaIds make a carousel or an album, up to each network's maxMediaPerPost: Instagram, Facebook, Telegram and Discord 10, Threads 20, TikTok 35 pictures, Bluesky and Mastodon 4, every other network one. Files past the limit are left off with a warning. TikTok takes pictures or one video, never both; Facebook builds an album from pictures only.
altTexts describes each picture for people using screen readers, by position in mediaIds, null for none, up to 1,000 characters. It is kept on this post's use of the file, so the same picture can say different things in different posts. Instagram, Facebook, Threads, Bluesky, Mastodon, Pinterest (500 characters) and Discord receive it — the capability sheet marks them with supportsAltText.
{ "content": "Opening night.",
"mediaIds": ["9f3c…", "2b7a…"],
"altTexts": ["The band on a small stage under red lights", null],
"targets": [{ "platform": "instagram" }, { "platform": "bluesky" }] }thread in a target's options is the rest of a thread: each entry is posted as a reply under the one before, after content goes out first. Threads, X, Mastodon and Bluesky post it; each part is checked against that network's own limit. A network without threads gets only the first post, and the dry run says so.
{ "platform": "threads", "options": { "thread": ["Part two.", "Part three."] } }POST /v1/ai/captions (posts:write) with content (up to 8,000 characters) and the platforms it is for returns a caption per network and a few hashtags, to use as each target's caption. It writes with Claude Sonnet 5, keeps the original language and every fact, name, number and link, and aims inside each network's limit — the post itself is still checked. Pro includes 300 rewrites a month and Studio 1,500; other plans get 402 ai_not_in_plan. 20 calls a minute.
POST /v1/ai/captions
{ "content": "Weekly Mix #14 is up: two hours of deep house, recorded live in Brno.",
"platforms": ["linkedin", "bluesky"] }
→ { "captions": {
"linkedin": "Weekly Mix #14 is out — two hours of deep house, recorded live in Brno.",
"bluesky": "Weekly Mix #14 is up. Two hours of deep house, live from Brno." },
"hashtags": ["deephouse", "djset", "brno"],
"left": 297, "allowed": 300 }POST /v1/platforms/validate takes the same content, mediaIds, altTexts and targets and returns { ok, issues } — a caption too long for X, a vertical video TikTok requires, a file id that is not in this workspace, more files than a network takes — without creating anything. It checks more than creating does: YouTube subtitle files, tags and thumbnails, and hashtag counts. Each issue has a severity of error or warning; errors block publishing.
Everything below goes in a target's options. The ids a few of them need come from lookups on the account, all readable with posts:read: GET /v1/oauth/accounts/{id}/boards (Pinterest), /youtube/playlists, /tiktok/creator (the privacy levels this creator may use) and /mentions?q= (Bluesky and Mastodon handles).
title (up to 100; the caption's first line otherwise), description (replaces the caption), privacy (public, unlisted, private; public by default), tags (a list; 500 characters across all of them, counting commas and quotes — tags past that are left off), categoryId (YouTube's own id; 22, People & Blogs, by default and for an unknown one), madeForKids, containsSyntheticMedia (realistic altered or AI-made content), short (adds #Shorts; YouTube decides from the file whether it is a Short), defaultLanguage and defaultAudioLanguage (BCP-47), notifySubscribers: false, thumbnailMediaId (an uploaded JPEG or PNG up to 2 MB, on a verified channel), playlistId and captions.
{ "platform": "youtube",
"options": {
"title": "Weekly Mix #14", "privacy": "public",
"tags": ["house", "dj set"], "categoryId": "10", "madeForKids": false,
"thumbnailMediaId": "4a71…", "playlistId": "PLx0sYbCqOb8…",
"scheduleOnYouTube": true,
"captions": [ { "language": "cs", "name": "", "content": "1\n00:00:01,000 --> 00:00:04,000\nAhoj…" } ]
} }With scheduleOnYouTube on a public video scheduled more than 20 minutes ahead, the upload starts up to three hours before scheduledAt, private, and YouTube itself makes it public on the minute. Rescheduling or editing the caption afterwards is passed on to YouTube; the file cannot be swapped, and deleting the post deletes the waiting video. captions is a list of tracks with a language, an optional name and the file's text as content — SRT, WebVTT or SBV, up to 400 KB and five tracks. Anything YouTube would not do — a thumbnail on an unverified channel, a playlist that is gone, a track it refuses — does not fail the post and is reported in the target's warning.
privacy (PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY; SELF_ONLY by default, and a level the creator may not use falls back to it), disableComment, disableDuet and disableStitch (video), yourBrand and brandedContent (branded content cannot be private), aiGenerated, coverTimestampMs (a video's cover frame), asDraft (to the creator's TikTok inbox to finish in the app).
Pictures instead of a video make a photo post of up to 35, with photoCoverIndex for the cover. TikTok takes pictures only as JPEG or WebP up to 1080p, so each is converted and fitted on the way. Set autoAddMusic and TikTok adds a track of its own choosing — the post then shows that track rather than an original sound.
{ "platform": "tiktok",
"options": { "privacy": "PUBLIC_TO_EVERYONE", "autoAddMusic": true, "photoCoverIndex": 0 } }placement: feed (default) or story — a story needs a file and carries no caption. firstComment is posted under the post once it is up; if refused, the post stands and the target carries a warning. Instagram also takes collaborators (usernames), and on a single video trialReel (MANUAL or SS_PERFORMANCE, shown only to non-followers first; collaborators are dropped on a trial), shareToFeed: false to keep it off the grid and thumbnailMediaId for the cover. On Facebook, asReel posts a single video of 3 to 90 seconds as a reel.
{ "platform": "instagram",
"options": { "trialReel": "SS_PERFORMANCE", "shareToFeed": false,
"thumbnailMediaId": "4a71…", "firstComment": "Tracklist in bio." } }boardId is required. title (up to 100) defaults to the first line; the description is the rest, up to 500. The pin links to the post's linkUrl.
Threads: topicTag (up to 50), replyControl (everyone, followers_only, accounts_you_follow, mentioned_only, parent_post_author_only) and poll, 2 to 4 answers of up to 25 characters on a post without files. Mastodon: visibility (public, unlisted, private, direct), spoilerText for a content warning (it counts towards the limit), sensitive and language (two or three letters, such as cs); the thread's replies share the audience and warning. Telegram: silent, pin (the bot needs the right to pin), protectContent, spoiler and noLinkPreview; 1,024 characters with a file, 4,096 without. Discord: username (up to 80, without "discord" or "clyde"), avatarUrl (https), threadName (up to 100) for a forum channel and silent.
[
{ "platform": "mastodon", "options": { "visibility": "unlisted", "spoilerText": "Spoilers", "language": "cs" } },
{ "platform": "telegram", "options": { "silent": true, "pin": true } },
{ "platform": "discord", "options": { "username": "Weekly Mix", "threadName": "Mix #14" } }
]These take caption and thread where it applies, and nothing else. X posts text only. LinkedIn posts text with one picture, or with linkUrl as an article card; not video. Bluesky links URLs, mentions and hashtags from the text itself.
GET /v1/posts returns { data, nextBefore }, newest first by when each post went out (or will). Without parameters: every draft and scheduled post whatever its date, and what went out in the last 120 days. Parameters: limit (default 200, up to 500), days (the window, up to 730), from and to (an ISO window instead; both or neither), and before — pass the nextBefore of one page to get the next.
{ "id": "…", "status": "done", "content": "…", "linkUrl": null,
"scheduledAt": "…", "createdAt": "…",
"mediaIds": ["9f3c…", "2b7a…"], "altTexts": ["…", null],
"mediaItems": [ { "id": "9f3c…", "kind": "image", "status": "ready", "url": "https://…",
"altText": "…", "durationSeconds": null, "position": 0 }, … ],
"media": { … the first of them, for a thumbnail … },
"targets": [
{ "id": "…", "platform": "instagram", "accountId": "…",
"status": "published", "remoteId": "…", "remoteUrl": "https://instagram.com/p/…",
"publishedAt": "…", "error": null, "warning": null, "attempts": 1, "nextAttemptAt": null,
"canDeleteRemote": true, "canEditRemote": false,
"metrics": { "views": 1200, "likes": 80, "comments": 4, "shares": 2, "saves": 9,
"reach": 950, "impressions": 1300, "fetchedAt": "…" } } ] }A post is draft, scheduled, publishing or done (every network finished, failures included). Each target is pending, publishing, published, failed (with error; a retry that is coming shows in nextAttemptAt) or skipped (deleted from the network). While remotePending is true the network is still processing it and remoteUrl is not known yet.
POST /v1/posts returns as soon as the work is queued. Read targets[].status, or subscribe to a webhook, to find out what actually happened.PATCH /v1/posts/{id} takes content, linkUrl, mediaIds and altTexts (replacing the files; an empty list removes them), scheduledAt, publishNow and targets. A post already publishing or done is refused with 409 already_started; a scheduled post's networks can change until any of them has started (targets_locked). Giving a draft a time runs the same checks as creating a post.
DELETE /v1/posts/{id} removes the post here, and a video waiting on YouTube with it. What has already been published stays on the networks — delete that per target with DELETE …/targets/{targetId}/remote.
A post has one target per network account, each with its own id, remote id and figures. Not every network allows every action; each target says what it can with canDeleteRemote and canEditRemote.
A comment action is { "action": "reply", "text": "…" } or hide, unhide, like, unlike, delete; the comments listing says which the network allows in can. Editing the live post takes content (and title on YouTube) on Facebook, YouTube, Mastodon, Telegram and Discord. Deleting marks the target skipped and keeps the record; TikTok and an Instagram account connected directly rather than through a Facebook Page cannot be deleted from through any API. Moving a target takes { "accountId": "…" } of the same network.
A file used by a post that has not finished going out cannot be deleted (409 in_use).
Accounts are connected and disconnected in the app, not over the API.
A review takes { "targetId": "…" } and is included 500 times a month on Pro and 2,500 on Studio.
POST /v1/workspace/webhooks
{ "url": "https://example.com/hooks/preflight", "events": ["post.published", "post.failed"] }
→ 201 { "id": "…", "url": "…", "events": [ … ], "secret": "whsec_…" }The address must be https and public. The secret is shown once. Events are post.published — once per target, when it is live — and post.failed, sent only once retrying has stopped, so it does not report something that fixes itself.
POST https://example.com/hooks/preflight
x-schedulr-event: post.published
x-schedulr-signature: sha256=5f0c…
{ "event": "post.published", "createdAt": "…",
"data": { "postId": "…", "targetId": "…", "platform": "tiktok",
"remoteId": "…", "remoteUrl": "https://www.tiktok.com/@…/video/…" } }
{ "event": "post.failed", "createdAt": "…",
"data": { "postId": "…", "targetId": "…", "platform": "instagram",
"error": "…", "permanent": true, "attempts": 5 } }x-schedulr-signature is sha256= and the hex HMAC-SHA256 of the exact request body with your secret. Verify over the raw bytes — re-encoding the JSON first produces a signature that will never match. Answer with a 2xx within 10 seconds; anything else is retried five times with growing gaps. remoteUrl can be null when a network never reports its link.
The same workspace is available to AI assistants over the Model Context Protocol at https://preflight.social/mcp. Claude and other clients connect with the address alone and sign in through OAuth; anything else can send an API key as a bearer token. A key sees only the tools its scopes allow.
Tools: list_channels, list_posts, post_performance, analytics_summary, next_queue_times, get_post, validate_post (posts:read); draft_post (posts:write); create_media_upload, complete_media_upload (media:write), get_media (media:read); and create_post, which publishes for real and needs posts:write and posts:publish, an idempotencyKey, and confirmPublishNow to send something out at once.
Failures return a stable code and a message meant to be read by a person.
{ "error": { "code": "invalid_request", "message": "targets[] is required.", "field": "targets" } }A 402 carries limit: { used, allowed } and the plan that would allow it in upgradeTo. Requests are limited to 600 a minute per address, with tighter limits on creating files (60) and on AI (20).