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:
Code
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:
Code
Per-platform:
Code
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.
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.
| Type | Description | Supported platforms |
|---|---|---|
post | Regular feed post | All platforms |
story | 24 hour story | Instagram, Facebook, Snapchat |
reel | Short-form vertical video | Instagram, Facebook, YouTube, TikTok |
Stories and reels require media. Posting a story or reel without media returns 400.
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.
Code
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) — on LinkedIn it publishes as a swipeable document, elsewhere as an image carousel.
Code
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.
Code
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).
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.
Code
Per-platform media objects work the same way, so a channel override can carry its own descriptions:
Code
Where it goes, per platform
| Platform | Behavior |
|---|---|
| Mastodon | Each image or video uploads with its own native media description — 4 images means 4 distinct alts. Visible to screen readers and satisfies the community's alt-text reminder bots. |
| Bluesky | Per-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. |
| X | One media-metadata call per photo or GIF. 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. |
The 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. | |
alt_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. | |
altText on images, both single-image and multi-image posts. Video and documents are not supported by LinkedIn. | |
| Everything else | Currently 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. - 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 and Bluesky; 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
400with codevalidation_error. - Platforms with a lower cap are clamped at publish time: X and Instagram truncate at 1000 characters.
altmust 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, a URL in the post text just renders as plain text and no preview card is generated.
Code
| Field | Required | Notes |
|---|---|---|
link_url | yes | Must be a valid http(s) URL. Switching the post into link-share mode. |
link_title | no | Used by LinkedIn directly. Facebook ignores it and fetches the page's OG title server-side. |
link_description | no | Same behaviour as link_title. |
link_thumbnail_url | no | Reserved for forward compatibility. Both platforms currently auto-fetch 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.
Scheduling
To schedule a post, add scheduled_at as an ISO 8601 timestamp in UTC. OmniSocials publishes within a 1 minute window of the requested time.
Code
Scheduled posts move from draft to scheduled to posted automatically. You can also schedule a post by creating a draft and calling PATCH /posts/:id to set scheduled_at later.
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.
Code
See the Platforms section for a per-platform breakdown of supported options.
Automatic first comment
Instagram, Facebook, LinkedIn, and YouTube 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.
Code
| Channel key | Max length | Notes |
|---|---|---|
instagram | 2200 | Feed posts and reels |
facebook | 8000 | Page posts only |
linkedin | 1250 | Personal profile |
linkedin_page | 1250 | Company page |
youtube | 10000 | Video must allow comments |
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.
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:
Code
| Field | Meaning |
|---|---|
hashtag_set | Set name, matched case-insensitively (or hashtag_set_id) |
hashtag_placement | caption_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_platforms | Optional 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 30-hashtag cap is enforced at create time with a hashtag_limit_exceeded error instead of a publish-time failure.
Publishing
Draft posts do nothing until you publish them. Three ways to publish:
- Create and publish in one call. Use
POST /posts/create-and-publishwith the same body as/posts/create. - Publish an existing draft.
POST /posts/:id/publish. No body required. - Schedule it. Set
scheduled_aton 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. 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:
Code
warnings is omitted when there is nothing to warn about, so integrations that only read data are unaffected. Debiting starts 2026-08-14 (enforce_from); before that, URL posts publish free with a heads-up. From that date, 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.
Updating and deleting
Only drafts and scheduled posts can be updated. PATCH /posts/:id accepts any fields from the create body. Published posts are immutable. To change a live post, delete it and recreate.
DELETE /posts/:id removes the post from OmniSocials. For published posts, this also attempts to delete the post from the underlying platform (where supported). For scheduled posts, it cancels the scheduled publish.
Post statuses
| Status | Meaning |
|---|---|
draft | Created but not published or scheduled |
scheduled | Waiting to be published at scheduled_at |
posting | A publish is in flight |
published | Successfully published to every selected channel |
warning | Published to some but not all selected channels (check errors) |
failed | Publish 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.
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.
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.
Code
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.