{"openapi":"3.1.0","info":{"title":"xpost API","version":"1.0","description":"The posting layer between AI agents and social audiences: approval queues, brand guardrails, and per-platform delivery receipts across 9 networks (Bluesky, X, LinkedIn, Instagram, Facebook, TikTok, YouTube, Threads, Pinterest).\n\n**Auth**: every request needs `Authorization: Bearer <api key>` — create keys in Dashboard → API keys. Agent keys respect copilot mode: their posts land in `pending_approval` and a human approves from the dashboard, Telegram, or email. Agent keys can never approve posts.\n\n**Scopes**: `posts:read` (all GETs + dry-runs), `posts:write` (create posts/media), `posts:approve` (approve route, human keys only). Missing scope → 403 naming it.\n\n**Rate limits**: Every endpoint is rate-limited per key (default 60 requests/min, editable per key on the Keys page). Over the limit → 429 with `Retry-After` and `X-RateLimit-*` headers. Keys may also carry a daily post cap.\n\n**MCP**: the same surface is exposed as a hosted MCP connector at `/api/mcp` (Bearer key, or `/api/mcp/<key>` for header-less connector UIs) — 11 tools including engagement insights."},"servers":[{"url":"{base}/api/v1","variables":{"base":{"default":"https://xpost.to"}}}],"security":[{"bearerAuth":[]}],"paths":{"/posts":{"post":{"summary":"Create a post","description":"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.","operationId":"createPost","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["social_accounts"],"properties":{"caption":{"type":"string","maxLength":10000,"description":"Required for every destination that prints one — omit it only on a story-only post"},"social_accounts":{"type":"array","minItems":1,"items":{"type":"string","format":"uuid"},"description":"Account ids from GET /social-accounts"},"media_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"From POST /media; order = carousel order, first item leads"},"scheduled_at":{"type":"string","format":"date-time","description":"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":{"type":"boolean","default":false},"platform_configurations":{"type":"object","description":"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_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.","additionalProperties":{"type":"object","additionalProperties":true},"example":{"instagram":{"placement":"stories"},"x":{"first_comment":"Link in this comment 👇"}}},"skip_signature":{"type":"boolean","default":false,"description":"Leave the project's signature off this post. The signature is added to every post by default, including yours."}}}}}},"responses":{"201":{"description":"Created — check `status`; `message` explains a copilot hold","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending_approval","scheduled","processing","draft"]},"message":{"type":"string"}}},"example":{"id":"0d9e…","status":"pending_approval","message":"This project is in copilot mode: the post is queued for human approval."}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Missing posts:write scope, or no active plan","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Blocked by project guardrails — rewrite to comply, never evade","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"violations":{"type":"array","items":{"type":"object","properties":{"rule":{"type":"string"},"detail":{"type":"string"}}}}}}}}},"429":{"description":"Rate limit or the key's daily post cap hit — honor Retry-After","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"summary":"List recent posts","description":"Last 50 posts for the project with status (pending_approval, scheduled, processing, posted, partial, failed, rejected, draft).","operationId":"listPosts","responses":{"200":{"description":"Post list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/posts/bulk":{"post":{"summary":"Bulk-create posts (≤100)","description":"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.","operationId":"bulkCreatePosts","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["rows"],"properties":{"rows":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["caption","accounts"],"properties":{"caption":{"type":"string","minLength":1,"maxLength":10000},"accounts":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Account id, platform:username, or unique username"},"scheduled_at":{"type":"string","description":"ISO 8601; omit to post now"},"media_urls":{"type":"array","items":{"type":"string","format":"uri"},"description":"Public https URLs, ingested per row"}}}}}}},"text/csv":{"schema":{"type":"string","description":"Header row: caption,accounts[,scheduled_at,media_urls]"}}}},"responses":{"200":{"description":"Per-row report — check every row","content":{"application/json":{"schema":{"type":"object","properties":{"created":{"type":"integer"},"failed":{"type":"integer"},"results":{"type":"array","items":{"type":"object"}}}}}}},"400":{"description":"Unparseable body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Missing posts:write scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited or daily post cap hit mid-batch","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/posts/{id}/approve":{"post":{"summary":"Approve or reject a held post","description":"Decide a `pending_approval` post. Human keys with `posts:approve` only — agent keys are always 403 here; approval is the product's trust boundary.","operationId":"decidePost","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["decision"],"properties":{"decision":{"type":"string","enum":["approve","reject"]},"reason":{"type":"string","maxLength":2000}}}}}},"responses":{"200":{"description":"Decision applied","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Agent key, or missing posts:approve scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Post is not pending approval","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/media":{"post":{"summary":"Upload media","description":"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.","operationId":"uploadMedia","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"thumbnail_timestamp_ms":{"type":"integer","minimum":0},"alt_text":{"type":"string","maxLength":1000}}}},"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"},"thumbnail_timestamp_ms":{"type":"integer"},"alt_text":{"type":"string"}}}}}},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["image","video"]}}}}}},"400":{"description":"No file/url, unfetchable URL, or unsupported type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Missing posts:write scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Larger than 100 MB","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/post-results":{"get":{"summary":"Delivery receipts for a post","description":"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`.","operationId":"getPostResults","parameters":[{"name":"post_id","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Receipts","content":{"application/json":{"schema":{"type":"object","properties":{"post_id":{"type":"string"},"post_status":{"type":"string"},"data":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/social-accounts":{"get":{"summary":"List connected accounts","description":"The agent's account-discovery endpoint: id, platform, username, status per connected account. Use ids as `social_accounts` when creating posts.","operationId":"listSocialAccounts","responses":{"200":{"description":"Accounts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"platform":{"type":"string"},"username":{"type":"string"},"status":{"type":"string"}}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/connection-issues":{"get":{"summary":"Open connection-health issues","description":"What's broken right now: dead logins, expiring tokens, unreachable accounts — detected proactively by the health checker.","operationId":"listConnectionIssues","responses":{"200":{"description":"Open issues","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Run a connection health check now","description":"Trigger an immediate health check of the project's connected accounts (bypasses the scheduler's throttle). Returns a summary plus the open-issue list.","operationId":"runConnectionHealthCheck","responses":{"200":{"description":"Check ran","content":{"application/json":{"schema":{"type":"object","properties":{"summary":{"type":"object"},"data":{"type":"array"}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/metrics":{"get":{"summary":"Engagement metrics for one post","description":"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).","operationId":"getPostMetrics","parameters":[{"name":"post_id","in":"query","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Metrics","content":{"application/json":{"schema":{"type":"object","properties":{"post_id":{"type":"string"},"data":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found in this project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/insights":{"get":{"summary":"Project engagement roll-up","description":"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).","operationId":"getInsights","parameters":[{"name":"since_days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}},{"name":"platform","in":"query","schema":{"type":"string"}},{"name":"format","in":"query","schema":{"type":"string","enum":["feed","reel","story"]}}],"responses":{"200":{"description":"Roll-up","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"integer"},"deliveries":{"type":"integer"},"totals":{"type":"object"},"by_platform":{"type":"array"}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/top-posts":{"get":{"summary":"Best-performing deliveries","description":"Deliveries ranked by a metric over the window — study these before drafting to learn what works for this audience.","operationId":"getTopPosts","parameters":[{"name":"metric","in":"query","schema":{"type":"string","enum":["engagement","views","likes","comments","shares","reach","saves"],"default":"engagement"}},{"name":"since_days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":50,"default":10}},{"name":"platform","in":"query","schema":{"type":"string"}},{"name":"format","in":"query","schema":{"type":"string","enum":["feed","reel","story"]}}],"responses":{"200":{"description":"Ranked deliveries","content":{"application/json":{"schema":{"type":"object","properties":{"metric":{"type":"string"},"since_days":{"type":"integer"},"data":{"type":"array"}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/best-times":{"get":{"summary":"Best posting times","description":"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.","operationId":"getBestTimes","parameters":[{"name":"platform","in":"query","schema":{"type":"string"}},{"name":"format","in":"query","schema":{"type":"string","enum":["feed","reel","story"]}},{"name":"since_days","in":"query","schema":{"type":"integer","minimum":1,"maximum":365,"default":90}}],"responses":{"200":{"description":"Slots","content":{"application/json":{"schema":{"type":"object","properties":{"slots":{"type":"array","items":{"type":"object","properties":{"weekday":{"type":"string","example":"Tue"},"hour_utc":{"type":"integer"},"avg_engagement":{"type":"number"},"samples":{"type":"integer"}}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/guardrails/check":{"post":{"summary":"Dry-run guardrails on a draft","description":"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.","operationId":"checkGuardrails","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["caption"],"properties":{"caption":{"type":"string","minLength":1,"maxLength":10000}}}}}},"responses":{"200":{"description":"Verdict","content":{"application/json":{"schema":{"type":"object","properties":{"allowed":{"type":"boolean"},"violations":{"type":"array","items":{"type":"object"}}}}}}},"400":{"description":"Missing caption","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/openapi.json":{"get":{"summary":"This spec","description":"The machine-readable OpenAPI 3.1 description of the API. Public, no auth.","operationId":"getOpenApiSpec","security":[],"responses":{"200":{"description":"OpenAPI document","content":{"application/json":{"schema":{"type":"object"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An API key from Dashboard → API keys (format xp_live_…). Shown once at creation."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]}}}}