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.
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:
okistruewhen the real create would answer201or207: at least one channel inacceptedand nothing inproblems.rejectionsis the same list the create would return. Fix the content and check again.problemslists reasons the whole post would be refused:plan_limit_reachedorposting_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.
cancelledlists channel ids that were stopped. Their targets becomecancelled.refusedlists the others, with their currentstatusand areason:already_dispatched(sending has started) oralready_finished(it already ended, for examplepublishedorfailed).
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
}