Create a post

Create a post targeting one or more connected accounts. Guardrails run on ALL outbound text (caption, per-platform overrides, the X and Bluesky thread, the X and Instagram first comment) — a block returns 422 with the violations, never a silent drop. Creating a post sends it: scheduled is the ordinary answer and the post goes out at its time. In copilot mode it is held for human approval instead, and a pending_approval response is the system working, not an error. Either way the response carries a signed, no-login link to the post — preview_url, or approval_url on a hold — meant to be handed to the person. Omit scheduled_at to publish as soon as it's created (or approved). Instagram, TikTok, YouTube, and Pinterest require media; YouTube requires a video. caption may be omitted when no destination insists on one: a story (placement: "stories") prints no caption, so text sent with one is dropped, and X, Instagram and LinkedIn all publish media with no words. Every other platform still requires it, and one such destination in the send brings the requirement back for the whole post — the caption is shared.

post/posts

Request

  • Base URL: https://xpost.to/api/v1
  • URL: https://xpost.to/api/v1/posts
  • Auth: HTTP bearer

Request body

captionstring

Required for every destination that prints one — omit it only on a story-only post

social_accountsstring[] required

Where it posts: an account id, platform:username, or a bare username when it can only mean one account — the same references /posts/bulk takes. Prefer platform:username: an agent's post is shown to a person to approve before anything is created, and a uuid tells them nothing about which account it is. Every reference must resolve or the whole post is refused, never posted to the subset that matched.

media_idsstring[]

From POST /media; order = carousel order, first item leads

upload_ticketstring

A ticket from POST /media/ticket that files have been posted to: every file on it becomes the post's media, in order, after anything in media_ids, duplicates dropped. Must belong to this key's workspace; an empty ticket is refused.

media_pendingboolean

