# Create and Schedule Posts via API

import { TechArticle } from "../components/TechArticle";

<TechArticle slug="creating-posts" fm={frontmatter} />

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:

```bash
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.

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 coming soon) |
| `reel` | Short-form vertical video | Instagram, 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](/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): on LinkedIn it publishes as a swipeable document, 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

| Platform | Behavior |
|----------|----------|
| **Mastodon** | Each 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. |
| **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. |
| **Pinterest** | 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. |
| **Instagram** | `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. |
| **LinkedIn** | `altText` on images, both single-image and multi-image posts. Video and documents are not supported by LinkedIn. |
| **Threads** | `alt_text` on every image and video container: single posts, carousel items, and per-part thread media. |
| 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`.
- **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, a URL in the post text just renders as plain text and no preview card is generated.

```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."
}
```

| 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.

## 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" }
    }
  }
}
```

| Field | Required | Notes |
| --- | --- | --- |
| `type` | yes | `frame` uses the video frame at `thumb_offset`; `custom` uploads the image at `cover_url`. |
| `thumb_offset` | with `frame` | Milliseconds into the video. |
| `cover_url` | with `custom` | Public JPEG or PNG. |
| `overrides` | no | Entries 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. 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.

## 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.

```bash
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`.

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`.

## 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](/platforms) section for a per-platform breakdown of supported options.

## Automatic first comment

Instagram, Facebook, LinkedIn, YouTube, and TikTok 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 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 |
| `tiktok` | 150 | Needs comments enabled on the TikTok channel (see below); video must be public and 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.

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](/api/hashtag-sets) (`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"
}
```

| 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. 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. 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. 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](https://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 on the platform and recreate it.

`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](/api), 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](/platforms/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](/platforms/threads#location-tagging).
- **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](/platforms/x#posting-threads), [Bluesky](/platforms/bluesky#posting-threads), [Mastodon](/platforms/mastodon#posting-threads), and [Threads](/platforms/threads#posting-threads).
- **`source`**: a free-form attribution label (defaults to `"api"`) shown in the dashboard, useful when several integrations write into one workspace.

## Post statuses

| Status | Meaning |
|--------|---------|
| `draft` | Created but not published or scheduled |
| `scheduled` | Waiting to be published at `schedule_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.

## Common errors

The error codes this guide mentions, in one place. All follow the [shared error shape](/rate-limits-and-errors#error-body-shape).

| Status | Code | Meaning |
|--------|------|---------|
| `400` | `validation_error` | A 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. |
| `400` | `file_too_large` | A `media_urls` entry exceeds 100 MB. Upload via `upload-from-url` and pass the media id instead. |
| `400` | `hashtag_limit_exceeded` | A hashtag set would push an Instagram caption or comment past 30 hashtags. |
| `400` | `invalid_status` / `nothing_to_retry` / `max_retries_reached` | The retry endpoint refused; see [Retrying failed posts](#retrying-failed-posts). |
| `402` | `x_credits_insufficient` | An X link post exceeds the credit balance; `error.details` carries the balances. |
| `403` | `insufficient_scope` | The key lacks `posts:write`. |

## 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.

```bash
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](/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.
