Docs › API reference › Posts

Posts API: create, schedule, retry, read results

Create, schedule, list, edit, cancel and retry posts, and read the per-channel result of each, including the results inbox.

Updated

Same call for every channel. Choose one and see what it accepts.

A post is one message sent to one or more channels. The API calls it a group. The result for each channel is a target. This page covers the whole life of a post. For what each status means in plain words, see Posts and results.

POST /v1/groups

Create a post

POST /v1/groups creates a post and starts sending it, or schedules it.

Always send an Idempotency-Key. A retry with the same key and request returns the original post instead of posting again. See Idempotency.

curl https://omniposts.dev/v1/groups \
  -H "Authorization: Bearer omniposts_..." \
  -H "Idempotency-Key: launch-post-1" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "We shipped version 2.",
    "targets": [
      { "channelId": "0192a3b4-0000-7000-8000-000000000001" },
      { "channelId": "0192a3b4-0000-7000-8000-000000000002", "body": "A shorter note for this channel." }
    ],
    "scheduledFor": "2026-10-20T09:30:00Z",
    "timezone": "Europe/London"
  }'

Request fields

Field Type Meaning
body string The text. Each target posts this unless it has its own body. Defaults to an empty string
mediaIds array of ids Files from Media, used by every target that has no mediaIds of its own
targets array Required in practice. One entry per channel. See below. An empty list is refused
scheduledFor time When to send, as a UTC instant. Leave it out (and leave queue out) to send as soon as possible
timezone string A time zone name such as Europe/London. It is stored and returned with the post. The send time is scheduledFor
queue boolean true puts the post in your next free posting time. See Use the queue. Do not send it with scheduledFor

Each entry in targets:

Field Type Meaning
channelId id Required. A channel from GET /v1/channels
body string Replaces the post's body for this channel only
mediaIds array of ids Replaces the post's mediaIds for this channel. Leave it out to use the post's media. Send [] for no media
settings object of strings Platform options from the provider's uiFields. See Providers

A channel listed twice is posted to once. OmniPosts checks every target against that platform's limits, so one channel can be refused while the others go ahead.

Settings for each connector

The call is the same for every channel. Only a target's settings changes shape: each connector takes its own. These are the settings each connector lists in its uiFields; Providers (GET /v1/providers) always has the current list.

Bluesky takes no settings. Leave settings out.

Setting Values Default
visibility public, unlisted (kept off public timelines) or private (followers only) public
Setting Values Default
link_preview "true" shows a preview card for the first link in a text-only post "true"
silent "true" delivers the post without a notification sound "false"
Setting Values Default
visibility PUBLIC (anyone on LinkedIn) or CONNECTIONS (your connections only) PUBLIC

Values are strings, even for a toggle. A setting the connector does not list is refused for that channel as setting_unknown, and the other channels go ahead.

{ "channelId": "BLUESKY_CHANNEL_ID" }
{
  "channelId": "MASTODON_CHANNEL_ID",
  "settings": { "visibility": "unlisted" }
}
{
  "channelId": "TELEGRAM_CHANNEL_ID",
  "settings": { "link_preview": "false", "silent": "true" }
}
{
  "channelId": "LINKEDIN_CHANNEL_ID",
  "settings": { "visibility": "CONNECTIONS" }
}

The post you get back

All of 201, 207 and 200 return the same shape: the post, plus rejections.

Post fields:

Field Meaning
status draft, scheduled, publishing, published, partial, failed or cancelled. It is worked out from the targets: partial means some channels posted and some did not
body, mediaIds As sent
scheduledFor The send time, or null
timezone As sent, or null
queued true if the queue chose the time. See below
createdAt, updatedAt Timestamps with microseconds. updatedAt is what you send back in If-Match when editing
targets One result per channel

Target fields:

