REST API reference
Everything in xpost — the dashboard and the MCP connector included — runs on this API. Base URL https://xpost.to/api/v1, machine-readable spec at /api/v1/openapi.json.
Authentication
Every request carries an API key from Dashboard → AI agent:
Authorization: Bearer xp_live_YOUR_KEY
Keys carry scopes — posts:read (reads and dry-runs), posts:write (posts and media), posts:approve (deciding the queue; human keys only). A missing scope is a 403 that names it. Agent keys can never approve, whatever their scopes say.
Rate limits
Per key: 60 requests/min by default (editable per key), plus an optional daily post cap. Over either limit → 429 with Retry-After and X-RateLimit-* headers. Honor them.
The two responses worth understanding
- ·
201 status: pending_approval— copilot mode held the post for a human. Expected, not an error. - ·
422 Blocked by project guardrails— the violations array names the rule. Rewrite to comply; never evade.
Webhooks: being told instead of asking
Add a URL under Dashboard → Project → Notifications and xpost POSTs to it when something happens, so nothing has to poll. Five events: post.published, post.failed, post.approved, post.rejected, account.needs_reconnect. You subscribe to the ones you want; a new event kind never starts arriving on its own.
Every request carries the event, a delivery id, a timestamp and a signature:
POST /your/endpoint
x-xpost-event: post.published
x-xpost-delivery: 3f1c… # stable per delivery; the retry reuses it
x-xpost-timestamp: 1787616007 # seconds
x-xpost-signature: v1=9ad3… # HMAC-SHA256 of "<timestamp>.<body>"
{
"event": "post.published",
"delivery_id": "3f1c…",
"created_at": "2026-08-17T09:20:07.000Z",
"project": { "id": "…", "name": "Acme" },
"data": {
"post": { "id": "…", "caption": "…", "status": "posted", "delivered": 2, "targets": 2 },
"deliveries": [
{ "platform": "x", "username": "acme", "status": "success", "url": "https://x.com/…", "error": null }
]
}
}Verify before you trust it — the signing secret is on the same screen:
import { createHmac, timingSafeEqual } from "crypto";
// Sign the RAW body, before any JSON parsing — a re-serialised body won't match.
const expected = "v1=" + createHmac("sha256", process.env.XPOST_WEBHOOK_SECRET)
.update(`${headers["x-xpost-timestamp"]}.${rawBody}`)
.digest("hex");
const got = headers["x-xpost-signature"];
const ok = expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got));
// Reject anything older than five minutes: the timestamp is inside the
// signature, so a captured request can't be re-dated, only replayed.
const fresh = Math.abs(Date.now() / 1000 - Number(headers["x-xpost-timestamp"])) < 300;- · Answer
2xxto accept. Anything else is retried 5 times over about 2½ hours, then given up on with the reason shown on the endpoint. 10 given-up events in a row and we stop sending until you turn it back on. - · Be idempotent anyway. One event is sent once per endpoint — a post that needed three publish attempts still produces one
post.published— but a retried DELIVERY reuses itsx-xpost-deliveryid, so a receiver that timed out after doing the work will see it again. - · https only, no redirects followed, 10-second timeout, and the host must be publicly routable.
Endpoints
get/api/v1/posts— List recent posts
Posts for the project, newest first, filterable by status and bounded by limit. Ask for what you want: ?status=pending_approval answers "what is waiting on me?" on its own, and several may be named at once (?status=pending_approval,scheduled). The reply echoes filter — what actually applied — because a filter that matched everything and a filter that never reached the query look identical in the rows. Rows carry status (pending_approval, scheduled, processing, posted, partial, failed, rejected, draft). A pending_approval row also carries approval_url — its one-click approve/reject page. A rejected row carries rejectionReason: what the person actually objected to, which is what to fix before sending it again. publishes says what has to happen before a row goes out: at_time (scheduledAt is a slot somebody chose), on_approval (a person's yes, scheduledAt: null), or now (nothing — it is on its way). It is **null** on a parked row — a draft or a rejected one — because nothing is going to happen to those until somebody decides something; read status.
parameters
status(query, string)limit(query, integer, default 50)
responses
200— Post list400— Unknown status, or a limit outside 1-100401— Missing or invalid API key429— Rate limited
post/api/v1/posts— Create a post
Create a post targeting one or more connected accounts. Guardrails run on ALL outbound text (caption, per-platform overrides, X first comment and thread) — a block returns 422 with the violations, never a silent drop. In copilot mode the post is held for human approval: a pending_approval response is the system working, not an error. Omit scheduled_at to publish as soon as it's created/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.
body (application/json)
caption(string) — Required for every destination that prints one — omit it only on a story-only postsocial_accounts(array, 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. Preferplatform: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(array) — From POST /media; order = carousel order, first item leadsscheduled_at(string) — ISO 8601 with Z or a UTC offset; omit to post now (after approval in copilot mode). Past times (beyond a 2-minute grace) are refused.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,poll. 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: Instagramis_paid_partnership+branded_content_sponsors(max 2 usernames) +is_ai_generated; TikTokis_brand_content(a third party paid) /is_organic_brand_content(your own business) /is_ai_generated; YouTubehas_paid_product_placement+contains_synthetic_media; Pinterestis_ai_generated. Instagram:first_commenttoo (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_statusand 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 withrepeated: true) instead of writing another — a timed-out call is not evidence that nothing was written.skip_signature(boolean) — Leave the project's signature off this post. The signature is added to every post by default, including yours.
responses
201— Created — checkstatus;messageexplains a copilot hold400— Validation error401— Missing or invalid API key403— Missing posts:write scope, or no active plan422— Blocked by project guardrails — rewrite to comply, never evade429— Rate limit or the key's daily post cap hit — honor Retry-After
post/api/v1/posts/bulk— Bulk-create posts (≤100)
Up to 100 posts in one call, as JSON {rows: [...]}, raw text/csv (header caption,accounts[,scheduled_at,media_urls,idempotency_key], | separates multiple values inside a cell), or multipart CSV in a file field. Account refs may be ids, platform:username, or a bare username when unambiguous. Every row independently runs the FULL single-post path — guardrails, plan limits, copilot approval. Always read the per-row report: some rows can fail while others succeed (a row that crashed reports code: "internal_error" and nothing was created for it); resend only the rows that need it. **The response is 200 even when the key's daily post cap fills mid-batch** — the rows past the cap report it individually, so the rows before it are not lost. Give every row its own idempotency_key: a resend after a timeout answers rows already written with repeated: true instead of posting them twice.
body (application/json, also text/csv)
rows(array, required)
responses
200— Per-row report — check every row. Also the answer when the daily post cap fills mid-batch: the capped rows say so individually.400— Unparseable body401— Missing or invalid API key403— Missing posts:write scope429— Rate limited (the daily post cap is reported per row, in a 200)
patch/api/v1/posts/{id}— Change a post that hasn't gone out
Caption, attachments, time, per-platform options — anything omitted is kept. It takes the composer's own edit path: a new post supersedes the old one, so guardrails, caps and the signature all run again on the words that will actually be published (and the response carries the NEW id). Any post in the project that hasn't gone out — pending_approval / draft / scheduled / rejected, whoever wrote it. **An agent's edit of a post a human approved (or rejected) goes back into the queue**, because the approval was for the old words. A post that has already been tried can't be edited: retry the delivery that failed instead.
parameters
id(path, required, string)
body (application/json)
caption(string)media_ids(array)scheduled_at(string) — null clears the scheduleplatform_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,poll. 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: Instagramis_paid_partnership+branded_content_sponsors(max 2 usernames) +is_ai_generated; TikTokis_brand_content(a third party paid) /is_organic_brand_content(your own business) /is_ai_generated; YouTubehas_paid_product_placement+contains_synthetic_media; Pinterestis_ai_generated. Instagram:first_commenttoo (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_statusand similar enums and ids) are exempt: they are not places a message can hide.
responses
200— Edited — note the newid400— Validation error, or a post in a state that can't be edited401— Missing or invalid API key403— Missing posts:write scope404— Post not found in this project422— Blocked by project guardrails429— Rate limited
delete/api/v1/posts/{id}— Delete a post that never went out
Remove a post permanently, along with its delivery records. An **agent key** may only delete posts an agent created, and only while they are pending_approval, draft, rejected or failed — this is how an assistant clears up drafts the person didn't want instead of leaving them in the approval queue. **A scheduled post is taken off the schedule instead of deleted**: the answer is 200 with deleted: false, unscheduled: true and the status it went back to (pending_approval, keeping its time for when it is approved again, or draft) — read deleted before reporting what happened. Anything published, or approved by a person, is refused (409) for agents and humans alike: those rows are the receipt of what went out.
parameters
id(path, required, string)
responses
200— Deleted — or, for a scheduled post, unscheduled;deletedsays which401— Missing or invalid API key403— Missing posts:write scope404— Post not found in this project409— This post can't be deleted in its current state429— Rate limited
post/api/v1/posts/{id}/approve— Approve or reject a held post
Decide a pending_approval post. Human keys with posts:approve only — agent keys are always 403 here; approval is the product's trust boundary.
parameters
id(path, required, string)
body (application/json)
decision(string, required)reason(string)
responses
200— Decision applied401— Missing or invalid API key403— Agent key, or missing posts:approve scope404— Post not found in this project409— Post is not pending approval429— Rate limited
post/api/v1/post-results/{id}/retry— Send a failed delivery again
Re-sends ONE failed delivery — the id from GET /post-results. The right answer to "it failed on Instagram": creating the post again would send a second copy to the accounts that worked. A delivery that succeeded is refused.
parameters
id(path, required, string)
responses
200— Queued again401— Missing or invalid API key403— Missing posts:write scope404— That delivery isn't in this project409— That delivery isn't failed429— Rate limited
post/api/v1/post-results/{id}/takedown— Remove a published post from its platform
Deletes the live post on X, Instagram, LinkedIn… **This reaches back into the world**: the people who saw it stop being able to, there is no undo, and reposting makes a new post with a new URL. The receipt row stays and the actor is recorded. Only ever on an explicit request to take that post down.
parameters
id(path, required, string)
responses
200— Taken down401— Missing or invalid API key403— Missing posts:write scope404— That delivery isn't in this project409— That delivery never went out429— Rate limited
post/api/v1/media— Upload media
Attach an image, video or PDF for a post. Three shapes: JSON {url} (the server fetches a public https URL), JSON {ticket} (redeems an upload slot from POST /media/ticket), or multipart form-data with a file field. ≤100 MB. Optional: thumbnail_timestamp_ms picks a video's cover frame; alt_text (≤1000 chars) sets an image's accessibility text (applied natively on X and Bluesky); people_tags tags people in a picture (see the field — Instagram only, and every handle is checked before the file is stored). Returns {id, kind} — pass id in create_post media_ids. A PDF (kind: "document") is a LinkedIn document post: one per post, attached on its own, and only to a LinkedIn account whose connection supports documents — create_post refuses anything else and says why.
body (application/json, also multipart/form-data)
url(string)ticket(string) — An upload ticket from POST /media/ticket, once the file has been posted to the slot. Give either this orurl.thumbnail_timestamp_ms(integer)alt_text(string)people_tags(array) — People tagged IN the picture. Instagram only, and only on an account connected through bundle.social with Facebook Login — checkcan.people_tagson GET /social-accounts before sending any. Stored on the MEDIA row, not the post: reuse the photo and the tags come with it, like alt text. Every handle is verified against Instagram business discovery BEFORE the file is stored; one it cannot see (a private or personal account) is refused with 422 and anunknownlist, because Instagram does not drop a bad tag and publish anyway — it refuses the whole post. On a feed image these render as photo tags; on a story the same field renders as a MENTION sticker.
responses
200— Stored400— No file/url/ticket, unfetchable URL, unsupported type, an expired ticket, or a malformed people_tags (including tags sent with a video — the response carries the storedid)401— Missing or invalid API key403— Missing posts:write scope404— Nothing was uploaded to that ticket, or it was already claimed409— people_tags were sent but the handle check isn't configured (bundle.social key missing)413— Larger than 100 MB422— A people_tags handle Instagram can't confirm — the body'sunknownarray names them; retry without those429— Rate limited
post/api/v1/media/ticket— Get an upload slot
Mint an upload slot for a file that has to reach us without the caller being able to send it. Always returns {ticket, attach_url, max_bytes, expires_in_seconds}: attach_url takes the file straight from a BROWSER (POST multipart with a file field), which is what the drop-zone card inside an MCP host uses, and it answers with the media_id directly — nothing to redeem. When a doorstep bucket is configured the reply ALSO carries {url, fields, curl}, a presigned path-style S3 POST for a caller whose sandbox can reach S3: post the file there (the file field must come LAST), then redeem ticket at POST /media, and we collect the object, sniff its real type from the bytes and delete it. Nothing of yours is stored on S3. Note that an assistant sandbox usually cannot reach either host — Claude's allows package managers only — which is why the browser route exists. A slot lasts a few minutes.
responses
200— Slot minted401— Missing or invalid API key403— Missing posts:write scope429— Rate limited503— Ticket uploads are not configured on this instance
get/api/v1/post-results— Delivery receipts for a post
Also how a post is shown again: the reply carries approval_url, a signed link to the post as a picture of itself (caption, destinations, outcome), for every status — an already-decided post cannot be decided through it. Per-platform delivery receipts: each targeted account gets its own row with status, retries, the live post URL on success, or error detail on failure. A successful row may also carry note, with note_kind saying what it means: "info" — the post went out whole and the note only says how (the pin used the account's catch-all board because none was picked) — or null, meaning something was left behind (X takes 4 images, Facebook won't let an app post photos and video together, a reel is one clip). Check this after publish time and report honestly — including partial failures and any note. Don't re-post over an info note: nothing is missing.
parameters
post_id(query, required, string)
responses
200— Receipts401— Missing or invalid API key404— Post not found in this project429— Rate limited
get/api/v1/project— The project's own rules
What an agent is judged by, in one call: timezone (the clock a bare date-time is read on), approval (copilot holds agent posts for a human; autopilot doesn't), signature (the footer appended to every caption before guardrails run — its length is already deducted from each account's caption_chars_available), guardrails (the enabled rules, as configured, so a draft can comply instead of being rewritten after a 422), and posts_today (this key's daily allowance, counted the same way the 429 counts it). Read-only, and deliberately nothing about members, billing or keys.
responses
200— Project rules401— Missing or invalid API key403— Missing posts:read scope429— Rate limited
get/api/v1/social-accounts— List connected accounts
The agent's account-discovery endpoint: id, platform, username, status per connected account. Each account also carries ref — the readable name to send in social_accounts when creating a post (x:someone). Use it rather than the id: an agent's post is shown to a person to approve, printed as the agent wrote it, and a uuid asks them to approve a destination they cannot see. ref is always sendable as returned; for the rare account whose username is duplicated on its platform it IS the id. Each account also carries limits and can — what this connection will actually publish. limits: caption_chars, caption_chars_available (the ceiling minus the project's signature — the room the caption really has), max_media, media_required (a text-only post is refused where true), video_seconds. can: placements (surfaces beyond the feed), first_comment, people_tags, reel_music, documents, mixed_media. These are **route** facts, not the platform's brochure: Threads documents 20 attachments and takes 10 through us, Pinterest carousels on its own API and takes 1, and several of the can flags depend on how this account was connected.
responses
200— Accounts401— Missing or invalid API key429— Rate limited
get/api/v1/posting-rules— Posting rules and option catalog, per account
Everything a post to each account may carry, as data: the limits/can brief from GET /social-accounts plus options — every platform_configurations.<platform> key THIS account accepts, with kind (text | textarea | list | toggle | select), accepted values, default, max_length/max_items ceilings, placement gates (only_placement/except_placement) and a plain-English note where the key alone would mislead. Keys are filtered by the account's actual connection route — a key that would lose the whole post on this route (an Instagram first comment off bundle.social, reel music or a partnership label off Facebook Login) is simply absent. Pinterest accounts also answer boards: the live boards board_ids accepts, each { name, eligible, reason? } (null = lookup unavailable right now; boards may be omitted and the pin lands on Quick Saves). A board with eligible: false is listed rather than hidden and CANNOT take a pin — a sandbox board refuses every real pin, and sending it loses the delivery. Read this before building platform_configurations instead of guessing from prose.
parameters
account_ids(query, string)
responses
200— Per-account rules401— Missing or invalid API key429— Rate limited
get/api/v1/connection-issues— Open connection-health issues
What's broken right now: dead logins, expiring tokens, unreachable accounts — detected proactively by the health checker.
responses
200— Open issues401— Missing or invalid API key429— Rate limited
post/api/v1/connection-issues— Run a connection health check now
Trigger an immediate health check of the project's connected accounts (bypasses the scheduler's throttle). Returns a summary plus the open-issue list.
responses
200— Check ran401— Missing or invalid API key429— Rate limited
get/api/v1/metrics— Engagement metrics for one post
Per-delivery engagement (views, likes, comments, shares, reach, saves, engagement) with the live URL. A null metric means the platform doesn't report it — not zero. Empty data = not synced yet (metrics refresh roughly every 6 h).
parameters
post_id(query, required, string)
responses
200— Metrics401— Missing or invalid API key404— Post not found in this project429— Rate limited
get/api/v1/insights— Project engagement roll-up
Post/delivery counts, metric totals, and a per-platform breakdown over the window. "How are we doing?" in one call. Optionally sliced to one platform and/or one format (feed post / reel / story).
Every answer carries coverage, which says why an empty answer is empty. deliveries is what went out in the window, and the three counts under it PARTITION it — with_metrics + unreportable + awaiting === deliveries, always: with_metrics is what the totals could be built from, unreportable has no numbers and never will (deleted, expired, or a kind of post the platform never reports on), awaiting is simply not in yet. unsupported is a SUBSET of unreportable, not a fourth bucket: deliveries nobody will ever report on — stories, which Instagram and Facebook report nothing about, ever. So format=story returns zero totals however many stories went out; read coverage.unsupported before calling a window quiet. ever_delivered ignores the window and every filter, and separates "nothing here" from "nothing ever".
parameters
since_days(query, integer, default 30)platform(query, string)format(query, feed | reel | story)
responses
200— Roll-up401— Missing or invalid API key429— Rate limited
get/api/v1/top-posts— Best-performing deliveries
Deliveries ranked by a metric over the window — study these before drafting to learn what works for this audience.
parameters
metric(query, engagement | views | likes | comments | shares | reach | saves, default engagement)since_days(query, integer, default 30)limit(query, integer, default 10)platform(query, string)format(query, feed | reel | story)
responses
200— Ranked deliveries401— Missing or invalid API key429— Rate limited
get/api/v1/best-times— Best posting times
Weekday × hour (UTC) slots ranked by average engagement from the project's OWN past results. Use when choosing scheduled_at.
Read signal before the order. A ranking always comes back and is only advice when there is something to rank on: none means no engagement has been recorded at all, so first place is wherever the sort left it; thin means a slot rests on one or two posts (each slot carries its own samples); ok means every slot has three or more.
parameters
platform(query, string)format(query, feed | reel | story)since_days(query, integer, default 90)
responses
200— Slots401— Missing or invalid API key429— Rate limited
post/api/v1/guardrails/check— Dry-run guardrails on a draft
Check a caption against the project's brand guardrails (banned words/topics, tone, link policy, posting cap) WITHOUT creating a post. Fix the draft before create_post instead of burning a rejection.
Read rules_checked before treating a pass as a pass: empty means the project has no guardrails configured, so allowed: true means nothing objected rather than that the caption was checked. rules_skipped lists rules that were asked and could not answer — the topic and tone classifiers fail open by design.
body (application/json)
caption(string, required)
responses
200— Verdict400— Missing caption401— Missing or invalid API key429— Rate limited
get/api/v1/openapi.json— This spec
The machine-readable OpenAPI 3.1 description of the API. Public, no auth.
responses
200— OpenAPI document