docs

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 2xx to 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 its x-xpost-delivery id, 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/postsList 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 list
  • 400 Unknown status, or a limit outside 1-100
  • 401 Missing or invalid API key
  • 429 Rate limited

post/api/v1/postsCreate 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 post
  • social_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. 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 (array)From POST /media; order = carousel order, first item leads
  • scheduled_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_apoll_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.
  • 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 — check status; message explains a copilot hold
  • 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

post/api/v1/posts/bulkBulk-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 body
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 429 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 schedule
  • 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_apoll_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.

responses

  • 200 Edited — note the new id
  • 400 Validation error, or a post in a state that can't be edited
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 404 Post not found in this project
  • 422 Blocked by project guardrails
  • 429 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; deleted says which
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 404 Post not found in this project
  • 409 This post can't be deleted in its current state
  • 429 Rate limited

post/api/v1/posts/{id}/approveApprove 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 applied
  • 401 Missing or invalid API key
  • 403 Agent key, or missing posts:approve scope
  • 404 Post not found in this project
  • 409 Post is not pending approval
  • 429 Rate limited

post/api/v1/post-results/{id}/retrySend 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 again
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 404 That delivery isn't in this project
  • 409 That delivery isn't failed
  • 429 Rate limited

post/api/v1/post-results/{id}/takedownRemove 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 down
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 404 That delivery isn't in this project
  • 409 That delivery never went out
  • 429 Rate limited

post/api/v1/mediaUpload 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 or url.
  • 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 — check can.people_tags on 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 an unknown list, 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 Stored
  • 400 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 stored id)
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 404 Nothing was uploaded to that ticket, or it was already claimed
  • 409 people_tags were sent but the handle check isn't configured (bundle.social key missing)
  • 413 Larger than 100 MB
  • 422 A people_tags handle Instagram can't confirm — the body's unknown array names them; retry without those
  • 429 Rate limited

post/api/v1/media/ticketGet 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 minted
  • 401 Missing or invalid API key
  • 403 Missing posts:write scope
  • 429 Rate limited
  • 503 Ticket uploads are not configured on this instance

get/api/v1/post-resultsDelivery 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 Receipts
  • 401 Missing or invalid API key
  • 404 Post not found in this project
  • 429 Rate limited

get/api/v1/projectThe 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 rules
  • 401 Missing or invalid API key
  • 403 Missing posts:read scope
  • 429 Rate limited

get/api/v1/social-accountsList 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 Accounts
  • 401 Missing or invalid API key
  • 429 Rate limited

get/api/v1/posting-rulesPosting 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 rules
  • 401 Missing or invalid API key
  • 429 Rate limited

get/api/v1/connection-issuesOpen connection-health issues

What's broken right now: dead logins, expiring tokens, unreachable accounts — detected proactively by the health checker.

responses

  • 200 Open issues
  • 401 Missing or invalid API key
  • 429 Rate limited

post/api/v1/connection-issuesRun 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 ran
  • 401 Missing or invalid API key
  • 429 Rate limited

get/api/v1/metricsEngagement 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 Metrics
  • 401 Missing or invalid API key
  • 404 Post not found in this project
  • 429 Rate limited

get/api/v1/insightsProject 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-up
  • 401 Missing or invalid API key
  • 429 Rate limited

get/api/v1/top-postsBest-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 deliveries
  • 401 Missing or invalid API key
  • 429 Rate limited

get/api/v1/best-timesBest 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 Slots
  • 401 Missing or invalid API key
  • 429 Rate limited

post/api/v1/guardrails/checkDry-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 Verdict
  • 400 Missing caption
  • 401 Missing or invalid API key
  • 429 Rate limited

get/api/v1/openapi.jsonThis spec

The machine-readable OpenAPI 3.1 description of the API. Public, no auth.

responses

  • 200 OpenAPI document