Field Meaning
id, groupId, channelId, providerId Ids
status pending, preflight, queued, publishing, awaiting_platform, unknown, published, failed or cancelled. See post statuses
failure null, or a failure object explaining why this channel did not post
externalId, externalUrl The post's id and link on the platform, once published
publishedAt When it went out
attempt How many times OmniPosts has tried
body This channel's own text if you gave one, otherwise null (it posts the post's body)
{
  "id": "0192a3b4-0000-7000-8000-0000000000f1",
  "status": "scheduled",
  "body": "We shipped version 2.",
  "mediaIds": [],
  "scheduledFor": "2026-10-20T09:30:00Z",
  "timezone": "Europe/London",
  "queued": false,
  "createdAt": "2026-10-14T09:12:33.481516Z",
  "updatedAt": "2026-10-14T09:12:33.481516Z",
  "targets": [
    {
      "id": "0192a3b4-0000-7000-8000-0000000000f2",
      "groupId": "0192a3b4-0000-7000-8000-0000000000f1",
      "channelId": "0192a3b4-0000-7000-8000-000000000001",
      "providerId": "bluesky",
      "status": "pending",
      "failure": null,
      "externalId": null,
      "externalUrl": null,
      "publishedAt": null,
      "attempt": 0,
      "body": null
    }
  ],
  "rejections": []
}

The status codes

Status Meaning
201 Every channel was accepted. rejections is empty
207 The post was created, but some channels were rejected. rejections says which and why. The accepted channels go ahead
200 A replay of an earlier request with the same Idempotency-Key. The header Idempotent-Replayed: true is set
422 Nothing was created. See below

201 and 207 do not mean the post has been published. They mean it was accepted. Read the targets, or use webhooks, for the outcome.

Rejections

When a channel fails OmniPosts's checks at the door, it appears in rejections and also in targets as a failed target, so the results inbox shows why it never ran. Each rejection is {channelId, failure}:

(targets is shortened here.) The codes you can see at this stage:

Code Why
unknown_channel The channel is not connected to your organization
needs_reconnect The channel must be reconnected first
channel_switched_off, provider_removed Posting to that platform is off, or the platform is no longer supported
media_missing A mediaIds entry was not found. Upload it again
text_required, text_too_long The text is missing or over the platform's limit
media_required, too_many_images, too_many_videos, mixed_media, media_format, media_too_large, alt_text_too_long The media breaks that platform's rules
setting_unknown, setting_invalid, setting_required, setting_locked A settings value is not allowed
no_targets You sent no channels (the only rejection with a null channelId)

Limits come from Providers, so you can check before you send.

{
  "status": "scheduled",
  "targets": [ ],
  "rejections": [
    {
      "channelId": "0192a3b4-0000-7000-8000-000000000002",
      "failure": {
        "class": "USER_ERROR",
        "code": "text_too_long",
        "message": "Bluesky allows 300 characters; this post has 341. Shorten it for Bluesky.",
        "action": "FIX_CONTENT",
        "retryable": true
      }
    }
  ]
}

When nothing is created (422)

If every channel is rejected, nothing is created and you get 422:

The Idempotency-Key is not used up, so fix the problem and send again with the same key. Other 422 answers:

Code Meaning
plan_limit_reached Your plan's posts per month are used up. details.limit is posts_per_month, details.plan is the plan id
queue_empty queue: true but you have no posting times
queue_full queue: true but every posting time in the next 12 weeks is taken
{
  "error": {
    "code": "nothing_to_publish",
    "message": "Bluesky allows 300 characters; this post has 341. Shorten it for Bluesky.",
    "details": {
      "rejections": [
        { "channelId": "0192a3b4-0000-7000-8000-000000000002", "failure": { "class": "USER_ERROR", "code": "text_too_long", "message": "...", "action": "FIX_CONTENT", "retryable": false } }
      ]
    }
  }
}

Use the queue

Send "queue": true instead of scheduledFor and OmniPosts picks the earliest free posting time from your weekly schedule. See Queue to set that schedule.

The answer has queued: true and scheduledFor set to the time it chose. Sending queue: true together with scheduledFor is 400 invalid_request. If you later set a time by hand with edit, queued becomes false and the post leaves the queue.

