Preflight

    Reference

    Publish from your own site.

    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

    Authentication

    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.

    Call it from a server. Browsers on other sites are refused by CORS, and a key in front-end code is a key anyone can read.

    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.

    Files

    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.

    Hand us a URL

    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.

    Upload it yourself

    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…\"" }, … ] }
    Send the duration and the size. The server does not measure a video's duration, dimensions or frame rate, so without 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.

    Limits and how long files are kept

    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.

    A file from Google Drive

    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.

    The media object

    { "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.

    Creating a post

    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": [ … ] }

    When it goes out

    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.

    The rest of the body

    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.

    Send an Idempotency-Key header. A retried request carrying the same key returns the original post (200) instead of creating a second one — whatever its body. Without it, a network timeout on your side can mean the same video going out twice.

    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.

    Several accounts on one network

    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).

    Different text for one network

    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." } }
    ]

    Carousels and alt text

    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" }] }

    A thread

    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."] } }

    Writing for each network

    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 }

    Check before you commit

    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.

    Per-network options

    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).

    YouTube

    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.

    TikTok

    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 } }
    A specific song cannot be chosen, and video cannot carry one at all. TikTok's sound library has no API behind it. A video posted through this API — or any other scheduling tool — appears under the creator's original sound.

    Instagram and Facebook

    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." } }

    Pinterest

    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, Mastodon, Telegram and Discord

    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" } }
    ]

    X, LinkedIn and Bluesky

    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.

    Reading posts

    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.

    A created post is not a published post. 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.

    Changing a post

    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.

    Endpoints

    Posts

    GET/v1/postsPosts with their per-network state
    POST/v1/postsCreate, schedule, queue or publish
    POST/v1/platforms/validateDry run against each network
    GET/v1/posts/{id}One post
    PATCH/v1/posts/{id}Change content, files, time or networks
    DELETE/v1/posts/{id}Remove it here
    POST/v1/posts/{id}/retryRetry the networks that failed
    POST/v1/posts/metrics/refreshRefresh the last fortnight's figures

    One network's copy of a post

    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.

    POST/v1/posts/{id}/targets/{targetId}/metricsFetch its figures now
    GET/v1/posts/{id}/targets/{targetId}/metrics/historyEvery reading, oldest first; ?since=
    GET/v1/posts/{id}/targets/{targetId}/insightsYouTube retention and traffic sources
    GET/v1/posts/{id}/targets/{targetId}/commentsComments, read live
    POST/v1/posts/{id}/targets/{targetId}/comments/{commentId}Act on one comment
    PATCH/v1/posts/{id}/targets/{targetId}/remoteEdit the live post
    DELETE/v1/posts/{id}/targets/{targetId}/remoteDelete it from the network
    PATCH/v1/posts/{id}/targets/{targetId}/accountPoint it at another account

    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.

    Files

    POST/v1/mediaDeclare a file, get upload instructions
    POST/v1/media/{id}/completeFinish a multipart upload
    POST/v1/media/{id}/uploadedFinish a single-part upload
    GET/v1/mediaNewest first; ?limit= up to 200
    GET/v1/media/{id}Status, metadata and a preview link
    GET/v1/media/{id}/thumbA small WebP preview
    GET/v1/media/{id}/postsThe posts that use it
    DELETE/v1/media/{id}Delete the file; the record stays as deleted

    A file used by a post that has not finished going out cannot be deleted (409 in_use).

    Accounts and capabilities

    GET/v1/platformsLimits and abilities per network; no key
    GET/v1/oauth/accountsConnected accounts, with their zone
    GET/v1/oauth/accounts/{id}/boardsPinterest boards
    GET/v1/oauth/accounts/{id}/youtube/playlistsYouTube playlists
    GET/v1/oauth/accounts/{id}/tiktok/creatorWhat this TikTok creator may use
    GET/v1/oauth/accounts/{id}/mentions?q=Handles to mention

    Accounts are connected and disconnected in the app, not over the API.

    AI

    POST/v1/ai/captionsA caption per network
    POST/v1/ai/reviewWhat its figures say to change
    GET/v1/ai/review/{targetId}The last review, free to read

    A review takes { "targetId": "…" } and is included 500 times a month on Pro and 2,500 on Studio.

    Webhooks

    GET/v1/workspace/webhooksSubscriptions
    POST/v1/workspace/webhooksSubscribe
    DELETE/v1/workspace/webhooks/{id}Unsubscribe
    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.

    MCP server

    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.

    Errors and limits

    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" } }
    400invalid_requestThe body did not validate; field says which
    400no_slotsA queued post for a channel without a schedule
    401unauthorizedMissing, expired or unrecognised key
    402post_limit, upload_limit, file_too_large, ai_limit, ai_not_in_planThe plan's allowance
    403forbiddenThe key lacks the scope for this call
    404not_foundNo such post, file or account here
    409account_not_connected, media_unusableA named account or file cannot be used
    409already_started, targets_locked, in_flight, nothing_to_retryNot in the post's current state
    409unsupported, not_published, account_disconnectedThe network or target does not allow this
    413payload_too_largeA request body over 1 MB
    422validation_failedA network would refuse it; see issues
    429rate_limitedToo many requests; wait for Retry-After
    502metrics_failed, comments_failed, update_failed, delete_failed, youtube_refusedThe network refused; its message is passed on

    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).