OmniSocialsOmniSocials
DocumentationAPI reference
Help centerSupport
  • Documentation
  • API reference
Developers
  • Quickstart
  • API reference
  • SDKs
  • MCP server
  • CLI
For AI agents
  • Docs for AI agents
  • llms.txt
  • OpenAPI spec
  • Postman collection
Product
  • OmniSocials
  • Pricing
  • Changelog
  • Help center
Company
  • Support
  • Privacy policy
  • Terms of service

© 2026 OmniSocials

OmniSocials on GitHubOmniSocials on XOmniSocials on LinkedIn
Overview
Get started
SDKs
CLI
MCPDocs for AI agentsWebhooks
Guides
    PostsStoriesReelsCross-postingMediaAnalyticsSocial inboxAutomations
Platforms
Integrations
Migrate
Plans and billing
GlossaryChangelogAPI reference
Guides

Create and schedule posts via API

Posts are the core resource of the OmniSocials API. A single post can target one or many connected accounts, be a feed post, story, or reel, carry media, be scheduled, and have per-platform overrides.

Minimal request

The smallest valid post is text to one account:

TerminalTerminal
curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": { "default": "Hello from the API" }, "accounts": ["your-account-id"] }'

A successful response returns the new post record with "status": "draft". Drafts are NOT published automatically. Call POST /posts/:id/publish to publish, or use POST /posts/create-and-publish to create and publish in one call.

Content

The content field accepts either a string (same text on every selected channel) or an object keyed by channel ID (per-platform text).

Flat string:

JSON
{ "content": "Same text on every channel", "accounts": ["channel-a", "channel-b"] }

Per-platform:

JSON
{ "content": { "default": "Check out our new feature", "x": "New feature just shipped 🚀", "linkedin": "We just shipped a new feature. Here's what it does..." }, "accounts": ["channel-a", "channel-b", "channel-c"] }

The default key is the fallback for channels without an explicit override. Character limits are enforced per platform. A post that exceeds any selected platform's limit fails validation.

Accounts and channels

Pass one or more account IDs in the accounts array. Get account IDs from GET /accounts. An account ID is tied to a specific channel (e.g. a specific Instagram profile), not just a platform.

In responses, accounts comes back as a map of every platform key to true or false, for example { "instagram": true, "x": false }. The channels the post targets are true.

Connecting social accounts is done through OAuth in the OmniSocials dashboard, not through the API. Once connected, they are available to every API key in the workspace.

Post types

The type field selects feed post, story, or reel. It defaults to post if omitted.

TypeDescriptionSupported platforms
postRegular feed postAll platforms
story24 hour storyInstagram, Facebook (Snapchat coming soon)
reelShort-form vertical videoInstagram, Facebook, YouTube, TikTok

Stories and reels require media. Scheduling or publishing a story or reel without media returns 400; a plain draft can be created without media and filled in later. A story takes up to 10 media items; each one is a slide, published as its own story in order (see Creating Stories).

Attaching media

There are two ways to attach media. Both can be combined in the same request.

1. Upload first, then reference by ID. Upload files through POST /media/upload or POST /media/upload-from-url, then pass the returned IDs in media_ids.

JSON
{ "content": { "default": "Check out these photos" }, "accounts": ["your-account-id"], "media_ids": ["123", "124"] }

2. Inline URLs. Pass external image, video, or PDF URLs in media_urls. The API downloads them for you and attaches them to the post. Maximum 10 URLs, each file up to 100 MB; an oversized file fails with code file_too_large. For larger files (up to 1 GB), upload first via POST /media/upload-from-url and attach the returned media id via media_ids instead. A PDF URL is rasterized into one image slide per page (up to 20) and the original file is kept: on LinkedIn it publishes as a swipeable document made from that original PDF, elsewhere as an image carousel.

JSON
{ "content": { "default": "Check out these photos" }, "accounts": ["your-account-id"], "media_urls": [ "https://example.com/photo1.jpg", "https://example.com/photo2.jpg" ] }

Per-platform media. Both media_ids and media_urls accept either a flat array (same media on every selected channel) or an object keyed by channel ID. A default key inside the object is the fallback for channels without an explicit override. Pass an empty array to opt a channel out of media.

JSON
{ "media_urls": { "default": ["https://example.com/photo.jpg"], "instagram": ["https://example.com/square.jpg"], "pinterest": ["https://example.com/tall.jpg"], "x": [] } }

Alt text (accessibility descriptions)

Alt text is a short description of an image, read aloud by screen readers and shown when an image fails to load. Some communities take it seriously (Mastodon users famously run reminder bots for posts without image descriptions), and several platforms accept it natively through their APIs. OmniSocials lets you attach it per media item and delivers it to every platform that supports it.