curl https://omniposts.dev/v1/groups \
  -H "Authorization: Bearer omniposts_..." \
  -H "Idempotency-Key: queued-post-1" \
  -H "Content-Type: application/json" \
  -d '{ "body": "From the queue.", "queue": true, "targets": [{ "channelId": "CHANNEL_ID" }] }'

Threads

On platforms that support it (a provider whose thread lists maxParts: Bluesky and Mastodon today), a post can continue as a chain of replies to itself. Give the further parts in thread, in order; the post's own body and mediaIds are the first part. A target can carry its own thread to replace the post's for that channel.

Each part is checked like a post (text limit, media rules) when you create, and thread_not_supported or thread_too_long rejects the target before anything is sent. Once accepted, the first part goes out as usual and the rest follow one by one, each replying to the one before; while that happens the target's status is awaiting_platform.

The target's externalId and externalUrl are the first part's. threadState says how far the thread got:

If a part fails for good — or its outcome cannot be confirmed — the thread stops there: the first part and everything in posted are live, failure says which part and why, and the target is still published, because it is. OmniPosts never deletes a part from the platform and never re-sends one on a guess.

{
  "body": "Three things we learned shipping threads 🧵",
  "targets": [{ "channelId": "..." }],
  "thread": [
    { "body": "1. Post the first part, then reply to it — never the other way round." },
    { "body": "2. Each part gets its own stable id, so a retry can never post a part twice.", "mediaIds": ["..."] }
  ]
}
"threadState": {
  "parts": 2,
  "posted": [{ "externalId": "…", "externalUrl": "…" }, { "externalId": "…", "externalUrl": "…" }],
  "failure": null
}

POST /v1/groups/validate

Check a post before sending it

