Docs
Docs/API Reference/Posts

Posts

Create, schedule, queue, approve, publish, retry and delete posts through the Octopost REST API, including threads, file uploads and bulk scheduling.

Network status (2026-11): X (twitter), Bluesky (bluesky), Mastodon (mastodon) and LinkedIn (linkedin, personal profiles) are live. Threads (threads) opens after Meta approves the Octopost app. Instagram, Facebook, TikTok, YouTube, Pinterest and Reddit are in platform review and not available yet. Discord, Slack and Telegram are not supported. The API still accepts some of their ids in platforms for older integrations, but Octopost can't post to them.

A post is one piece of content headed to one or more networks. It can be a draft, scheduled for a time, waiting in your queue, waiting for approval, or published. Octopost handles each network's auth, media uploads, threading and character counting.

All paths below are on https://octopost.ink and need an API key.

The post object#

GET /api/posts/{id} returns the full post:

{
  "success": true,
  "post": {
    "id": "6f1c2d3e-8a9b-4c5d-9e0f-112233445566",
    "content": "Shipping v2 today.",
    "platforms": ["twitter", "bluesky"],
    "scheduledFor": null,
    "published": true,
    "status": "partial",
    "results": {
      "twitter": { "platform": "twitter", "status": "published", "postId": "1234567890", "url": "https://x.com/you/status/1234567890" },
      "bluesky": { "platform": "bluesky", "status": "failed", "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
    },
    "lastError": "bluesky: Bluesky session expired. Reconnect the account.",
    "mediaUrls": [{ "url": "https://example.com/shot.png", "altText": "The new dashboard" }],
    "poll": null,
    "mastodonVisibility": "public",
    "contentWarning": null,
    "platformPostIds": {
      "twitter": { "id": "1234567890", "url": "https://x.com/you/status/1234567890" },
      "bluesky": { "id": "", "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
    },
    "queueStatus": null,
    "queuePosition": null,
    "segments": null,
    "variants": null,
    "createdAt": "2026-11-04T15:00:00.000Z",
    "updatedAt": "2026-11-04T15:00:04.000Z"
  }
}
FieldTypeDescription
idstringPost id (a UUID).
contentstringMain post text.
platformsstring[]Networks this post targets.
scheduledForstring | nullWhen the scheduler will publish it (or retry it, while status is retrying). Cleared once it publishes.
publishedbooleanTrue once at least one network has it.
statusstringOne word for where the post is. See status.
resultsobjectOne entry per network, keyed by lowercase id. See results.
lastErrorstring | nullThe latest failure, one network: error per network that isn't live. null when nothing failed.
mediaUrlsarray | nullMedia for the main post: URL strings or { url, altText } objects.
pollobject | null{ options, durationMinutes, multipleChoice?, hideTotals? }.
mastodonVisibilitystringpublic, unlisted, private or direct.
contentWarningstring | nullMastodon content warning.
platformPostIdsobject | nullThe stored record per network (ids, URLs, thread parts). results is the easier read.
queueStatusstring | nullqueued (in your queue), pending (waiting for approval) or null.
queuePositionnumber | nullSort key inside the queue. Lower goes first.
segmentsarray | nullThread replies. See Threads.
variantsobject | nullA different version for some networks. See Different text per network.
createdAt, updatedAtstringISO 8601 timestamps.

status#

statusMeaning
draftSaved, not scheduled or queued.
scheduledHas a scheduledFor time.
queuedIn your queue; the queue publishes it at the next slot.
pending_approvalWaiting for approval (POST /api/posts/{id}/approve).
publishingBeing published right now. Poll GET /api/posts/{id} every 15 seconds or so until it changes (see slow publishes).
retryingA network failed with a temporary error and Octopost retries it automatically at scheduledFor (scheduled and queued posts: up to 3 times, about 1, 5 and 15 minutes apart).
publishedLive on every network.
partialLive on some networks (or a thread stopped part way). results says which.
failedLive nowhere. results says why.
needs_reviewPublishing was interrupted by a server restart. Some networks may have it: check results, then retry the rest.

partial, failed and needs_review posts can be retried with POST /api/posts/{id}/retry; only what isn't live is published again.

results#

results has one entry for every network of the post:

FieldDescription
platformLowercase network id.
statuspublished, partial (a thread with only some parts live), failed, or pending (not attempted yet).
postId, urlThe live post (the first part, for a thread).
errorWhat went wrong, written to be shown to a person, usually with what to do next.
errorCodeMachine-readable reason when known. See Errors.
retryabletrue when the failure is temporary (network down, rate limit, timeout) and a retry is likely to work.
publishedCount, totalCountThreads: how many parts are live out of how many.
varianttrue when this network got its own version of the post (see variants). Left out otherwise.
firstCommentOnly when the post has a first comment and the post is live on that network: { "status": "published", "postId", "url" } or { "status": "failed", "error" }. A failed first comment doesn't change status; retry posts it without posting the post again.

platformPostIds#

The stored record, keyed by lowercase network id. Each entry is one of:

ShapeWhen
{ "id": "...", "url": "..." }A single post went out.
{ "id": "", "error": "...", "errorCode": "...", "retryable": false }That network failed.
{ "type": "thread", "ids": [...], "urls": [...], "threadUrl": "..." }A thread posted as a native reply chain.
{ "type": "comment_thread", "mainId": "...", "mainUrl": "...", "commentIds": [...], "commentUrls": [...] }A thread posted as a main post plus comments (LinkedIn).

A thread entry that also has error means some parts posted and the rest didn't. Posts that failed before 2026-11-04 can still hold bookkeeping keys such as _failedAt; results ignores them.


Create a post#

POST /api/posts

One endpoint covers every way to create a post. What happens depends on the fields you send:

You sendResult
content + platforms onlyA draft.
published: truePublished right away. The response waits for every network and includes the results.
scheduledForScheduled. The scheduler checks every minute and publishes once the time has passed.
queueStatus: "queued" (no scheduledFor)Added to the end of your queue.
queueStatus: "pending" (no scheduledFor)Held for approval, then joins the queue once approved.
scheduledFor + queueStatus: "pending"Scheduled, but held until approved. After approval it publishes at scheduledFor (or on the next minute if that time has passed).

queueStatus: "queued" together with scheduledFor is ignored: the post is just scheduled.

Octopost checks two things before saving: you have an active plan (403 with "code": "trial_expired" if not) and your email is verified (403 if not). This applies to drafts too.

Request body (JSON)#

FieldTypeRequiredDescription
contentstringYesMain post text, 1 to 5,000 characters. Each network's own limit is checked when it publishes (X counts a link as 23 characters).
platformsstring[]YesAt least one of twitter, threads, bluesky, linkedin, mastodon.
publishedbooleanNotrue publishes now. Default false. publish_now also works.
scheduledForstring | nullNoISO 8601 time with a timezone offset. Octopost doesn't reject past times: a past time publishes on the next scheduler run.
queueStatus"queued" | "pending" | nullNoSee the table above.
mediaUrlsarrayNoPublic URLs of images or video for the main post. Each item is a URL string or { "url": "...", "altText": "..." } (alt and alt_text work too; alt text up to 1,000 characters; strings and objects can be mixed). Alt text is sent to X, Bluesky, Mastodon, LinkedIn and Threads. For a local file, get a URL from POST /api/media-uploads.
allowDuplicatebooleanNoAPI keys only: the same text to the same network within 7 days is refused with 409 unless this is true. allow_duplicate also works.
segmentsarrayNoUp to 24 thread replies, each { "content": "...", "mediaUrls": [...] }. See Threads.
variantsobjectNoA different version for some networks: { "linkedin": { "content": "..." } }. See Different text per network.
pollobjectNo{ "options": ["A", "B"], "durationMinutes": 1440, "multipleChoice": false, "hideTotals": false }. 2 to 4 options of up to 25 characters, 5 to 10,080 minutes. Only twitter and mastodon support polls: any other network in platforms gets a 400.
accountsobject or stringNoWhich connected account to post as when you have more than one on a network: { "twitter": "@handle" }, or one string applied to every network it matches. Values can be the handle, the Octopost account id, or the network's own account id. Leave it out to use the API key's default account, otherwise the network's default (first connected) account.
mastodonVisibilitystringNopublic (default), unlisted, private or direct.
contentWarningstringNoMastodon content warning, up to 500 characters.
firstCommentstringNoPosted right after the post (after the last part of a thread): a reply on X, Bluesky, Mastodon and Threads, a comment on LinkedIn. Good for the link or the hashtags. Each network's limit applies (X 280 with links as 23, Bluesky 300, Threads 500, LinkedIn 1,250); too long returns 400. first_comment also works.
crossLinkEnabledbooleanNoPublish to primaryPlatform first, then post a link to it on the other networks. Default false.
primaryPlatformstring | nullNoThe network to publish first when cross-linking.
crossLinkTemplatestring | nullNoText for the cross-link, up to 500 characters. Variables: {platform}, {link}, {title}, {excerpt}. Default New post on {platform}: {link}.

On this endpoint (and PUT /api/posts/{id}) the JSON body also accepts snake_case names: scheduled_for, queue_status, media_urls, mastodon_visibility, content_warning, cross_link_enabled, primary_platform, cross_link_template, publish_now, and segments[].media_urls. You can also send media as "media": { "images": [{ "url": "...", "alt_text": "..." }], "video": { "url": "..." } }, which Octopost turns into mediaUrls. When both forms are sent, camelCase wins.

Response: draft or scheduled (201)#

{
  "success": true,
  "post": {
    "id": "6f1c2d3e-...",
    "content": "New blog post: Building a cross-posting API",
    "platforms": ["twitter", "bluesky"],
    "scheduledFor": "2026-11-10T14:00:00.000Z",
    "published": false,
    "mediaUrls": [{ "url": "https://example.com/header.png", "altText": "Blog header" }],
    "poll": null
  },
  "warnings": ["No connected accounts for: bluesky. Posts to these platforms will fail unless you connect them first."]
}

warnings is left out when there is nothing to warn about. It lists networks with no connected account and accounts that need to be reconnected. The post is saved either way, so you can connect the account before it goes out.

Response: queued or pending (201)#

{
  "success": true,
  "post": {
    "id": "6f1c2d3e-...",
    "content": "Tip of the day: ...",
    "platforms": ["twitter"],
    "queueStatus": "pending"
  }
}

Response: published now (201, 207 or 202)#

With published: true the request waits up to about 20 seconds for every network, then answers:

HTTP statusstatusMeaning
201publishedLive on every network. success: true.
207partialLive on some networks. success: false.
207failedLive nowhere. success: false.
202publishingStill publishing after about 20 seconds. It carries on without you; poll GET /api/posts/{id}. See slow publishes.
{
  "success": false,
  "status": "partial",
  "summary": "Published to X. Failed: Bluesky: Bluesky session expired. Reconnect the account.. Fix the cause above, then call retry_failed.",
  "post": {
    "id": "6f1c2d3e-...",
    "content": "Shipping v2 today.",
    "platforms": ["twitter", "bluesky"],
    "published": true,
    "status": "partial",
    "platformPostIds": {
      "twitter": { "id": "1234567890", "url": "https://x.com/you/status/1234567890" },
      "bluesky": { "id": "", "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
    }
  },
  "results": {
    "twitter": { "platform": "twitter", "status": "published", "postId": "1234567890", "url": "https://x.com/you/status/1234567890" },
    "bluesky": { "platform": "bluesky", "status": "failed", "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
  },
  "publishResults": [
    { "platform": "Twitter", "success": true, "postId": "1234567890", "postUrl": "https://x.com/you/status/1234567890" },
    { "platform": "Bluesky", "success": false, "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
  ],
  "warnings": ["Failed to publish to: bluesky (Bluesky session expired. Reconnect the account.)"]
}

Things to know about this response:

  • Read status and results. summary says the same in a sentence (it names the MCP tool retry_failed; over REST that is POST /api/posts/{id}/retry).
  • publishResults is the raw list for this attempt, kept for older clients; its platform is a display name ("Twitter").
  • If you are over your plan's monthly X allowance, X shows up as a failed result with "errorCode": "PLAN_LIMIT", not as an HTTP error.
  • A post that isn't fully live gets a failed post entry. Every outcome sends a webhook (post.published, post.partial_success or post.failed).
  • 409 { "success": false, "error": "...", "post": { "id" } }: the post was already being published by another request.
  • If publishing crashes outright you get 500 with "status": "failed", "post": { "id", "content", "published": false } and a warning that the post was saved but not published. Retry it with /retry.

Slow publishes: 202 publishing#

Most publishes finish in a few seconds. Some take minutes: a Threads post with several images or a video can take 4 minutes or more, and most HTTP clients give up long before that. So a publish that is still running after about 20 seconds answers early instead of making you wait. This applies to POST /api/posts with published: true, POST /api/posts/{id}/publish and POST /api/posts/{id}/retry:

HTTP/1.1 202 Accepted
Location: /api/posts/6f1c2d3e-...
Retry-After: 15
{
  "success": true,
  "status": "publishing",
  "postId": "6f1c2d3e-...",
  "summary": "Still publishing: some networks take a few minutes (Threads with media can take several). It carries on without you. Poll GET /api/posts/6f1c2d3e-... every 15 seconds or so until status is published, partial or failed. ...",
  "post": { "id": "6f1c2d3e-...", "content": "Shipping v2 today.", "platforms": ["twitter", "threads"], "published": false, "status": "publishing", "platformPostIds": {} },
  "results": {
    "twitter": { "platform": "twitter", "status": "published", "postId": "1234567890", "url": "https://x.com/you/status/1234567890" },
    "threads": { "platform": "threads", "status": "pending" }
  }
}

results shows what is already live at that moment (here X is done and Threads is still uploading). /retry adds "retried": [...].

The publish keeps going on Octopost's side. Poll the post until status is no longer publishing:

while :; do
  status=$(curl -s https://octopost.ink/api/posts/6f1c2d3e-... \
    -H "Authorization: Bearer op_your_api_key_here" | jq -r .post.status)
  [ "$status" != "publishing" ] && break
  sleep 15
done
echo "$status"   # published, partial or failed; `results` says why

Don't send the publish again while it's publishing. A new create without the same Idempotency-Key makes a second post, and a second /publish or /retry on the same post gets 409 already_publishing. A repeat create with the same Idempotency-Key is safe: it answers 202 with the same post while it's still publishing, and 200 with the final result after that. You also get the result as a webhook (post.published, post.partial_success or post.failed) when it finishes.

A 202 with status: "processing" is different: the network (TikTok) accepted the post and hasn't said how it ended. Poll it the same way.

Idempotency-Key#

Send an Idempotency-Key header (1 to 255 printable characters; a UUID works well) to make a create safe to repeat. If a request times out and you send it again with the same key, Octopost returns the post the first request created instead of creating (and publishing) a second one:

curl -X POST https://octopost.ink/api/posts \
  -H "Authorization: Bearer op_your_api_key_here" \
  -H "Idempotency-Key: 3b9f7e0c-2f1a-4c55-9d1e-6a0b2c4d8e10" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Shipping v2 today.", "platforms": ["twitter"], "published": true }'

A repeat answers 200 with "idempotentReplay": true and the post's current status, summary, post and results (202 while the original is still publishing). Keys are per account and kept forever. If the post a key created has since been deleted, the repeat gets 409 { "error": "Idempotency-Key already used", "details": "..." }; send a new key. An invalid key is a 400. The MCP tools take the same thing as an idempotency_key argument.

Errors#

StatusBodyWhen
400{ "error": "Invalid request", "details": [...] }The body failed validation. details is the list of issues, each with a path and message.
400{ "error": "Invalid request", "details": "Request body is not valid JSON" }The body isn't valid JSON (or multipart). An upload that is too big or not a supported type is also a 400, with the reason in details.
400{ "error": "Invalid Idempotency-Key", "details": "..." }The Idempotency-Key header is too long or has control characters.
400{ "error": "Poll validation failed", "details": "..." }A poll was sent with a network other than twitter or mastodon.
400{ "error": "Invalid accounts", "details": "..." }accounts names an account you haven't connected.
403{ "error": "...", "code": "trial_expired" }No active plan.
403{ "error": "Please verify your email address before creating posts" }Email not verified.
409{ "error": "...", "code": "duplicate", "duplicateOf": "<post id>" }API key: the same text went to one of these networks in the last 7 days. Send allowDuplicate: true to post it anyway.
409{ "error": "Idempotency-Key already used", ... }See above.
429{ "error": "...", "code": "daily_cap" }API key: this key reached the daily post limit set on the API keys page. Nothing was saved.

Guardrails#

Requests made with an API key (not posts made in the dashboard) go through the account's guardrails:

  • Daily post limit per key, set on the API keys page: 429 with code: daily_cap once reached (counted over the last 24 hours).
  • Blocked terms, set in Settings: a post containing one is saved as pending approval instead of being published, scheduled or queued. The response is 201 with "held": true, "status": "pending_approval" and "heldReason"; nothing goes out until a person approves it (Approvals: approval email, Approve / Reject; auto-approve rules don't override a blocked term).
  • Duplicates: the same text (case and spacing ignored) to the same network within 7 days gets 409 with code: duplicate, unless allowDuplicate is true.

POST /api/posts/preview reports all three before you create anything.

Example: schedule a post#

curl -X POST https://octopost.ink/api/posts \
  -H "Authorization: Bearer op_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "New blog post: Building a cross-posting API",
    "platforms": ["twitter"],
    "scheduledFor": "2026-11-10T09:00:00-05:00",
    "mediaUrls": [{ "url": "https://example.com/header.png", "altText": "Blog header" }]
  }'

Threads#

Add segments to turn a post into a thread. content is the first post and each segment becomes the next reply. The thread is stored as one post and publishes as one unit.

  • Reply chain: X, Bluesky and Mastodon (live), Threads (after Meta approval). Each segment replies to the one before it.
  • Comments: LinkedIn (live). The main post goes up, then each segment is added as a comment.
curl -X POST https://octopost.ink/api/posts \
  -H "Authorization: Bearer op_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "I just shipped version 2 of my app.",
    "platforms": ["twitter"],
    "published": true,
    "segments": [
      { "content": "https://example.com/v2" }
    ]
  }'

This posts the announcement, then the link as a reply, so the main post isn't down-ranked for carrying a link.

Each segment is { "content": "1 to 5,000 characters", "mediaUrls": [...] } and can carry its own media. Omit segments or send [] for a single post. Every segment still has to fit each network's limit (280 on X).

Different text per network#

variants gives some networks their own version of the post. Keys are network ids; every network you leave out gets the main post.

curl -X POST https://octopost.ink/api/posts \
  -H "Authorization: Bearer op_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "We just shipped v2: faster sync, offline mode and a new editor.",
    "platforms": ["twitter", "bluesky", "linkedin"],
    "published": true,
    "variants": {
      "linkedin": {
        "content": "Today we shipped v2. Here is what changed and why we built it..."
      },
      "twitter": {
        "content": "v2 is out: faster sync, offline mode, a new editor.",
        "segments": [{ "content": "Release notes: https://example.com/v2" }]
      }
    }
  }'

Bluesky gets the main text. LinkedIn gets the longer text. X gets a short post with the link as a reply.

FieldDescription
contentText for this network instead of content.
segmentsThread replies for this network instead of segments. [] posts a single post there.
mediaUrlsMedia for this network instead of mediaUrls (same item format). [] posts no media there.
instagramPostTypeInstagram only: feed (default; several images become a carousel, one video a Reel), carousel (2 to 20 items), story (each item is its own story, in order; no caption, and the main post's segments aren't sent) or reel (one video). Also accepted as instagram_post_type.
  • Each field is optional. A field you leave out comes from the main post, so { "content": "..." } keeps the main media and replies.
  • Every network in variants must also be in platforms; otherwise you get 400 { "error": "Invalid variants", "details": "..." }.
  • Each version is checked against its own network's limit before anything is saved (400 with the count).
  • Duplicate detection compares the text each network gets, and blocked terms are checked in every version.
  • With crossLinkEnabled, only the primary network's version is used; the other networks get the cross-link text.
  • Snake_case works too: media_urls and segments[].media_urls inside a variant.
  • In results and webhooks, a network that got its own version has variant: true. Webhook content is still the main text.

Uploading files#

Instead of public URLs you can upload files with multipart/form-data:

curl -X POST https://octopost.ink/api/posts \
  -H "Authorization: Bearer op_your_api_key_here" \
  -F 'content=Launch day' \
  -F 'platforms=["twitter"]' \
  -F 'published=true' \
  -F 'altTexts=["The new dashboard"]' \
  -F 'media_0=@./screenshot.png'

How Octopost reads a multipart body:

  • Text fields are plain form values. The same snake_case aliases as JSON work (scheduled_for, queue_status, publish_now, ...).
  • platforms, poll, segments and accounts are JSON-encoded strings. published, publish_now and crossLinkEnabled are "true" or "false". Empty values count as not sent.
  • Files for the main post go in fields named media_0, media_1, and so on, in order. altTexts is a JSON array of alt texts matched by the same index.
  • Files for thread replies go in segment_<segment index>_media_<file index> (for example segment_0_media_0 for the first reply). Inside the segments JSON, each segment can carry altTexts (array) and existingMediaUrls (URLs to keep).
  • Allowed types: JPEG, PNG, GIF, WebP, MP4, QuickTime (MOV) and WebM, up to 20 MB per file. The type is checked from the file's bytes, not its name. Octopost stores uploads and serves them from https://octopost.ink/uploads/posts/....
  • A file that is too big or not a supported type fails the request with 400 and the reason in details.

Upload URLs for local files#

When you can't send multipart (agents, MCP, scripts that build JSON), get a one-time upload URL, send the file's bytes to it, then use the returned media_url like any public URL:

curl -X POST https://octopost.ink/api/media-uploads \
  -H "Authorization: Bearer op_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"filename": "screenshot.png"}'
# {"success": true, "upload_url": "https://octopost.ink/api/media-uploads/up_...", "method": "PUT",
#  "expires_at": "...", "max_bytes": 26214400, ...}

curl -T ./screenshot.png "https://octopost.ink/api/media-uploads/up_..."
# 201 {"media_url": "https://octopost.ink/uploads/posts/<you>/1762...-ab12.png", "type": "image/png", "kind": "image", "size": 48213}
  • The upload URL works for one file and 15 minutes, and needs no Authorization header (the token in the URL is the credential). PUT or POST the raw bytes as the body, not a form.
  • The type is read from the file's bytes: JPEG, PNG, GIF, WebP, MP4, MOV or WebM. Anything else gets 415 and the URL can be used again with the right file.
  • Size limit by plan (max_bytes): 25 MB (trial and Basic), 50 MB (Premium), 100 MB (Business). Too big gets 413. Send size (bytes) when creating the URL to check up front.
  • A used URL returns 410, an expired one 410, an unknown one 404. The MCP create_upload tool returns the same URL.

Preview a post#

POST /api/posts/preview takes the same body as Create a post and saves nothing. Use it to check a post before sending it.

{
  "success": true,
  "ok": false,
  "would": "be refused",
  "summary": "Not ready: 1 problem(s). Fix them before calling create_post.",
  "problems": ["segments[0] is 290 characters on X; the limit is 280. Shorten it or split it (split_thread)."],
  "warnings": ["1 image(s) in content have no alt text. Add {url, alt} so people using screen readers know what they show."],
  "characters": { "twitter": { "limit": 280, "counting": "Weighted: links count as 23, ...", "parts": [41, 290], "fits": false } },
  "x": { "units": 14, "perPart": [13, 1], "remainingThisMonth": 486, "limit": 500, "resetsAt": "2026-12-01T00:00:00.000Z" },
  "media": [{ "part": "content", "items": [{ "url": "https://...", "kind": "image", "alt": false }] }],
  "postingAs": { "twitter": { "accountId": "…", "name": "yourhandle", "needsReconnect": false } },
  "guardrails": { "dailyPostLimit": { "limit": null, "used": 0, "remaining": null }, "duplicateOf": null, "heldFor": [] }
}

would is publish, be held for approval (a blocked term) or be refused. characters.<network>.parts has one count per part (main post, then each segment) under that network's rules. x appears when twitter is in platforms. With variants, a network that has its own version is counted and checked with that version and its characters entry has variant: true.


List posts#

GET /api/posts

Newest first (by createdAt, then id).

Query parameterDescription
limitOptional, 1 to 100 (larger values are capped at 100). Returns one page plus a cursor for the next. Without limit every matching post comes back in one response.
cursorThe pagination.nextCursor from the previous page. An unreadable cursor is a 400.
statusOptional filter: draft, scheduled, queued, pending (waiting for approval), publishing, retrying, published, partial, failed or needs_review. Any other value is ignored.
curl "https://octopost.ink/api/posts?status=failed&limit=20" \
  -H "Authorization: Bearer op_your_api_key_here"
{
  "success": true,
  "posts": [
    {
      "id": "6f1c2d3e-...",
      "content": "Tip of the day: ...",
      "platforms": ["twitter"],
      "scheduledFor": null,
      "published": false,
      "status": "failed",
      "results": {
        "twitter": { "platform": "twitter", "status": "failed", "error": "X rejected this as a duplicate of a recent post.", "errorCode": "DUPLICATE_CONTENT", "retryable": false }
      },
      "lastError": "twitter: X rejected this as a duplicate of a recent post.",
      "platformPostIds": { "twitter": { "id": "", "error": "X rejected this as a duplicate of a recent post.", "errorCode": "DUPLICATE_CONTENT" } },
      "queueStatus": null,
      "queuePosition": null,
      "mediaUrls": null,
      "segments": null,
      "createdAt": "2026-11-04T15:00:00.000Z",
      "updatedAt": "2026-11-04T15:00:02.000Z"
    }
  ],
  "pagination": { "limit": 20, "hasMore": true, "nextCursor": "WyIyMDI2LTExLTA0VDE1OjAwOjAwLjAwMFoiLCI2ZjFjMmQzZSJd" }
}

To page through everything, keep calling with cursor=<nextCursor> until hasMore is false (nextCursor is then null). Without limit, pagination is { "limit": null, "hasMore": false, "nextCursor": null }. Each post has the same status and results as the post object.

To find posts by date, use the calendar.


Get a post#

GET /api/posts/{id}

Returns { "success": true, "post": { ... } } with the full post object. 404 with { "error": "Post not found" } if there is no such post in your account.


Update a post#

PUT /api/posts/{id}

Changes an unpublished post. Send only the fields you want to change. A published post returns 400 with { "error": "Cannot edit published posts" }.

FieldTypeDescription
contentstring1 to 5,000 characters.
platformsstring[]Replaces the network list.
scheduledForstring | nullISO 8601 with a timezone offset. null unschedules it. Leave it out to keep the current time. A new time must be in the future (400 with code: "scheduled_in_past"); a post waiting for approval stays held when it moves.
firstCommentstring | nullReplaces or removes (null or "") the first comment.
mediaUrlsarrayReplaces the main post's media.
pollobject | nullReplaces or removes the poll.
mastodonVisibilitystringpublic, unlisted, private or direct.
contentWarningstring | nullMastodon content warning.
queueStatus"queued" | "pending" | nullMoves the post into or out of the queue. Moving it in gives it a place at the end.
segmentsarray | nullReplaces the thread replies. [] or null makes it a single post.
variantsobject | nullReplaces every per-network version. null or {} removes them. Leave it out to keep them; removing a network from platforms also removes its version.

Other fields (accounts, cross-link settings) can't be changed here and are ignored. The JSON body accepts the same snake_case aliases as create. Multipart works too: send existingMediaUrls (a JSON array of URLs to keep) and new media_N files; if you send neither, the media is left alone.

{
  "success": true,
  "post": {
    "id": "6f1c2d3e-...",
    "content": "Updated text",
    "platforms": ["twitter"],
    "scheduledFor": "2026-11-10T14:00:00.000Z",
    "published": false,
    "mediaUrls": null,
    "poll": null,
    "mastodonVisibility": "public",
    "contentWarning": null,
    "createdAt": "2026-11-04T15:00:00.000Z",
    "updatedAt": "2026-11-04T15:10:00.000Z"
  }
}

Errors: 400 { "error": "Invalid request", "details": [...] } for validation, 404 { "error": "Post not found" }.


Delete a post#

DELETE /api/posts/{id}

Deletes the post from Octopost. If it was published, Octopost also deletes it from the networks, every part of a thread included. Add ?deleteFromPlatforms=false to keep the published copies.

Deleting from the network works on X, Bluesky, LinkedIn and Mastodon. Threads posts can't be deleted through the API and stay up.

{
  "success": true,
  "message": "Post deleted",
  "platformDeletions": {
    "twitter": { "deleted": true, "url": "https://x.com/you/status/1234567890" },
    "threads": { "deleted": false, "error": "Platform does not support API deletion", "url": "https://www.threads.net/@you/post/abc" }
  }
}

platformDeletions is only there when Octopost tried to delete published copies. Networks that can't delete through their API (Threads, for example) stay up there and the post is still removed from Octopost, with the reason in platformDeletions. If a network refuses or times out, you get 502 with success: false and the post stays in Octopost so you can try again; to remove only Octopost's copy, use ?deleteFromPlatforms=false.


Publish a post#

POST /api/posts/{id}/publish

Publishes a post that isn't fully live (draft, scheduled, queued, partial or failed) right now; networks already live are not posted again. A post waiting for approval is refused with 409 until a person approves it, and an API key can't publish a post a person rejected. On accounts that hold every agent post, an API key's publish holds the post instead (202, status: "pending_approval").

The request takes no body. It needs an active plan (403 with "code": "trial_expired" if not) and counts against the 50-per-hour publish limit. An optional Idempotency-Key header makes it safe to repeat (see below).

Default: one JSON response#

The request waits until every network has finished (up to about 20 seconds; after that it answers 202 publishing and you poll the post), then returns:

{
  "success": true,
  "status": "partial",
  "message": "Post published to 1/2 platform(s). Published to X. Failed: Bluesky: Bluesky session expired. Reconnect the account.. Fix the cause above, then call retry_failed.",
  "post": { "id": "6f1c2d3e-...", "content": "Shipping v2 today.", "platforms": ["twitter", "bluesky"], "published": true },
  "results": {
    "twitter": { "platform": "twitter", "status": "published", "postId": "1234567890", "url": "https://x.com/you/status/1234567890" },
    "bluesky": { "platform": "bluesky", "status": "failed", "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
  },
  "platformResults": [
    { "platform": "Twitter", "success": true, "postId": "1234567890", "postUrl": "https://x.com/you/status/1234567890" },
    { "platform": "Bluesky", "success": false, "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED", "retryable": false }
  ],
  "failedPlatforms": ["bluesky"]
}
HTTP statusstatusMeaning
200publishedLive on every network.
207partialLive on some networks. success is true (something went out); failedPlatforms lists the rest (lowercase ids).
207failedLive nowhere. success is false.
202publishingStill publishing after about 20 seconds; it carries on. Poll GET /api/posts/{id}. See slow publishes.
409{ "error": "...", "code": "already_publishing" }: another request or the scheduler is publishing it right now. code can also be precondition (the post changed first).

results covers every network of the post. Networks that were already live are not posted again; they show as published. platformResults is the raw list for this attempt only, kept for older clients.

Publishing a thread with several videos can take minutes. Either poll after a 202, or use the stream below, which stays open and reports each network as it finishes.

Idempotency-Key#

Send Idempotency-Key: <1-255 printable characters> to make the call safe to repeat. A repeat with the same key doesn't publish again: it returns the post's current state with "idempotentReplay": true (200 when it's published, 202 while it's still publishing, otherwise 207). A key already used for a different request (another post, or a create) is a 422.

Streaming progress: ?stream=1#

POST /api/posts/{id}/publish?stream=1 returns text/event-stream and sends one event per step as data: <json> lines:

EventFieldsWhen
startedplatforms (lowercase ids)Right away.
platform_startplatformA network starts publishing.
platform_doneplatform, success, postUrl?, error?A network finished.
completeoutcome (the same object as the JSON response above)Last event when publishing finished.
errormessage, code?Last event if publishing couldn't start (already_publishing) or crashed.
curl -N -X POST "https://octopost.ink/api/posts/6f1c2d3e-.../publish?stream=1" \
  -H "Authorization: Bearer op_your_api_key_here"
data: {"type":"started","platforms":["twitter","bluesky"]}

data: {"type":"platform_start","platform":"twitter"}

data: {"type":"platform_done","platform":"twitter","success":true,"postUrl":"https://x.com/you/status/1234567890"}

data: {"type":"platform_start","platform":"bluesky"}

data: {"type":"platform_done","platform":"bluesky","success":false,"error":"Bluesky session expired. Reconnect the account."}

data: {"type":"complete","outcome":{"success":true,"status":"partial", ...}}

The stream always ends with complete or error. The HTTP status is 200 once streaming starts, so read outcome.status.

Errors (before publishing starts)#

StatusBody
400{ "error": "Post is already published" }
400{ "error": "..." } (invalid Idempotency-Key)
403{ "error": "...", "code": "trial_expired" }
404{ "error": "Post not found" }
422{ "error": "This Idempotency-Key was already used for another request ..." }

This endpoint sends webhooks: post.published when every network succeeded, post.partial_success when some failed, post.failed when all failed. Anything not live also gets a failed post entry.


Retry failed networks#

POST /api/posts/{id}/retry

Publishes the post again on every network that isn't live yet: failed networks, and threads that stopped part way (they resume at the part that failed; live parts are never posted again). Networks that are already live are skipped, except to post a first comment that failed there.

Optional body to retry only some networks (lowercase ids):

{ "platforms": ["bluesky"] }

No body, or a body that isn't valid JSON, retries everything that isn't live.

Response (same status, summary and results as create):

{
  "success": true,
  "status": "published",
  "summary": "Published to X, Bluesky.",
  "results": {
    "twitter": { "platform": "twitter", "status": "published", "postId": "1234567890", "url": "https://x.com/you/status/1234567890" },
    "bluesky": { "platform": "bluesky", "status": "published", "postId": "at://...", "url": "https://bsky.app/profile/you/post/abc" }
  },
  "retryResults": [
    { "platform": "Bluesky", "success": true, "postId": "at://...", "postUrl": "https://bsky.app/profile/you/post/abc" }
  ],
  "platformPostIds": {
    "twitter": { "id": "1234567890", "url": "https://x.com/you/status/1234567890" },
    "bluesky": { "id": "at://...", "url": "https://bsky.app/profile/you/post/abc" }
  }
}
StatusBody
200Everything is live now.
207Something is still not live: status is partial or failed (success is false only when nothing is live).
202status: "publishing": the retry is still running after about 20 seconds and carries on. Poll GET /api/posts/{id}; see slow publishes.
400{ "error": "No failed platforms to retry" }
400{ "error": "None of the specified platforms have failures to retry" }
404{ "error": "Post not found" }
409{ "error": "...", "code": "already_publishing" } (or precondition)

Retries send webhooks and update the post's failed post entry (resolved once everything is live).


Approval#

POST /api/posts/{id}/approve

Approves a post with queueStatus: "pending". A post without scheduledFor moves into the queue (queueStatus: "queued"). A scheduled post has its hold removed (queueStatus: null) and publishes at scheduledFor.

{
  "success": true,
  "post": { "id": "6f1c2d3e-...", "queueStatus": "queued", "scheduledFor": null }
}

Optional JSON body { "content": "..." } edits the text before approving (dashboard only). The response includes approvedBy.

Who can approve: the dashboard always. An API key only when the account owner turned on "Let agents approve posts made by a different agent" in Settings, and never for a post that key made; otherwise 403 with a message to pass on to the user. 409 { "error": "Post is not pending approval" } if it isn't pending. 404 if there's no such post.

While a post is pending, PUT /api/posts/{id} can't set queueStatus: "queued" (409); approve it instead. If an API key changes the text, networks, media or thread of an approved post that hasn't published yet, the post goes back to pending.

POST /api/posts/{id}/reject

Rejects a pending post (dashboard only; API keys get 403). Optional JSON body { "note": "..." }. The post becomes a draft: its schedule and queue place are cleared, and an API key can't queue, schedule or publish it again (409). It sends the post.rejected webhook.

People can also approve from the email Octopost sends when posts are waiting. See Approvals for the emails, approver seats and auto-approve rules.


The queue#

The queue publishes one post at a time on a fixed interval, inside the hours you choose. Posts get in with queueStatus: "queued" on create or update, by approval, or with the endpoint below. When a queued post's turn comes, Octopost publishes it and takes it out of the queue. A temporary network failure is retried automatically (the post shows status: "retrying", up to 3 times, about 1, 5 and 15 minutes apart). Queue publishes send webhooks and create failed post entries like every other publish; check the post's status and results afterwards.

Add or remove a post#

POST /api/posts/{id}/queue
DELETE /api/posts/{id}/queue

POST adds a draft to the end of the queue. It returns 400 if the post is published ("Cannot enqueue a published post"), scheduled ("Cannot enqueue a scheduled post") or already queued or pending ("Post is already in the queue").

{ "success": true, "post": { "id": "6f1c2d3e-...", "queueStatus": "queued", "queuePosition": 1762268400000 } }

DELETE takes a queued or pending post out of the queue (queueStatus back to null). An unscheduled post becomes a draft; a scheduled one stays scheduled. 400 { "error": "Post is not in the queue" } otherwise.

{ "success": true, "post": { "id": "6f1c2d3e-...", "queueStatus": null, "queuePosition": null } }

Reorder#

PUT /api/posts/queue/reorder
{ "postIds": ["id-to-go-first", "id-to-go-second"] }

Gives each listed post the position of its index (0, 1, 2, ...). Every id must be one of your posts with queueStatus: "queued". Posts you leave out keep their old position, which is usually a large number, so they end up after the ones you list.

Response: { "success": true }. Errors: 400 { "error": "Invalid request", "details": [...] }, 400 { "error": "Post <id> is not queued" }, 404 { "error": "Post not found: <id>" }.

Upcoming publish times#

GET /api/posts/queue/publish-times

Estimates when each queued post will go out, given your queue settings:

{
  "success": true,
  "publishTimes": [
    { "postId": "6f1c2d3e-...", "publishAt": "2026-11-04T18:00:00.000Z" },
    { "postId": "7a8b9c0d-...", "publishAt": "2026-11-04T22:00:00.000Z" }
  ],
  "timezone": "America/New_York"
}

If the queue is turned off (or never set up), publishTimes is [].

Queue settings#

GET /api/queue-settings
PUT /api/queue-settings

GET returns your settings, creating the defaults on first call:

{
  "success": true,
  "settings": {
    "id": "...",
    "userId": "...",
    "enabled": true,
    "intervalHours": 4,
    "startHour": 9,
    "endHour": 20,
    "timezone": "America/New_York",
    "lastPublishAt": null,
    "nextPublishAt": "2026-11-04T18:00:00.000Z",
    "createdAt": "...",
    "updatedAt": "..."
  }
}

PUT takes any of these and returns the same shape:

FieldTypeDescription
enabledbooleanTurn the queue on or off.
intervalHoursinteger1 to 24 hours between posts. Default 4.
startHourinteger0 to 23. First hour (in timezone) a queued post can go out. Default 9.
endHourinteger0 to 23, must be greater than startHour. Posts go out before this hour. Default 20.
timezonestringIANA name such as America/Chicago. Default America/New_York.

Errors: 400 { "error": "Invalid request", "details": [...] }, 400 { "error": "endHour must be greater than startHour" }.


Calendar#

GET /api/posts/calendar?start=2026-11-01&end=2026-11-30

Returns posts scheduled between start and end (inclusive), plus unscheduled posts created in that range. Both parameters are required and take any ISO 8601 date or time.

{
  "success": true,
  "posts": [
    {
      "id": "6f1c2d3e-...",
      "title": "New blog post: Building a cross-posting API",
      "content": "New blog post: Building a cross-posting API",
      "start": "2026-11-10T14:00:00.000Z",
      "end": "2026-11-10T14:00:00.000Z",
      "platforms": ["twitter"],
      "status": "scheduled",
      "color": "#3b82f6",
      "published": false,
      "scheduledFor": "2026-11-10T14:00:00.000Z",
      "createdAt": "2026-11-04T15:00:00.000Z",
      "mediaUrls": [],
      "poll": null
    }
  ],
  "meta": { "start": "2026-11-01T00:00:00.000Z", "end": "2026-11-30T00:00:00.000Z", "count": 1 }
}
  • title is the first 50 characters of content.
  • start and end are both scheduledFor, or createdAt for unscheduled posts.
  • status is published, scheduled (time in the future), pending (time has passed but it isn't published yet) or draft. Here pending means overdue, not waiting for approval.

Errors: 400 { "error": "start and end dates are required" }, 400 for a date that can't be read.


Import posts from a CSV#

Upload many posts at once. Every row is checked before anything is saved: if any row has a problem, nothing is imported and you get every problem with its row number. The import is all or nothing.

# Download the template
curl -o template.csv https://octopost.ink/api/posts/import -H "Authorization: Bearer op_..."

# Check a file without saving anything
curl -X POST "https://octopost.ink/api/posts/import?dry_run=1" \
  -H "Authorization: Bearer op_..." -H "Content-Type: text/csv" --data-binary @posts.csv

# Import it
curl -X POST https://octopost.ink/api/posts/import \
  -H "Authorization: Bearer op_..." -H "Content-Type: text/csv" --data-binary @posts.csv

You can also send a multipart form with a file field, or JSON {"csv": "..."}. Files up to 1 MB and 200 posts.

ColumnRequiredDescription
contentyesThe post text
networksyestwitter, bluesky, threads, linkedin, mastodon. Separate several with |, spaces or ;
scheduled_fornoISO 8601 with a timezone offset, in the future (2026-12-01T09:00:00-05:00). Empty: the post joins the queue
media_urlsnoUp to 4 public image or video URLs, separated by spaces or |
content,networks,scheduled_for,media_urls
"New feature: export your posts to CSV.",twitter|bluesky,2026-12-01T09:00:00-05:00,
"A tip for Monday: batch your posts once a week.",twitter,,

201: everything was imported.

{
  "success": true,
  "imported": 2,
  "posts": [
    { "row": 2, "postId": "...", "status": "scheduled", "scheduledFor": "2026-12-01T14:00:00.000Z" },
    { "row": 3, "postId": "...", "status": "queued", "scheduledFor": null }
  ]
}

status is scheduled, queued or pending_approval (a blocked term, or your account holds agent posts for approval). With ?dry_run=1 the answer is 200 with dryRun: true and nothing is saved.

400: nothing was imported.

{
  "success": false,
  "error": "Nothing was imported: 2 problems to fix.",
  "errors": [
    { "row": 4, "column": "networks", "message": "Octopost can't post to instagram. Use: twitter, threads, bluesky, linkedin, mastodon." },
    { "row": 7, "column": "scheduled_for", "message": "scheduled_for needs a timezone offset, ..." }
  ]
}

Rows are numbered like your spreadsheet: the header is row 1. With an API key the guardrails apply to every row: the whole file has to fit in the key's daily post limit, and text already posted to the same network in the last 7 days is refused. The same text twice in one file on the same network is refused too.

You can also import from the dashboard: Posts > Import CSV.

Bulk scheduling#

POST /api/posts/bulk/schedule

Gives up to 50 unpublished posts the same scheduled time.

{
  "postIds": ["6f1c2d3e-...", "7a8b9c0d-..."],
  "scheduledFor": "2026-11-10T09:00:00-05:00"
}

scheduledFor needs a timezone offset and must be in the future.

{
  "success": true,
  "message": "2 post(s) scheduled successfully",
  "scheduledFor": "2026-11-10T14:00:00.000Z",
  "updatedCount": 2
}
StatusBody
400{ "error": "Invalid request", "details": [...] } (no ids, more than 50, or a bad time)
400{ "error": "Scheduled time must be in the future" }
400{ "error": "Cannot schedule posts that are already published" }
404{ "error": "Some posts were not found or do not belong to you" }

Failed posts#

Every publish (REST, MCP, the scheduler, the queue and retries) that leaves a network not live records it here: one open entry per post, listing only the networks that aren't live. A later attempt that fails again updates the same entry (retryCount goes up); once everything is live the entry is resolved. Scheduled and queued posts get up to 3 automatic retries (after about 1, 5 and 15 minutes) for temporary errors before an entry is made.

List#

GET /api/failed-posts?limit=50&offset=0&includeResolved=false
Query parameterDefaultDescription
limit501 to 100.
offset0Entries to skip.
includeResolvedfalsetrue also returns entries you resolved or that succeeded on retry.
{
  "success": true,
  "failedPosts": [
    {
      "id": "f00d...",
      "originalPostId": "6f1c2d3e-...",
      "userId": "...",
      "content": "Shipping v2 today.",
      "platforms": ["bluesky"],
      "mediaUrls": null,
      "poll": null,
      "failureReason": "Partial failure - some platforms succeeded",
      "platformErrors": { "bluesky": "Bluesky session expired. Reconnect the account." },
      "retryCount": 0,
      "lastAttempt": "2026-11-04T15:00:04.000Z",
      "createdAt": "2026-11-04T15:00:04.000Z",
      "resolved": false,
      "resolvedAt": null
    }
  ],
  "unresolvedCount": 1,
  "pagination": { "limit": 50, "offset": 0, "hasMore": false }
}

Newest first. hasMore is true when this page is full. platforms and the keys of platformErrors are lowercase network ids (entries made before 2026-11-04 may use display names such as "Bluesky").

Get one#

GET /api/failed-posts/{id}

Returns { "success": true, "failedPost": { ... } }, or 404 { "error": "Failed post not found" }.

Retry or resolve#

PATCH /api/failed-posts/{id}
{ "action": "retry" }
  • retry publishes the original post again on the networks that aren't live (thread parts and options come from the post, so a thread stays a thread). It needs a verified email. The response is { "success": true, "status": "published" | "partial", "message": "...", "platformResults": [ ... ] }. When everything is live the entry is resolved. A retry where nothing went out returns 400 with the reason.
  • resolve marks the entry as handled without publishing: { "success": true, "message": "Failed post marked as resolved" }.

Any other action returns 400 { "error": "Invalid action. Use \"resolve\" or \"retry\"" }.

Delete#

DELETE /api/failed-posts/{id}

Removes the entry (the original post stays): { "success": true, "message": "Failed post deleted" }.

Related guides