How to set it

Any media entry can be an object instead of a bare string:

  • In media_urls: { "url": "...", "alt": "..." }
  • In media_ids: { "id": "...", "alt": "..." }

Strings and objects can be mixed freely in the same array, and both forms work everywhere media is accepted: flat arrays, per-platform objects, and thread parts (x.thread_parts, bluesky.thread_parts, mastodon.thread_parts, threads.thread_parts).

Alt text is per image. Each media item carries its own description: a post with four images can (and should) have four distinct alt texts. Items without an alt simply publish without a description.

JSON
{ "content": { "default": "Morning ride before work" }, "accounts": ["your-account-id"], "media_urls": [ { "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" }, { "url": "https://example.com/route.jpg", "alt": "Map of a 12 km cycling route along the river" }, "https://example.com/no-alt-needed.jpg" ] }

Per-platform media objects work the same way, so a channel override can carry its own descriptions:

JSON
{ "media_urls": { "default": [{ "url": "https://example.com/photo.jpg", "alt": "A red bicycle against a brick wall" }], "mastodon": [{ "url": "https://example.com/photo.jpg", "alt": "Red city bike with a wicker basket, leaning on a weathered brick wall in morning light" }] } }

Where it goes, per platform

PlatformBehavior
MastodonEach image or video uploads with its own native media description, so 4 images means 4 distinct alts. Visible to screen readers and satisfies the community's alt-text reminder bots.
BlueskyPer-image alt in the post embed (videos too). Bluesky clients surface missing alt text, so descriptions you set here show up exactly like natively-added ones.
XOne media-metadata call per photo (JPEG, PNG or WebP; OmniSocials sends no GIFs to X). X does not support alt text on videos. A failure to set alt text never fails the publish: the post still goes out, without the description.
PinterestThe exception: Pinterest accepts only one pin-level alt_text, even for carousels. An explicit pinterest.alt_text always wins; otherwise the first media item that has an alt becomes the pin's alt text, and other slides' descriptions are ignored by Pinterest.
Instagramalt_text on image posts and carousel image slides; each slide carries its own description. Reels and Stories are not supported by Instagram. Clamped to Instagram's 1,000-character cap.
LinkedInaltText on images, both single-image and multi-image posts. Video and documents are not supported by LinkedIn.
Threadsalt_text on every image and video container: single posts, carousel items, and per-part thread media.
Everything elseCurrently ignored. Safe to send: entries with alt are accepted on every platform and the description is simply not delivered.

Special cases

  • PDF uploads: a PDF entry rasterizes into one slide per page, and every slide inherits the entry's single alt.
  • Thread posts (X, Bluesky, Mastodon, Threads): each part's media carries its own alt, so a 3-part Mastodon thread with one image per status publishes 3 independent descriptions.
  • Videos: delivered on Mastodon, Bluesky, and Threads; not supported by X, and Instagram and LinkedIn don't take video alt either (their delivery is images-only); Pinterest video pins use the pin-level alt_text.

