---
title: "Create a post"
method: POST
path: "/posts"
---

# Create a post

`POST /posts`

## Request

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

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.

## Request body

- object
  - `caption` string: Required for every destination that prints one — omit it only on a story-only post
  - `social_accounts` string[], 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_ids` string[]: From POST /media; order = carousel order, first item leads
  - `upload_ticket` string: 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_pending` boolean: 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_at` string: 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_draft` boolean
  - `platform_configurations` object: 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_key` string: 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.

## Response `201`

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

- object
  - `id` string, uuid
  - `status` 'pending_approval' | 'scheduled' | 'processing' | 'draft'
  - `scheduled_at` string, 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.
  - `timezone` string: The project's IANA zone: the clock `scheduled_at` was read on. Report the time in this zone, not the caller's.
  - `message` string
  - `approval_url` string: 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_url` string: 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_media` boolean: Created with `media_pending` and holding no picture yet. It cannot go out until one is on it.
  - `replaced_draft` string, 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_media` boolean: 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.
  - `missing` string: What the draft still lacks before it can go out.
  - `releases_on_media` boolean: 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_url` string: 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_curl` string: `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_bytes` integer: 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_url` string: 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.

## Other responses

- `400`: Validation error
- `401`: Missing or invalid API key
- `403`: Missing posts:write scope, or no active plan
- `422`: Blocked by project guardrails — rewrite to comply, never evade
- `429`: Rate limit or the key's daily post cap hit — honor Retry-After

---

[API](https://skmtc.dev/xpost/apis/xpost-api.md) · [All operations](https://skmtc.dev/xpost/apis/xpost-api/llms.txt) · [OpenAPI document](https://skmtc.dev/xpost/apis/xpost-api/revisions/9f570fcfed6d?raw)
