REST API reference
Everything in xpost — the dashboard and the MCP connector included — runs on this API. Base URL https://dev.xpost.to/api/v1, machine-readable spec at /api/v1/openapi.json.
Authentication
Every request carries an API key from Dashboard → 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.
Endpoints
get/api/v1/posts— List recent posts
Last 50 posts for the project with status (pending_approval, scheduled, processing, posted, partial, failed, rejected, draft).
responses
200— Post list401— 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) — Account ids from GET /social-accountsmedia_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. 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.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], | 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; resend only the fixed rows.
body (application/json, also text/csv)
rows(array, required)
responses
200— Per-row report — check every row400— Unparseable body401— Missing or invalid API key403— Missing posts:write scope429— Rate limited or daily post cap hit mid-batch
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/media— Upload media
Attach an image, video or PDF for a post. Either JSON {url} (the server fetches a public https URL) 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). 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, required)thumbnail_timestamp_ms(integer)alt_text(string)
responses
200— Stored400— No file/url, unfetchable URL, or unsupported type401— Missing or invalid API key403— Missing posts:write scope413— Larger than 100 MB429— Rate limited
get/api/v1/post-results— Delivery receipts for a post
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 — the post went out, but published less than was attached (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.
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/social-accounts— List connected accounts
The agent's account-discovery endpoint: id, platform, username, status per connected account. Use ids as social_accounts when creating posts.
responses
200— Accounts401— 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).
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; each slot carries its sample count — few samples, weak signal.
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.
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