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 Refused — the violations array names the reason. Fix it; 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/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)
  • from (query, string)
  • to (query, string)

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/posts— Create a post

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.

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
  • 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 post), thread (up to 4 follow-up post texts chained under the main post), 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.

responses

  • 201 — Created — check status; message says what happens next, in words meant to be passed on
  • 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/bulk— Bulk-create posts (≤100)

Up to 100 posts in one call, as JSON {rows: [...]}, raw text/csv (header caption,accounts[,scheduled_at,media_ids,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 — 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)
  • upload_ticket (string) — A ticket from POST /media/ticket that files have been posted to: they become the attachments, in order, after anything in media_ids.
  • add_media_ids (array) — Appended after what the post already holds (or after media_ids when both are sent), duplicates dropped — an edit that adds a picture without knowing the current set.
  • scheduled_at (string) — A new instant (Z or offset); next_slot hands the post to the project's queue; 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 post), thread (up to 4 follow-up post texts chained under the main post), 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.

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}/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 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}/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 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 — or is_post_id: a post id was passed, and deliveries lists the ids this route takes
  • 429 — 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 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 — or is_post_id: a post id was passed, and deliveries lists the ids this route takes (POST /posts/{id}/takedown takes a post id directly)
  • 429 — Rate limited

post/api/v1/posts/{id}/takedown— Remove a published post from every platform it is live on

Takes a whole POST down: every delivery of it that is live right now, or only the ones on platform. Same effect and the same care as /post-results/{id}/takedown — no undo, receipt rows stay, the actor is recorded — without fetching the receipt first to learn a delivery id. One platform refusing does not stop the others: each delivery answers for itself in deliveries, with error_code and retriable where it was refused. Only ever on an explicit request to take that post down.

parameters

  • id (path, required, string)

body (application/json)

  • platform (string) — Only take it down here (e.g. x). Omit for every platform it is live on.

responses

  • 200 — Each live delivery's outcome
  • 400 — Body must be JSON
  • 401 — Missing or invalid API key
  • 403 — Missing posts:write scope
  • 404 — That post isn't in this project
  • 409 — The post isn't live anywhere (never_went_out)
  • 429 — 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 — also how an MCP host's attachment arrives, through its temporary download 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 — No file has reached that ticket yet — post the file to the upload slot first, then redeem the SAME ticket. A ticket whose browser door was used answers 200 with media_ids and already_uploaded, not 404
  • 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/ticket— Get an upload slot

Mint an upload slot for a script or sandbox that holds a file and cannot POST it to /media directly. Not part of the MCP surface since 2026-09-15: an assistant's attachment goes through create_post.files or media_pending and the approval card. 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) and answers with the media_id directly. When a doorstep bucket is configured the reply ALSO carries {url, fields, curl}, a presigned path-style S3 POST: 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. A slot lasts half an hour, covers several files, and a failed step does not use it up — fix the step and redeem the same ticket.

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-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 — Receipts
  • 401 — Missing or invalid API key
  • 404 — Post not found in this project
  • 429 — 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 (autopilot, the default, lets agent posts go; copilot holds them for a human), guardrails (the enabled rules, as configured, so a draft can comply instead of being rewritten after a 422), posts_today (this key's daily allowance, counted the same way the 429 counts it), and queue (the posting schedule and its next free slots, for scheduled_at: "next_slot"). 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-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 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 five images on a natively connected account and pins one through the aggregator — max_media follows the route — 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-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). board_ids is optional on Pinterest — with none, the pin goes to the account's catch-all board ("Unorganized ideas") — but a board name must come from this list, never a guess. 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-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 issues
  • 401 — Missing or invalid API key
  • 429 — 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 ran
  • 401 — Missing or invalid API key
  • 429 — 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 — Metrics
  • 401 — Missing or invalid API key
  • 404 — Post not found in this project
  • 429 — 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-up
  • 401 — Missing or invalid API key
  • 429 — 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 deliveries
  • 401 — Missing or invalid API key
  • 429 — 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 — Slots
  • 401 — Missing or invalid API key
  • 429 — 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 — Verdict
  • 400 — Missing caption
  • 401 — Missing or invalid API key
  • 429 — 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