Limits and validation

  • Maximum 1500 characters per description (Mastodon's default cap). Longer values return 400 with code validation_error.
  • Platforms with a lower cap are clamped at publish time: X and Instagram truncate at 1000 characters.
  • alt must be a string; whitespace-only values are treated as unset.

Reading it back

Post read-backs return media items as { url, id, channel, alt? }; alt is present only when set, so you can verify descriptions landed (useful when batch-creating posts through the API or an AI agent).

Link previews

To share a URL as a rich preview card (thumbnail + title + description) on platforms that support it, pass link_url on the post body. This is what LinkedIn calls an "article share" and what Facebook calls a "link post". Without it, LinkedIn and Facebook use the first URL in the post text for the card when the post has no media (and, on LinkedIn, no poll); on Facebook the URL is then removed from the text once Facebook has drawn the card.

JSON
{ "content": "Check out our latest write-up", "accounts": ["your-linkedin-account-id", "your-facebook-account-id"], "link_url": "https://example.com/blog/new-feature", "link_title": "New Feature Launch", "link_description": "What it does and why it matters." }
FieldRequiredNotes
link_urlyesMust be a valid http(s) URL. Switching the post into link-share mode.
link_titlenoUsed by LinkedIn directly. Facebook ignores it and fetches the page's OG title server-side.
link_descriptionnoSame behaviour as link_title.
link_thumbnail_urlnoLinkedIn uploads this image as the card thumbnail (without it, the page's OG image). Facebook ignores it and uses the OG image.

Supported channels. LinkedIn (linkedin, linkedin_page) and Facebook (facebook). X auto-renders preview cards from any URL in the tweet body, so link_url is not needed there. Other channels (Instagram, TikTok, etc.) ignore link_url and post the text as-is.

Media vs link share. LinkedIn and Facebook both treat media posts and link-share posts as mutually exclusive. If a request includes both media_urls/media_ids and link_url, media wins and link_url is ignored for those channels.

Video covers

When a post's media is one video, pass video_cover to choose the thumbnail. One object covers every platform; add overrides to pick something different for a single platform.

JSON
{ "content": "Launch day", "accounts": ["your-facebook-page-id", "your-linkedin-page-id", "your-tiktok-account-id"], "media_urls": ["https://example.com/launch.mp4"], "video_cover": { "type": "frame", "thumb_offset": 3000, "overrides": { "linkedin_page": { "type": "custom", "cover_url": "https://example.com/cover.jpg" } } } }
FieldRequiredNotes
typeyesframe uses the video frame at thumb_offset; custom uploads the image at cover_url.
thumb_offsetwith frameMilliseconds into the video.
cover_urlwith customPublic JPEG or PNG.
overridesnoEntries keyed by instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube. An override wins over the base cover for that platform.

Supported channels. Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. On YouTube Shorts the cover shows only on channels where YouTube has enabled custom Shorts thumbnails; other channels show a frame from the video (see Creating reels). TikTok only accepts a frame, so a custom cover is skipped there and the video's own frame is used; add a tiktok override with type: "frame" to choose which one. On Facebook, LinkedIn and YouTube the thumbnail is applied after the video is live, and a thumbnail failure never fails the post.

The older per-platform fields (instagram.thumb_offset / cover_url, tiktok.video_cover_timestamp_ms, pinterest.video_cover) keep working and win over the base cover for their platform. facebook.thumbnail_type with thumb_offset or cover_url is accepted too and stored as video_cover.overrides.facebook. GET /posts/:id returns video_cover as stored. On PATCH, video_cover replaces the whole object and null removes it.

Scheduling

To schedule a post, add schedule_at as an ISO 8601 timestamp in UTC. OmniSocials publishes within a 1 minute window of the requested time.

JSON
{ "content": { "default": "Friday update" }, "accounts": ["your-account-id"], "schedule_at": "2026-04-18T14:00:00Z" }

Scheduled posts move from draft to scheduled to published automatically. You can also schedule a post by creating a draft and calling PATCH /posts/:id to set schedule_at later.

Planned drafts

Add draft: true next to schedule_at to store the post as a draft that keeps its time. It shows on the dashboard calendar at that slot with a dashed outline, and the scheduler never publishes it. A person schedules it from the composer, or you do with PATCH /posts/:id and { "draft": false }. Use it when an agent proposes a slot that a human should confirm.

JSON
{ "content": { "default": "Proposed for Friday morning" }, "accounts": ["your-account-id"], "schedule_at": "2026-04-18T14:00:00Z", "draft": true }

The response has "status": "draft" with schedule_at set. Two moves on PATCH /posts/:id: { "draft": false } schedules the planned draft at the time it already carries (400 validation_error when it has no schedule_at or the time is in the past; pass a new schedule_at then), and { "draft": true } unschedules a scheduled post while keeping its time, so nothing publishes (a post waiting for approval leaves its workflow). draft: true cannot be combined with publish_now or approval_workflow_id (400 validation_error). Without schedule_at, draft changes nothing: that post is a draft already.

Approval workflows

If a human should review posts before they go out, route them through one of the workspace's approval workflows. Workflows (steps and approvers) are configured in the dashboard under Approvals; the API lists them and attaches one at create time.

TerminalTerminal
curl https://api.omnisocials.com/v1/approval-workflows \ -H "Authorization: Bearer omsk_live_..."

Each workflow has an id, a name, workspace_id (null for company-wide workflows), and steps with named approvers. Pass the id as approval_workflow_id together with schedule_at:

JSON
{ "content": { "default": "Friday update" }, "accounts": ["your-account-id"], "schedule_at": "2026-04-18T14:00:00Z", "approval_workflow_id": "57823504" }

The post is created with status: "in_approval" and approval_status: "pending" instead of scheduled. The first step's approvers are notified (dashboard badge, email, Slack where configured), review it on the Approvals page (they can edit the text, move the time, and comment), and once the last step approves, the post publishes at schedule_at with nothing else needed. A rejection leaves it rejected. Approvers who hold an API key can also decide with POST /posts/:id/approve or /reject. To see who a post waits for, or who rejected it and why, see Reading the review.

Rules: approval_workflow_id requires schedule_at and cannot be combined with publish_now (400 validation_error); an unknown or other-workspace id returns 404 workflow_not_found; a workflow with no approvers on its first step returns 400 validation_error.

Reading the review

approval_status on the post tells you where the review stands: none, pending, approved or rejected. It does not tell you who the post waits for, who rejected it, or why. GET /posts/:id/approval returns the review itself: every step with its approvers and their decisions, the rejection with its reason, and the comment thread. It is read-only and needs the posts:read scope.

TerminalTerminal
curl https://api.omnisocials.com/v1/posts/123456/approval \ -H "Authorization: Bearer omsk_live_..."

A rejected post answers:

JSON
{ "data": { "post_id": "123456", "status": "rejected", "workflow": { "id": "42", "name": "Content approval" }, "requested_by": { "id": "7d1f3c52-9a4e-4b6f-8c21-5e0a9d3b7f14", "name": "Alex" }, "requested_at": "2026-10-01T09:00:00.000Z", "current_step": null, "steps": [ { "order": 1, "name": "Team review", "require_mode": "any", "status": "approved", "approvers": [ { "id": "2b8e6f90-1c3d-4a57-9e84-6f2d0b1c3a75", "name": "Sam", "email": "[email protected]", "status": "approved", "decided_at": "2026-10-01T10:15:00.000Z", "comment": null } ] }, { "order": 2, "name": "Client approval", "require_mode": "all", "status": "rejected", "approvers": [ { "id": "c4a09e1d-7b52-4f38-a6d1-3e9f8b2c5d60", "name": "Jordan", "email": "[email protected]", "status": "rejected", "decided_at": "2026-10-01T14:30:00.000Z", "comment": "The image does not match the caption" } ] } ], "rejection": { "by": { "id": "c4a09e1d-7b52-4f38-a6d1-3e9f8b2c5d60", "name": "Jordan" }, "reason": "The image does not match the caption", "at": "2026-10-01T14:30:00.000Z", "step": 2 }, "comments": [ { "id": "5f3a2b1c-8d7e-4c69-b0a4-1e2d3c4b5a69", "author": { "id": "c4a09e1d-7b52-4f38-a6d1-3e9f8b2c5d60", "name": "Jordan" }, "message": "Please use the new logo", "account": "instagram", "created_at": "2026-10-01T14:28:00.000Z" } ] } }
FieldMeaning
statusnone, pending, approved or rejected. none means the post has no approval workflow: workflow, requested_by, requested_at, current_step and rejection are then null, and steps and comments are empty arrays.
workflowid and name of the workflow. id is null for a one-off approval that was not made from a saved workflow.
requested_by, requested_atWho sent the post for approval, and when.
current_stepThe order of the step the post waits on. null when the review has ended.
steps[]order, name, require_mode (all = every approver of the step must approve, any = one approval is enough), status (pending, approved, rejected) and approvers.
steps[].approvers[]id, name, email, status (pending, approved, rejected), decided_at, and comment (the reason this approver gave with a rejection). name, email, decided_at and comment can be null.
rejectionnull, or who rejected the post (by), the reason, the time (at) and the step it happened on. reason, at and step can be null.
comments[]The review thread, oldest first: id, author (id and name, or null), message, account and created_at. account is the channel the comment is about (instagram, linkedin_page, ...), or null for the whole post. The thread includes the entries OmniSocials writes when a reviewer edits the post.

Who does my post wait for? While status is pending, find the step whose order equals current_step. Its approvers with status: "pending" are the people who still have to decide, and require_mode says whether one of them is enough.

Why was my post rejected? Read rejection.by and rejection.reason, then comments for what the reviewers wrote before they decided. A comment with an account is about that channel's version of the post only.

A post that is not in the key's workspace, or an id that is not numeric, returns 404 not_found.

Staying informed during a review

  • Webhooks. Subscribe to post.approved and post.rejected to hear the outcome without polling. The payload carries the decision, the approver's id and the reason in data.approval; see Webhooks.
  • Email to the requester. A rejection, a reviewer comment, a reviewer edit and the final approval each email the person who requested the approval. A reply from the requester in the thread emails the reviewers.
  • Reminders near the publish time. A post that still waits for approval sends an automatic email to its reviewers 24 hours before schedule_at. When the time passes without a decision, the post does not publish and the reviewers and the requester get an email. A late approval publishes it at once.

After a rejection, the requester can edit the post and send it for approval again from the dashboard (Edit & Resubmit on the post).

Platform-specific options

Some platforms support fields that do not apply anywhere else (Pinterest board IDs, YouTube privacy settings, TikTok comment controls, etc.). Pass these as top-level keys on the request body, using the channel ID as the key name.

JSON
{ "content": { "default": "New pin" }, "accounts": ["your-pinterest-account-id"], "media_urls": ["https://example.com/pin.jpg"], "pinterest": { "board_id": "123456789", "title": "Spring inspiration", "link": "https://example.com/spring", "alt_text": "Pastel-toned flower bouquet on a marble table" } }

See the Platforms section for a per-platform breakdown of supported options.

Polls

X, Threads, Mastodon, Telegram and Discord take a poll next to the post text (the text is the question), under the channel's options as poll. LinkedIn polls have their own top-level field, linkedin_poll, with a question of their own.

JSON
{ "content": { "default": "Which feature should we build next?" }, "accounts": ["your-x-account-id", "your-threads-account-id", "your-mastodon-account-id"], "x": { "poll": { "options": ["Polls", "Reports", "Ads"], "duration_minutes": 1440 } }, "threads": { "poll": { "options": ["Polls", "Reports", "Ads"] } }, "mastodon": { "poll": { "options": ["Polls", "Reports", "Ads"], "expires_in": 86400, "multiple": false } }, "telegram": { "poll": { "options": ["Polls", "Reports", "Ads"], "open_period": 86400 } }, "discord": { "poll": { "options": ["Polls", "Reports", "Ads"], "duration_hours": 24 } } }
ChannelOptionsRuns forExtras
X2 to 4, up to 25 charactersduration_minutes, 5 to 10080 (7 days), default 1440
Threads2 to 4, 1 to 25 characters24 hours, set by Threads
Mastodonwithin the instance's limits (default 4 of 50 characters)expires_in in seconds, within the instance's range (default 5 minutes to 1 month), default 86400multiple, hide_totals
Telegram2 to 12, up to 100 characters (question max 300)open_period in seconds, 5 to 2628000; left out, the poll stays openmultiple
Discord2 to 10, up to 55 characters (question max 300)duration_hours, 1 to 768 (32 days), default 24multiple; no buttons with a poll

A poll and media do not go together on these five platforms: a request with a poll and media on the same channel is refused with 400 validation_error. On a thread (thread_parts) the poll goes on the first post. GET /posts/{id} returns the poll under the same key. On PATCH /posts/{id}, pass "poll": null to remove it.

Pinterest posts can also tag products: pass up to 24 product Pin IDs in pinterest.product_tags and people can shop the items from the Pin. See Pinterest product tags.

Automatic first comment

Instagram, Facebook, LinkedIn, YouTube, TikTok, and Telegram can auto-post a first comment the moment the post publishes, handy for keeping hashtags or a link out of the main caption. Set first_comment under the channel's platform key. Each channel takes its own value, so you can vary it per platform in a single request.

JSON
{ "content": { "default": "New reel is live" }, "accounts": ["your-instagram-account-id"], "type": "reel", "media_urls": ["https://example.com/reel.mp4"], "instagram": { "first_comment": "#reels #marketing\nlink: https://example.com" } }
Channel keyMax lengthNotes
instagram2200Feed posts and reels
facebook8000Page posts and reels
linkedin1250Personal profile
linkedin_page1250Company page
youtube10000Video must allow comments
tiktok150Needs comments enabled on the TikTok channel (see below); video must be public and allow comments
telegram4096A silent reply under the post in the same chat

Not posted for stories. Leave the key out, or pass an empty string, for no first comment. On PATCH /posts/:id, sending an empty string clears a previously set first comment. It is posted automatically right after the post publishes; no separate call needed.

TikTok comments go through a second TikTok authorization, the Enable comments button on the TikTok channel card under Settings -> Organisation -> Workspaces. Without it the post still publishes and first_comment_result.status is failed with an error that says so. TikTok sometimes hands back the final video id a few minutes after publishing; the comment is then posted automatically as soon as the id is known, and first_comment_result.pending is true in the meantime.

Hashtag sets

Save a group of hashtags once, then apply it to any post by name. Sets are managed with the Hashtag Sets endpoints (GET/POST /hashtag-sets, PATCH/DELETE /hashtag-sets/:id) and applied at create time:

JSON
{ "content": { "default": "New PB on the deadlift today" }, "accounts": ["instagram", "tiktok"], "hashtag_set": "Fitness Brand", "hashtag_placement": "first_comment", "schedule_at": "2026-08-01T09:00:00Z" }
FieldMeaning
hashtag_setSet name, matched case-insensitively (or hashtag_set_id)
hashtag_placementcaption_append (default) appends the tags to each caption after a blank line; first_comment puts them in the automatic first comment on channels that support it (see the table above) and falls back to the caption elsewhere
hashtag_platformsOptional subset of the post's channels, e.g. ["instagram", "tiktok"]

The tags are merged in once, when the post is created: the post stores plain text, so editing or deleting a set later never changes existing posts. Tags already present in a caption are skipped, and Instagram's 5-hashtag cap is enforced at create time with a hashtag_limit_exceeded error instead of a publish-time failure.

Comment-to-DM automation

A post to Instagram or a Facebook Page can carry a comment-to-DM automation: when somebody comments one of the keywords on the post, the account sends them a DM, once per person, and can post a public reply under the comment. On Facebook the DM lands in Messenger. Pass comment_dm_automation on create (Instagram or Facebook must be in accounts):

JSON
{ "content": { "default": "Want the free guide? Comment GUIDE below" }, "accounts": ["instagram", "facebook"], "media_ids": ["508"], "schedule_at": "2026-11-01T09:00:00Z", "comment_dm_automation": { "platforms": ["instagram", "facebook"], "trigger": { "keywords": ["guide"], "match": "word" }, "actions": { "dm": { "text": "Here is the guide: https://example.com/guide" }, "public_reply": { "enabled": true, "variations": ["Sent you a DM!", "Check your inbox"] } }, "limits": { "max_sends": 500 } } }

platforms names the channels that get an automation: ["instagram"], ["facebook"] or both. Each channel gets its own automation with the same keywords, DM, public reply and limits, and each listed channel must be in accounts (400 validation_error otherwise). Leave platforms out for one automation: on Instagram when the post goes there, else on Facebook.

Each automation starts listening once the post is live on its channel. The post comes back with comment_dm_automation: the first automation's fields (Instagram first), plus platforms and automations (the id, platform and status of each). GET /posts/:id returns it. PATCH /posts/:id replaces the setup with a new object (a channel no longer listed in platforms loses its automation) or removes every automation with null. It needs the opt-in automations:write scope on the API key. Automations on existing posts, on the next post you publish, on every post, and on DM or story-reply keywords are managed with the Automations endpoints.

Publishing

Draft posts do nothing until you publish them. Four ways to publish:

  1. Create and publish in one call. Add "publish_now": true to the /posts/create body.
  2. The same, as its own endpoint. POST /posts/create-and-publish with the same body; it is an alias for publish_now.
  3. Publish an existing draft. POST /posts/:id/publish. No body required.
  4. Schedule it. Set schedule_at on creation. The platform publishes automatically at that time.

Successful publish returns "status": "published" and populates published_urls, an object mapping each platform identifier (linkedin, instagram, etc.) to the live post URL. Only platforms that published successfully appear.

X link posts use credits

X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that fee through as prepaid credits at X's exact rate: 20 credits per URL-containing tweet, with threads billed per part that contains a link. Only a link written with http:// or https:// counts; a bare domain (omnisocials.com) or a www. link is free. Posts without links, analytics, and media on X stay free.

When a create targets X and the text (or any thread part) contains a URL, the 201 response includes a top-level warnings array as a sibling of data:

JSON
{ "data": { "id": "874321", "status": "draft" }, "warnings": [ { "code": "x_url_post_credits", "message": "This post contains 1 X tweet(s) with a link and will use 20 credits when it publishes (X's link-post fee, passed through at cost).", "credits_required": 20, "credits_balance": 140, "enforced": true, "enforce_from": "2026-08-14" } ] }

warnings is omitted when there is nothing to warn about, so integrations that only read data are unaffected. The array carries two codes: x_url_post_credits (this section) and image_fit (a feed image outside what a channel shows uncropped, see Image fit per channel); PATCH /posts/:id returns it too. Debiting has been live since 2026-08-14 (enforce_from). If the credit balance can't cover credits_required at publish time, only the X target fails: its entry in errors explains how many credits are needed, and every other selected platform publishes normally. Top up at app.omnisocials.com/credits, then use POST /posts/:id/retry to re-publish just the X target. There is no API endpoint for credits; they are managed in the dashboard.

Scheduling is gated up front. Every scheduled X link post reserves its cost until it publishes (shown as an orange "reserved" slice on the dashboard's credits page). Creating, scheduling, or publishing an X link post that would push the reserved total past the balance returns 402 x_credits_insufficient, with error.details carrying credits_required, credits_balance, and credits_reserved. Drafts are never gated; the check runs when a draft is scheduled or published.

Image fit per channel

Every channel shows a feed image in its own window. Instagram shows feed images between 4:5 (portrait) and 1.91:1 (landscape): a taller image, a 9:16 phone photo for example, is accepted but cut to 4:5 in the feed with a blind centre crop, and Meta cannot boost a feed post outside that range. Pinterest may cut a Pin taller than 2:3 in the home feed, while the Pin page shows it whole. OmniSocials decides per channel what happens to such an image, tells you in the response, and lets you override it.

ChannelShown uncroppedDefault
instagram4:5 to 1.91:1crop: the image is cropped to 4:5 (tall) or 1.91:1 (wide) around the subject before it publishes, because Instagram cuts it to that window anyway and a cropped post can be boosted
pinterest2:3 and widerkeep: the Pin goes out as it is and the response says so

Feed images only (type: "post"). Stories, reels, TikTok photos and videos are never touched, and channels that show a tall image whole (X, LinkedIn, Mastodon, Google Business) have no rule. The crop is a separate file for that channel only, hidden from the media library and deleted with its parent; default and every other channel keep the original. It is placed around the image's focus point (the subject), which can only be set in the dashboard. To use your own crop for a channel, pass it as that channel's entry in the per-platform media_urls / media_ids object instead (see Attaching media).

Override with media_fit, a top-level field on POST /posts/create, POST /posts, POST /posts/create-and-publish and PATCH /posts/:id: an object keyed by channel, each value "auto" (the default), "crop" or "keep". Any other value returns 400 validation_error.

JSON
{ "content": { "default": "Golden hour on the pier" }, "accounts": ["your-instagram-account-id", "your-pinterest-account-id"], "media_urls": ["https://example.com/pier-9x16.jpg"], "media_fit": { "instagram": "keep", "pinterest": "crop" }, "pinterest": { "board_id": "1234567890" } }

The response says what happened. Every decision is an entry in the top-level warnings array (the same array as the X credits above), on create and on PATCH. action is cropped (with the new file in crop_url), kept, or crop_failed (the original was sent and the platform cuts it itself). An image that fits produces no entry.

JSON
{ "data": { "id": "874321", "status": "scheduled" }, "warnings": [ { "code": "image_fit", "platform": "instagram", "media_url": "https://cdn.omnisocials.com/.../pier-9x16.jpg", "ratio": "9:16", "target": "4:5", "action": "cropped", "crop_url": "https://cdn.omnisocials.com/.../pier-9x16-4x5.jpg", "message": "Instagram: This image is 9:16. Instagram shows feed images between 4:5 and 1.91:1, and Meta cannot boost a feed post outside that range. It was cropped to 4:5 around the subject. To send the original, update the post with media_fit: { \"instagram\": \"keep\" }." } ] }

In the post response the channel's media item carries fit ("crop" or "keep") and, for a crop, crop: { of, id, ratio, focus } (of is the original's URL); the default entry keeps the original.

Undo or redo later. PATCH /posts/:id with media_fit alone (no media in the body) re-decides the stored images: { "instagram": "keep" } gives the original back, { "pinterest": "crop" } crops a Pin that was kept. The response carries an image_fit warning for every image that changed.

Check before you post. POST /media/check with the media_id of a library image returns a fit array with one row per connected channel that has a rule (fits, ratio, target, default_action, message). See Media.

If you build an AI agent: before posting a tall (9:16) image to Instagram, run the media check. When fits is false, ask the user whether to crop to 4:5 (recommended, Meta can boost it) or keep the original (Instagram cuts it itself, no boost), and pass the answer as media_fit. Without a preference, let the default run and relay the image_fit warning. Never drop a channel over its image ratio; the post publishes either way.

The hard limit is unchanged: Instagram feed images outside 9:16 to 1.91:1 are still rejected with 400 validation_error. Between 9:16 and 4:5 an image is cropped, not rejected, unless media_fit.instagram is keep.

Updating and deleting

PATCH /posts/:id updates any post that is not published or failed, and accepts any fields from the create body. A published post cannot be changed through the API. To change a live post, delete it on the platform and create it again.

DELETE /posts/:id removes the post from OmniSocials only: it leaves the calendar, the posts list and the analytics. It never deletes the live post on a platform, so a published post stays live everywhere it went. To take a live post down as well, use "Delete from [platform]" on the post in the OmniSocials dashboard (every platform except Instagram and TikTok, whose APIs have no delete) or delete it in the platform's own app. For scheduled posts, DELETE cancels the scheduled publish.

Listing posts

GET /posts returns the workspace's posts, newest first, with limit (max 100, default 20) and offset for paging. Filter with ?status= (any value from the table below). For content published outside OmniSocials, GET /posts/recent-platform fetches recent posts live from the connected platforms.

More create-body fields

The full field reference lives in the API reference, but three groups are easy to miss:

  • Instagram-only fields: location_id, collaborators, and user_tags sit at the top level of the create body. See Instagram and GET /locations/search for finding a location_id.
  • Threads location tag (rolling out): threads.location_id tags a place on the Threads post. Threads location ids come from GET /locations/search?platform=threads and are a different namespace from Instagram's Facebook Place IDs, so never reuse one as the other. See Threads.
  • Thread posts: x.thread_parts, bluesky.thread_parts, mastodon.thread_parts, and threads.thread_parts publish chained threads (2-25 parts, per-part media). See X, Bluesky, Mastodon, and Threads.
  • source: a free-form attribution label (defaults to "api") shown in the dashboard, useful when several integrations write into one workspace.

Post statuses

StatusMeaning
draftCreated but not published or scheduled. A planned draft keeps its schedule_at and shows on the calendar with a dashed outline
in_approvalWaiting for a reviewer in an approval workflow (approval_status is pending)
rejectedAn approver declined the post; it will not publish. GET /posts/:id/approval says who and why
scheduledWaiting to be published at schedule_at
postingA publish is in flight
publishedSuccessfully published to every selected channel
warningPublished to some but not all selected channels (check errors)
failedPublish attempt failed on every selected channel

When status is warning or failed, the errors object maps each failed platform to a user-friendly message, and published_urls holds the ones that succeeded.

Every post also carries approval_status (none, pending, approved, rejected), the state of its approval workflow. For pending and rejected, read the review with GET /posts/:id/approval.

On GET /posts, filter with ?status= using any of the values above (published and the legacy alias posted are equivalent). Note that warning posts do not match status=failed; poll both if you track failures.

Common errors

The error codes this guide mentions, in one place. All follow the shared error shape.

StatusCodeMeaning
400validation_errorA field failed validation (content over a platform's limit, reel without video, unsupported type for a selected platform, mixed media on a platform that forbids it, mismatched carousel ratios). error.message names the exact problem.
400file_too_largeA media_urls entry exceeds 100 MB. Upload via upload-from-url and pass the media id instead.
400hashtag_limit_exceededA hashtag set would push an Instagram caption or comment past 5 hashtags.
400invalid_status / nothing_to_retry / max_retries_reachedThe retry endpoint refused; see Retrying failed posts.
402x_credits_insufficientAn X link post exceeds the credit balance; error.details carries the balances.
403insufficient_scopeThe key lacks posts:write.
403insufficient_permissionThe role of the person who created the key no longer allows this action in the workspace (for example, deleting posts needs delete_post). The role is checked on every request.

Retrying failed posts

POST /posts/:id/retry retries the failed platforms of a failed or warning post on the same post. Only the failed platforms are re-published; channels that already succeeded are never posted again.

TerminalTerminal
curl -X POST https://api.omnisocials.com/v1/posts/59546574/retry \ -H "Authorization: Bearer omsk_live_..."

The retry is asynchronous (it usually runs within a few minutes). Poll GET /posts/:id: on success the status flips to published and published_urls gains the platform URL; the resolved platform's entry is removed from errors. Each platform can be retried at most 3 times; after that the endpoint returns 400 max_retries_reached and you should create a new post. POST /posts/:id/publish deliberately refuses failed and warning posts; retry is the endpoint for them.

For instant failure alerts instead of polling, subscribe a webhook to the post.failed event; see Webhooks.

Retry linkage

Retries made in the OmniSocials dashboard can create a separate retry post. Both sides are exposed on the Post object: retry_of on the new post (the original failed post it retries) and retries on the original (its retry posts). When such a retry succeeds, the original flips to published with an empty published_urls: a resolved failure, not a second publish. If you count published posts by status, skip posts whose published_urls is empty and retries is set; the live URLs are on the retry post. The API's own /retry endpoint never creates this split; it retries on the same post.

Last modified on October 22, 2026
WebhooksStories
On this page
  • Minimal request
  • Content
  • Accounts and channels
  • Post types
  • Attaching media
  • Alt text (accessibility descriptions)
    • How to set it
    • Where it goes, per platform
    • Special cases
    • Limits and validation
    • Reading it back
  • Link previews
  • Video covers
  • Scheduling
    • Planned drafts
  • Approval workflows
    • Reading the review
    • Staying informed during a review
  • Platform-specific options
    • Polls
  • Automatic first comment
  • Hashtag sets
  • Comment-to-DM automation
  • Publishing
  • X link posts use credits
  • Image fit per channel
  • Updating and deleting
  • Listing posts
  • More create-body fields
  • Post statuses
  • Common errors
  • Retrying failed posts
  • Retry linkage
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON