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

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

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

**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` — 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. |
| 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; 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 truncates 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.

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

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

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.

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

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

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](/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. Three ways to publish:

1. **Create and publish in one call.** Use `POST /posts/create-and-publish` with the same body as `/posts/create`.
2. **Publish an existing draft.** `POST /posts/:id/publish`. No body required.
3. **Schedule it.** Set `scheduled_at` on creation. The platform publishes automatically at that time.

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

## Updating and deleting

Only drafts and scheduled posts can be updated. `PATCH /posts/:id` accepts any fields from the create body. Posted posts are immutable. To change a live post, delete it and recreate.

`DELETE /posts/:id` removes the post from OmniSocials. For `posted` 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` |
| `posted` | 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. To retry a `warning` post, fix the cause and call `POST /posts/:id/publish`. A fully `failed` post cannot be re-published — create a new post instead.