The picture is still to come: the person adds it in the approval card (POST /api/attach?post=<approval token>, which the card's summary hands it as attach_url and the 201 hands back as upload_url with upload_curl, for an environment that holds the file). Lets a post for a destination that requires media be created without any — HELD, and unapprovable until the media is on it (decidePost refuses with the same sentence the composer prints). Ignored, and the usual refusal returned, when nothing would hold the post. The 201 carries awaiting_media: true.

scheduled_atstring

ISO 8601 with Z or a UTC offset; omit to post now (on approval in copilot mode). Past times (beyond a 2-minute grace) are refused. Or the word next_slot: the project's posting schedule (Project → Queue) picks the earliest free slot — see queue.next_slots on GET /project. 409 no_queue_slots when the project has no schedule or is booked out four weeks ahead.

is_draftboolean
platform_configurationsobject

Per-platform options, keyed by platform name (x, instagram, facebook, threads, pinterest, tiktok, youtube, linkedin, bluesky). Common keys: caption (override the main caption for that platform), placement ("reels" | "stories" | "timeline" — instagram/facebook; threads supports reels only; reels need a video, stories need media and print no caption). Pinterest: title, board_ids, link. X: first_comment (auto-replied under the tweet), thread (up to 4 follow-up tweet texts chained under the main tweet), reply_settings (X polls are not supported — a poll is refused). Threads poll options are at most 25 characters each; a Threads topic tag at most 50, with no . or &. Threads: topic_tag (one word, no #), reply_control, poll_option_a…poll_option_d (at least two; a poll only rides a post with NO media, and this API is the only way to set one — the dashboard composer has no poll fields), gif_id (a giphy.com link, same media-free rule), crosspost_ig_story. Platform disclosure labels, set at publish and impossible to add afterwards: Instagram is_paid_partnership + branded_content_sponsors (max 2 usernames) + is_ai_generated; TikTok is_brand_content (a third party paid) / is_organic_brand_content (your own business) / is_ai_generated; YouTube has_paid_product_placement + contains_synthetic_media; Pinterest is_ai_generated. Instagram: first_comment too (max 2200 characters, not on stories) — but only for accounts connected through bundle.social, since Instagram refuses the comment on the other route; asking for one anywhere else is refused at create time, naming the account. Every text field here is guardrail-checked exactly like the caption — including keys not listed above, since anything that reaches a platform is checked. Machine values (placement, board_id, reply_settings, privacy_status and similar enums and ids) are exempt: they are not places a message can hide.

idempotency_keystring

Your own identifier for this post. A second create with the same key returns the post the first one made (200 with repeated: true) instead of writing another — a timed-out call is not evidence that nothing was written.

Example request

{
  "platform_configurations": {
    "instagram": {
      "placement": "stories"
    },
    "x": {
      "first_comment": "Link in this comment 👇"
    }
  }
}

Response

Created — check status; message says what happens next, in words meant to be passed on

idstring uuid
status'pending_approval' | 'scheduled' | 'processing' | 'draft'
scheduled_atstring date-time nullable

The slot the post actually got, or NULL when you named no time — see publishes. Deliberately not a stand-in timestamp: a post written on Monday and approved on Friday would report a slot four days past, and anything reading the field literally acts on it.

publishes'at_time' | 'on_approval' | 'now' nullable

What has to happen before this goes out. at_time: scheduled_at is a real slot somebody chose. on_approval: a person's yes, and scheduled_at is null — the post has no time, rather than a time nobody can see. now: nothing at all, it is already on its way. null: the post is PARKED (a draft, or a rejected one) and the question has no answer yet — read status. on_approval is never said where no approval is pending.

timezonestring

The project's IANA zone: the clock scheduled_at was read on. Report the time in this zone, not the caller's.

messagestring
approval_urlstring

Only on a copilot hold: a signed, no-login page showing this post with Approve and Reject buttons. Valid 48 hours. Show it to the human who asked for the post — it is the fastest route to a decision. A post nobody has to approve carries preview_url instead.

preview_urlstring

On a post nobody has to approve: the same signed, no-login page, showing the post as each network will draw it, the slot it goes out on, and the receipt per account once it has gone. Hand it to the person — it is how they see a post that was sent without them, in time to change or cancel it.

awaiting_mediaboolean

Created with media_pending and holding no picture yet. It cannot go out until one is on it.

replaced_draftstring uuid

This post FINISHED a draft in the project that held the same words for the same accounts: that draft is gone and this post stands in for it (its media carried over when this request brought none). Say there is one copy, not two.

needs_mediaboolean

Only on a draft: it has no picture or video, and a destination needs one — missing names them. It cannot be sent as it stands; attach media with PATCH /posts/{id}, or the person adds it in xpost.

missingstring

What the draft still lacks before it can go out.

releases_on_mediaboolean

This post waits for its PICTURE and nothing else — nobody has to approve it. Once every file is on it and release_url is called, it goes out on its own. Absent where a person still has to say yes.

upload_urlstring

THE POST'S OWN UPLOAD DOOR, while it waits: POST multipart form-data with a file field, no key — possession of the address is the authority, as it is for approval_url. Answers {media_id, kind, name, thumb_url, post_media_ids} and appends the file to this post, in the order the files arrive, up to the tightest max_media among its destinations. The same door the in-chat card uses from the person's browser, so a caller holding the bytes can send them without passing them through a tool argument.

upload_curlstring

upload_url written out as one shell command, YOUR_FILE standing in for the path — for an agent whose environment holds the file.

upload_max_bytesinteger

The most upload_url takes in ONE request. A bigger file goes through the in-chat card, which cuts it into pieces (POST /api/attach/chunk).

release_urlstring

Only with releases_on_media: POST it ONCE, after every file has reached upload_url, and the post goes out — due already it publishes, with time ahead it keeps its slot. Never per file: called after the first of two pictures it would send a half-finished post. Sending it while a required picture is still absent does nothing and answers {released: false}. In the in-chat card the person presses Post now, which calls it once for the whole batch.

Changes

No recorded changes to this operation across all 1 revision of this API.