POST /v1/groups/validate takes exactly the body of Create a post and tells you what would happen, without creating anything. It runs the same checks on every channel (connected, platform switched on, account healthy, access not ending before the schedule, files present, text and settings within the platform's limits) and looks at your plan's monthly posts and your posting limit. It never consumes an Idempotency-Key and never counts against a limit.

The answer is always 200:

  • ok is true when the real create would answer 201 or 207: at least one channel in accepted and nothing in problems.
  • rejections is the same list the create would return. Fix the content and check again.
  • problems lists reasons the whole post would be refused: plan_limit_reached or posting_limit_reached.

The verdict is a snapshot: a limit can fill up, or a channel can lose its access, between the check and the create. Treat the create's own answer as final.

curl https://omniposts.dev/v1/groups/validate \
  -H "Authorization: Bearer omniposts_..." \
  -H "Content-Type: application/json" \
  -d '{ "body": "Hello", "targets": [{ "channelId": "..." }, { "channelId": "..." }] }'
{
  "ok": true,
  "accepted": [{ "channelId": "0192a3b4-...-0001", "providerId": "bluesky" }],
  "rejections": [
    { "channelId": "0192a3b4-...-0002",
      "failure": { "class": "USER_ERROR", "code": "text_too_long", "message": "...", "action": "FIX_CONTENT", "retryable": false } }
  ],
  "problems": []
}

GET /v1/groups

List posts

GET /v1/groups lists posts, newest first by their time (scheduledFor, else createdAt). It is the data for a calendar.

Query Meaning
status Only posts with this status
channel Only posts that include this channel id
from, to Posts whose time is in [from, to), as UTC instants
limit, cursor See Pagination

The answer is { "items": [post, ...], "nextCursor": ... }. Each item is a post with its targets. Items have no rejections field.

curl "https://omniposts.dev/v1/groups?status=scheduled&from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z" \
  -H "Authorization: Bearer omniposts_..."

GET /v1/groups/{id}

Get a post

GET /v1/groups/{id} returns one post with all its targets. An id from another organization is 404.

PATCH /v1/groups/{id}

Edit a post

PATCH /v1/groups/{id} changes the text and/or the time of a post that has not started sending. Send only what you want to change.

Field Meaning
body New text for the post. Channels with their own text keep it
scheduledFor New send time. Setting it takes a queued post out of the queue (queued becomes false) and frees its slot

You cannot change channels or media. Cancel the post and create a new one for that.

curl -X PATCH https://omniposts.dev/v1/groups/0192a3b4-0000-7000-8000-0000000000f1 \
  -H "Authorization: Bearer omniposts_..." \
  -H 'If-Match: "2026-10-14T09:12:33.481516Z"' \
  -H "Content-Type: application/json" \
  -d '{ "body": "We shipped version 2.0.", "scheduledFor": "2026-10-20T10:00:00Z" }'

If-Match and stale_write

If-Match protects against overwriting a change made elsewhere. Set it to the post's updatedAt, in quotes. If the post changed since then, you get 409 stale_write and details.current holds the post as it is now.

Send updatedAt back exactly as you received it. It has microsecond precision. If you read it into a JavaScript Date and write it out again it loses the last three digits and will never match. Keep it as a string. A value that is not a time gives 400 invalid_request. The header is optional; without it no check is made.

{
  "error": {
    "code": "stale_write",
    "message": "This post changed since you opened it. Reload to see the latest.",
    "details": { "current": { "id": "...", "updatedAt": "2026-10-14T09:15:02.118204Z" } }
  }
}

When edits are refused

Status and code Meaning
409 already_dispatched A channel has already started sending, so the post can no longer be edited
422 invalid_edit The new text fails a channel's rules. The message says how
404 not_found No such post

POST /v1/groups/{id}/cancel

Cancel a post

POST /v1/groups/{id}/cancel cancels every channel that has not started sending. It has no body.

  • cancelled lists channel ids that were stopped. Their targets become cancelled.
  • refused lists the others, with their current status and a reason: already_dispatched (sending has started) or already_finished (it already ended, for example published or failed).

Cancel wins only before a channel is picked up for sending. Once one has started, OmniPosts cannot call it back. A cancelled queued post frees its queue slot.

{
  "cancelled": ["0192a3b4-0000-7000-8000-000000000001"],
  "refused": [
    {
      "channelId": "0192a3b4-0000-7000-8000-000000000002",
      "status": "published",
      "reason": "already_finished"
    }
  ]
}

POST /v1/groups/{id}/retry

Retry failed channels

POST /v1/groups/{id}/retry puts the failed channels of a post back in line to be sent. It has no body and returns the post.

Only a target that is failed and whose failure.retryable is true is retried. A result OmniPosts could not confirm (UNKNOWN class) is never retried, because the post might already be on the platform. Targets that are not eligible are left alone. Look at the returned targets to see which ones were re-queued.

Retrying helps for problems that have gone away: a platform outage, or a rate limit. It does not fix a problem with the post itself. See Errors.

GET /v1/targets

The results inbox

GET /v1/targets lists results one channel at a time, across all posts. Use it to see what happened to everything you sent, or what needs attention.

Query Meaning
status Only targets with this status, for example failed
channel Only this channel
from, to Targets that last changed in [from, to)
limit, cursor See Pagination

Newest first by sent time: publishedAt, else the post's scheduledFor, else creation.

An inbox item is a target plus scheduledFor from its post. One difference from a target inside a post: here body is the text this channel posts, which is its own text if it has one, otherwise the post's.

curl "https://omniposts.dev/v1/targets?status=failed&limit=20" \
  -H "Authorization: Bearer omniposts_..."
{
  "items": [
    {
      "id": "0192a3b4-0000-7000-8000-0000000000f3",
      "groupId": "0192a3b4-0000-7000-8000-0000000000f1",
      "channelId": "0192a3b4-0000-7000-8000-000000000002",
      "providerId": "bluesky",
      "status": "failed",
      "failure": { "class": "RETRYABLE", "code": "platform_error", "message": "...", "action": "RETRY", "retryable": true },
      "externalId": null,
      "externalUrl": null,
      "publishedAt": null,
      "attempt": 3,
      "body": "A shorter note for this channel.",
      "scheduledFor": "2026-10-20T09:30:00Z"
    }
  ],
  "nextCursor": null
}