# OmniSocials developer docs, full text > OmniSocials schedules, publishes and answers social media posts on 12 platforms (Instagram, Facebook, LinkedIn, YouTube, TikTok, X, Pinterest, Threads, Bluesky, Mastodon, Google Business, Telegram) through one REST API, 8 SDKs, a hosted MCP server and a CLI. Every guide page in one file, in sidebar order. The index with one line per page is https://docs.omnisocials.com/llms.txt; the REST endpoints are in https://docs.omnisocials.com/openapi.json. --- ## Document: Quickstart: publish your first post Get an OmniSocials API key and publish your first post in five steps, to 13 channels on 12 platforms. Examples in curl, Node.js and Python. URL: https://docs.omnisocials.com/introduction # Quickstart: publish your first post OmniSocials is an API for scheduling and publishing social media posts across 13 channels (12 platforms, with LinkedIn split into a personal profile and a company page) from a single endpoint. This guide gets you from zero to your first published post in five steps, in about five minutes. ## Before you start - An [OmniSocials account](https://app.omnisocials.com). - One social account connected to it. You connect it once in the dashboard (step 3). It cannot be done through the API. - An API key with the scopes `posts:write`, `media:write` and `accounts:read` (step 1). The examples use curl, the Node.js SDK (`npm install @omnisocials/sdk`) and the Python SDK (`pip install omnisocials`). Pick a tab and every step follows it. ## 1. Create an API key Sign in to [app.omnisocials.com](https://app.omnisocials.com), open **Settings → API**, and click **Create API Key**. Pick the scopes your integration needs. For most use cases, start with `posts:write`, `media:write`, and `accounts:read`. A key's scopes cannot be edited afterwards; when you need more, create a new key. Copy the key and store it somewhere safe. Keys start with `omsk_live_` and work on your real connected accounts; there is no separate test mode. ```bash export OMNISOCIALS_API_KEY="omsk_live_" ``` ## 2. Verify access Every request authenticates with a Bearer token in the `Authorization` header. The easiest way to check the key works is to list your connected accounts. The SDKs read `OMNISOCIALS_API_KEY` from the environment and send the header for you. ```bash title="curl" curl https://api.omnisocials.com/v1/accounts \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` ```ts title="Node.js" import { OmniSocials } from "@omnisocials/sdk"; const client = new OmniSocials(); // reads OMNISOCIALS_API_KEY const accounts = await client.accounts.list(); console.log(accounts.workspace_name, accounts.data.map((account) => account.id)); ``` ```python title="Python" from omnisocials import OmniSocials client = OmniSocials() # reads OMNISOCIALS_API_KEY accounts = client.accounts.list() print(accounts["workspace_name"], [account["id"] for account in accounts["data"]]) ``` A `200` response with a `data` array (plus `workspace_id` and `workspace_name`) means you're in. If you see `401`, the key is wrong. If you see `403`, the key is missing the `accounts:read` scope. ## 3. Connect a social account You cannot connect social accounts through the API. Accounts are connected once through OAuth in the OmniSocials dashboard, and then become available to every API key in the workspace. Open **Settings → Organisation → Workspaces**, choose the workspace, pick the platform you want, and complete the OAuth flow. Re-run the `GET /accounts` call from step 2 to confirm the account appears in the list. Copy its `id` for the next step. ## 4. Upload media (optional) Text-only posts skip this step. Platforms that require media (Instagram, TikTok, Pinterest, stories, reels) need a media file or URL. ```bash title="curl" curl -X POST https://api.omnisocials.com/v1/media/upload \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -F "file=@photo.jpg" ``` ```ts title="Node.js" const upload = await client.media.upload({ file: "photo.jpg" }); // a path, Buffer or Blob console.log(upload.data.id); ``` ```python title="Python" upload = client.media.upload(file="photo.jpg") # a path, bytes or a file opened in "rb" mode print(upload["data"]["id"]) ``` Response: ```json { "data": { "id": "123", "url": "https://cdn.omnisocials.com/...", "type": "image", "status": "ready" }, "compatibility": { "overall": "ok", "platforms": {} }, "message": "File uploaded successfully. Use the 'id' in your post request." } ``` Save `data.id` for the next step. Alternatively, skip the upload step entirely and pass external URLs directly in `media_urls` on the post. The API downloads and attaches them for you. See [Media](https://docs.omnisocials.com/media.md) for the full reference. ## 5. Create your first post Use the account `id` from step 3 and, optionally, the media `id` from step 4 in `media_ids`. ```bash title="curl" 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 OmniSocials API" }, "accounts": ["your-account-id"], "media_ids": ["123"] }' ``` ```ts title="Node.js" const { data: post } = await client.posts.create({ content: "Hello from the OmniSocials API", channels: ["your-account-id"], media_ids: ["123"], }); console.log(post.id, post.status); ``` ```python title="Python" post = client.posts.create( content="Hello from the OmniSocials API", channels=["your-account-id"], media_ids=["123"], ) print(post["data"]["id"], post["data"]["status"]) ``` The SDKs call the accounts field `channels` and the schedule time `scheduled_at`, where the REST examples use `accounts` and `schedule_at`. The API accepts both names, and `content` can be a plain string or an object with a `default` key. A successful response is a `201` with the new post record: ```json { "data": { "id": "1024", "status": "draft", "content": { "default": "Hello from the OmniSocials API" }, "accounts": { "instagram": true, "facebook": false, "x": false }, "created_at": "2026-08-20T09:15:00Z" } } ``` In responses, `accounts` is a map of every platform key to `true` or `false`; the channels the post targets are `true`. The example above is shortened. The post is saved as a draft. To publish immediately instead, add `"publish_now": true` to the body above, or POST to `/v1/posts/:id/publish` afterwards. `/v1/posts/create-and-publish` is an equivalent alias for the `publish_now` variant. In the SDKs, call `posts.createAndPublish()` (Node.js) or `posts.create_and_publish()` (Python) with the same fields, or `posts.publish(id)` afterwards. After publishing, poll `GET /v1/posts/1024` (`posts.get(id)` in the SDKs) until `status` is `published`; the live links appear in `published_urls`. If the call fails: `401` means the key is wrong, `403` means a missing scope, and `400` returns an `error.message` that names the exact problem. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). ## Next steps - [SDKs](https://docs.omnisocials.com/sdks.md) wrap this API in 8 languages (Node, Python, Go, Ruby, PHP, Java, .NET, Rust) so you skip the raw HTTP - [Creating posts](https://docs.omnisocials.com/creating-posts.md) covers scheduling, per-platform content, post types, and updates - [Cross-posting](https://docs.omnisocials.com/cross-posting.md) covers publishing the same post to many platforms at once (the reason to use OmniSocials instead of calling every platform's API directly) - [Media](https://docs.omnisocials.com/media.md) covers upload methods, formats, and size limits - [Platforms](https://docs.omnisocials.com/platforms.md) lists channel IDs and platform-specific options - [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md) lists the limits and status codes you need to handle - [API reference](https://docs.omnisocials.com/api) is the full endpoint spec generated from the OpenAPI schema --- ## Document: API authentication and API keys Create OmniSocials API keys, pick scopes, and authenticate with Bearer tokens. Covers the scope reference, opt-in inbox and automation scopes, and key rotation. URL: https://docs.omnisocials.com/authentication # API authentication and API keys Every OmniSocials API request requires a Bearer token in the `Authorization` header. Tokens are long-lived API keys created from the dashboard. ``` Authorization: Bearer omsk_live_ ``` ## Create an API key 1. Sign in to [app.omnisocials.com](https://app.omnisocials.com) 2. Open **Settings → API** 3. Click **Create API Key** 4. Name the key (e.g. "Zapier", "Internal automation"), pick the scopes it needs, and save 5. Copy the key and store it somewhere safe. Members who can manage the organisation's API can copy it again later from the key list under **Settings → API** Every key starts with `omsk_live_` and works on your real, connected social accounts. There is no separate test environment or test key, so point CI and local development at a workspace whose accounts you can post to safely, or create drafts (they never publish on their own). ## Scopes Every key is scoped to a subset of the API. A key only has access to the scopes you selected when you created it. | Scope | Grants access to | |-------|------------------| | `posts:read` | `GET /posts`, `GET /posts/:id` | | `posts:write` | `POST /posts/create`, `PATCH /posts/:id`, `DELETE /posts/:id`, publish endpoints | | `media:read` | `GET /media`, `GET /media/:id`, `GET /folders` | | `media:write` | `POST /media/upload`, `POST /media/upload-from-url`, `PATCH /media/:id`, `DELETE /media/:id` | | `accounts:read` | `GET /accounts`, `GET /accounts/:id` | | `analytics:read` | `GET /analytics/*` endpoints, `GET /posts/recent-platform` | | `webhooks:manage` | Full CRUD on webhooks, including secret rotation | | `inbox:read` | Opt-in. `GET /inbox/*`: conversations, messages, and the next unanswered item | | `inbox:write` | Opt-in. Replies, marking read, hiding and deleting comments | | `automations:read` | Opt-in. `GET /automations`, `GET /automations/:id`, and the activity log | | `automations:write` | Opt-in. Create, update, and delete automations, and `comment_dm_automation` on posts | The first seven scopes are the default set. The inbox and automation scopes are opt-in: turn on **Social Inbox access** or **Automations** when you create the key. Existing keys never get them automatically. Requests made with a key missing the required scope return `403 Forbidden`. Add the missing scope by creating a new key, not by editing an existing one. ## Rotating and revoking keys Revoke a key from **Settings → API** at any time. Revocation is immediate. In-flight requests using a revoked key fail with `401 Unauthorized` on the next call. To rotate a key without downtime, create the new key first, update your integration, and only then revoke the old key. ## Security notes - Never commit API keys to source control. Load them from environment variables or a secrets manager. - Give the keys you use in local development and CI only the scopes they need, and revoke them when a project ends. - A compromised key can do anything its scopes allow. Scope keys narrowly. - Tokens do not expire automatically. Rotate them periodically as a hygiene practice. --- ## Document: Test your OmniSocials API integration safely OmniSocials has no sandbox: every key is live. How to test with drafts, a test workspace, private posts, webhook request bins, and the Postman collection. URL: https://docs.omnisocials.com/testing # Test your OmniSocials API integration safely OmniSocials has no sandbox and no test mode. Every API key starts with `omsk_live_` and works on the real social accounts connected to its workspace. Testing safely comes down to controlling what can publish, and where. :::caution A request with `publish_now: true`, `POST /posts/create-and-publish`, `POST /posts/:id/publish`, or a `schedule_at` that you leave in place goes live on real accounts. Treat a test key like a production key. ::: ## Use a test workspace Every API key belongs to one workspace. You pick it in the **Workspace** field when you create the key under **Settings → API**, and the key only sees that workspace's accounts, posts and media. 1. Create a workspace for testing in the dashboard and connect test accounts to it. 2. Create a key for that workspace, with only the scopes your tests need. 3. Check the target before a test run: `GET /accounts` returns `workspace_id` and `workspace_name`. A workspace is part of your subscription, see [Billing](https://docs.omnisocials.com/billing.md#how-the-subscription-is-structured). A trial has full API access. When a trial or subscription lapses, requests fail with `402 subscription_inactive` until it is reactivated. Webhooks you create with a key only receive events for that key's workspace, so test deliveries stay apart from production ones. ## Post to accounts you can test on Connect social accounts that exist for testing, not your brand's main accounts. A few channels can keep a test post out of public view: | Channel | How to keep a test post private | |---------|----------------------------------| | `tiktok` | `tiktok.privacy_level: "SELF_ONLY"` | | `youtube` | `youtube.privacy_status: "private"` or `"unlisted"` | | `pinterest` | Pin to a secret board. The `boards` array of `GET /accounts/:id` includes secret boards | | `telegram` | Connect a channel or group that only your team is in | The other channels have no visibility option in the API, so a test post there is public on that account. Mastodon posts are always public, and Threads accounts must be public profiles to connect. ## Start with drafts Every post is a draft unless the request has `publish_now` or `schedule_at`. A draft never publishes on its own, so you can create, read, update and delete drafts as often as you like: ```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": "Integration test" }, "accounts": ["your-account-id"], "source": "integration-test" }' ``` A [planned draft](https://docs.omnisocials.com/creating-posts.md#planned-drafts) (`draft: true` with `schedule_at`) keeps its time on the dashboard calendar and still never publishes. :::tip Drafts skip most per-platform checks: character limits, media rules, Pinterest carousel ratios and the X credits check run when a post is scheduled or published. To run those checks without publishing, schedule the post well in the future, read the response, then delete it with `DELETE /posts/:id`, which cancels the scheduled publish. A `400` names the problem, and `warnings` reports X credits and image fit. ::: Scheduling has side effects of its own: it fires `post.scheduled` to your webhooks, and a scheduled X post with a link reserves its credits until it publishes. To check a file on its own, use `POST /media/check` (see [Media](https://docs.omnisocials.com/media.md#check-compatibility-before-posting)). For a human safety net, attach an [approval workflow](https://docs.omnisocials.com/creating-posts.md#approval-workflows): the post waits as `in_approval` and publishes only after the last step approves it. ## Clean up test posts - `DELETE /posts/:id` removes a post from OmniSocials. On a scheduled post it cancels the publish. - It never deletes a live post on a platform. To take one down, use "Delete from [platform]" on the post in the dashboard (every platform except Instagram and TikTok, whose APIs have no delete) or delete it in the platform's app. Telegram allows removal only in the first 48 hours. - Label test posts with `source` (a free-form string, returned on every post) so a cleanup script can find them. `GET /posts` has no `source` filter, so filter the list on your side. ## Test webhooks Webhook URLs must use HTTPS and resolve to a public host; private and loopback addresses are refused with `validation_error`. Two ways to receive test deliveries: - **A request bin.** Register the URL of a request inspection service as a webhook. It shows each delivery's raw body and headers (`X-OmniSocials-Signature`, `X-OmniSocials-Delivery` and the rest), which is what you need to check your signature code. - **A tunnel to your machine.** Expose your dev server with `cloudflared tunnel` or `ngrok http 3000` and register the tunnel URL. There is no endpoint that sends a test event. Trigger real events without publishing anything: | Event | How to trigger it in a test | |-------|-----------------------------| | `post.scheduled` | Create a post with `schedule_at` in the future through the API, then delete it | | `post.approved`, `post.rejected` | Create a post with `approval_workflow_id` and a future `schedule_at`, approve or reject it, then delete it. An approved post publishes at its time | | `post.published`, `post.failed` | These need a real publish. Use a private test post from the table above | After a run, `GET /webhooks/:id` shows `failure_count` and `last_failure` for deliveries your endpoint did not accept. See [Webhooks](https://docs.omnisocials.com/webhooks.md) for the payloads and signature verification. ## Mind the limits - The rate limit is 100 requests per minute per key. Give tests their own key, so a test run never uses up the production key's budget. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). - Each workspace can send 1,000 inbox replies per 24 hours, and every reply goes to a real person. - X posts with a link cost 20 credits each, in tests too. Test X with link-free text unless the link is what you are testing. - YouTube's daily API quota is shared by every OmniSocials customer. Keep YouTube test uploads few. ## Postman and OpenAPI - **Postman collection:** import [omnisocials.postman_collection.json](https://docs.omnisocials.com/omnisocials.postman_collection.json) into Postman and add your API key as the Bearer token. - **OpenAPI file:** [openapi.json](https://docs.omnisocials.com/openapi.json) describes every endpoint. Use it to generate a client, import it into another API tool, or validate responses in contract tests. The [CLI](https://docs.omnisocials.com/cli.md) is handy for scripted checks too: every command takes `--json`. --- ## Document: API rate limits and error codes The OmniSocials API allows 100 requests per minute per key. Full HTTP status table, the shared error body shape, stable error codes, and a retry strategy. URL: https://docs.omnisocials.com/rate-limits-and-errors # API rate limits and error codes ## Rate limits OmniSocials enforces a per-API-key rate limit of **100 requests per minute** on every endpoint. Limits reset on a rolling 60 second window. Every response includes three headers describing your current budget: | Header | Meaning | |--------|---------| | `X-RateLimit-Limit` | The ceiling for this key. Currently `100`. | | `X-RateLimit-Remaining` | Requests left in the current window. | | `X-RateLimit-Reset` | Unix timestamp when the window resets. | When the limit is exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header (seconds) and a `retry_after` value in the body. Back off for that long, then retry. ```json { "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Try again in 42 seconds." }, "retry_after": 42 } ``` Rate limits are enforced per key, not per workspace. Splitting heavy workloads across multiple keys is a valid scaling strategy. ## HTTP status codes | Status | Meaning | |--------|---------| | `200` | Success | | `201` | Resource created | | `204` | Success with no body (typically deletions) | | `400` | Validation error. See `error.details` for the field(s) that failed. | | `401` | Missing or invalid API key | | `402` | Insufficient credits (e.g. `x_credits_insufficient` on an X link post; `error.details` carries `credits_required`, `credits_balance`, and `credits_reserved`) | | `403` | Valid key, but missing the scope required for this endpoint | | `404` | Resource not found | | `409` | Conflict: the media item is still used by a post (`media_in_use`), the same inbox reply was already sent (`duplicate_reply`), or an automation already covers the post. Publishing a post that is already published returns `400` with `already_published` | | `429` | Rate limit exceeded | | `500` | Internal error. Retry with exponential backoff. | | `503` | Temporary outage or scheduled maintenance. On media uploads: `storage_unavailable`, see below. | ## Storage outages When our storage provider is down, an upload cannot be stored. Every route that stores media then returns `503` with the code `storage_unavailable`, a `Retry-After` header (seconds), and the same value as `retry_after` in the body: ```json { "error": { "code": "storage_unavailable", "message": "Our storage provider is temporarily unavailable. Your file was not stored. Try again in a few minutes." }, "retry_after": 30 } ``` This applies to `POST /media/upload`, `/media/upload-from-base64`, `/media/upload-from-url`, the presigned upload URL, and `media_urls` on post create and update. Nothing is wrong with your request and nothing was stored: no media item and no post. Wait for `Retry-After`, then send the same request again. A presigned upload URL stays valid for the retry until it expires. ## Error body shape Every 4xx and 5xx response follows the same shape: ```json { "error": { "code": "validation_error", "message": "content.default is required when no per-platform content is set", "details": [ { "field": "content.default", "message": "content.default is required" } ] } } ``` - `code` is a stable machine-readable identifier. Use this for branching logic. Field-level validation uses `validation_error`. - `message` is a human-readable explanation and may change between versions. - `details` is optional and code-specific. On field-level validation errors it is an array of `{ field, message }` objects; other codes attach their own shapes (for example, `x_credits_insufficient` carries credit balances, and media-limit errors carry per-platform file details). There is no top-level `error.field`. ## Retry strategy Retry `429`, `500`, `502`, `503`, and `504` with exponential backoff. Do not retry `400`, `401`, `403`, `404`, or `409` without first fixing the underlying request. A reasonable default is three attempts with delays of 1s, 2s, and 4s. Cap total wait time at 30 seconds. --- ## Document: Retries and duplicate posts: retry safely How the OmniSocials API handles timeouts, 5xx errors and failed posts: no idempotency keys, the retry endpoint, automatic retries, and avoiding double posts. URL: https://docs.omnisocials.com/retries # Retries and duplicate posts: retry safely A social post that goes out twice is worse than one that fails. This page says what OmniSocials does when a request or a publish goes wrong, and how to retry without posting twice. ## The short version - The API has no idempotency keys. Every `POST /posts/create` makes a new post, even with the same body. - After a timeout or a `5xx` on create, check `GET /posts` before you send the request again. - Retry failed channels with `POST /posts/:id/retry`, never by creating the post again. - `429` and `503 storage_unavailable` did nothing, so they are safe to resend after `Retry-After`. - One post can send several webhook events. Track state per `data.post_id`. ## No idempotency keys The API does not read an `Idempotency-Key` header or anything like it, and `POST /posts/create` does not compare a new post with earlier ones. Two identical requests create two posts, and when both publish, both go out (unless the platform refuses the second, see [Duplicate content](#duplicate-content)). Media uploads are the exception. An image or video up to 100 MB is matched by its content: uploading the same bytes to the same workspace again returns the existing media item instead of a new one. This covers `POST /media/upload`, `upload-from-base64`, presigned uploads, `upload-from-url` and `media_urls` on a post. A file over 100 MB, ingested in the background through `upload-from-url`, creates a new item on every call. ## Timeouts and 5xx errors `POST /posts/create` saves the post before it builds the response. A `500 internal_error` or a dropped connection can therefore arrive after the post was saved, and a saved post with `publish_now: true` publishes anyway. Treat a timeout or a `5xx` on create as "unknown", not as "failed". To make the check easy, give each create a unique `source` value. `source` is a free-form label (it defaults to `"api"`, is shown in the dashboard and comes back on every post): ```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": "Spring sale starts today" }, "accounts": ["your-account-id"], "publish_now": true, "source": "spring-sale-job-8f3a" }' ``` If that call times out or answers `5xx`: 1. Wait a few seconds, then call `GET /posts?limit=20`. Posts come back newest first. 2. Look for a post whose `source` is `spring-sale-job-8f3a`. 3. If it is there, do not create again. Poll `GET /posts/:id` for its outcome. 4. If it is not there, send the create again with the same `source`. The other write endpoints are safer to repeat: | Endpoint | What a second identical call does | |----------|-----------------------------------| | `POST /posts/:id/publish` | Only `draft` and `scheduled` posts can be published. A second call answers `400 invalid_status` (the post is `posting` or `warning`), `400 already_published` or `400 post_failed`, and publishes nothing | | `POST /posts/:id/retry` | While the retry runs the post is `posting`, so a second call answers `400 invalid_status` | | `DELETE /posts/:id` | The second call answers `404 not_found` | | Media uploads up to 100 MB | Returns the same media item | | `POST /inbox/conversations/:id/reply` | The same text and attachment to the same conversation within 10 minutes answers `409 duplicate_reply`. Nothing is sent again, and `data` carries the earlier message. Treat it as sent | | Any request answered `429` | Rejected before it ran. Resend after `Retry-After` | | Any upload answered `503 storage_unavailable` | Nothing was stored. Resend after `Retry-After` | ## How OmniSocials retries a publish Publishing runs in the background after your request returns. Several guards keep a channel from getting the same post twice: - **One success per channel.** A channel's success is recorded the moment the platform confirms it. Every later run of the same post (a retry, or recovery after a crash) skips the channels that already succeeded. - **Lookup before resend.** When Instagram's publish call or Facebook's feed post call times out or answers `5xx`, OmniSocials first looks on the account for a post created after the attempt with the same caption, and keeps it when it finds one. Instagram sometimes answers "Action blocked" for a post that is already live; the same lookup runs then. A post without a caption, such as a story, cannot be matched this way. - **Unknown outcomes are reported, not retried.** When X accepts a post but returns no post id, the channel fails with "X accepted the request but did not return a post id, so we can't confirm the post went live. Check your X profile before retrying, to avoid a duplicate." - **Threads and stories resume.** A Threads thread that fails halfway resumes at the first unpublished part, and a multi-slide story publishes only the slides that did not go out. ### Automatic retries A failure that OmniSocials classifies as temporary gets an automatic retry about 15 minutes later, up to 3 automatic attempts per channel. While one is planned, the channel's message in `errors` ends with "We'll retry this platform automatically in about 15 minutes." The post's `status` is `failed` or `warning` in the meantime. Calling `POST /posts/:id/retry` yourself replaces the planned automatic retry. Automatic attempts are counted apart from the 3 manual retries per channel. ## warning and failed | Status | Meaning | Webhook | |--------|---------|---------| | `warning` | Published on some channels, failed on others. `published_urls` has the live links, `errors` the failed channels | `post.published` with `data.status: "warning"` | | `failed` | Failed on every channel | `post.failed` | An `errors` entry disappears once a retry of that channel succeeds. `GET /posts?status=failed` does not return `warning` posts, so query both when you track failures. See [Post statuses](https://docs.omnisocials.com/creating-posts.md#post-statuses). ## Retrying failed channels `POST /posts/:id/retry` publishes the failed channels of a `failed` or `warning` post again, on the same post. Channels that already published are never posted again. ```bash curl -X POST https://api.omnisocials.com/v1/posts/59546574/retry \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` ```json { "data": { "id": "59546574", "status": "posting", "platforms": ["linkedin"], "message": "Retry queued for: linkedin. Platforms that already succeeded are never re-published. Poll GET /posts/{id} for the outcome." } } ``` The retry runs in the background. Poll `GET /posts/:id` until `status` is no longer `posting`. | Response | Meaning | |----------|---------| | `400 invalid_status` | The post is not `failed` or `warning` | | `400 nothing_to_retry` | No failed channel is recorded, for example on posts from before per-channel tracking. Create a new post instead | | `400 max_retries_reached` | A failed channel was already retried 3 times. All failed channels retry together, so one exhausted channel blocks the whole request. Create a new post | Channels dismissed with "ignore" in the dashboard are not retried. `POST /posts/:id/publish` refuses `failed` posts (`400 post_failed`) and `warning` posts (`400 invalid_status`); retry is the endpoint for them. **Fix the cause first.** A retry sends the same post again, so it fails again when the cause is the content or the account: a duplicate, a missing permission, an expired connection. Reconnect the account, top up credits, or change the post first. [Known issues](https://docs.omnisocials.com/known-issues.md) lists the common causes per channel. A `warning` post can still be edited with `PATCH /posts/:id` before you retry; a `failed` post cannot (`400 invalid_status`), so create a new post when its content has to change. Retries made in the dashboard can create a separate retry post, linked with `retry_of` and `retries`. See [Retry linkage](https://docs.omnisocials.com/creating-posts.md#retry-linkage). ## Duplicate content OmniSocials does not refuse a post for being the same as an earlier one. Some platforms do: | Channel | Message | |---------|---------| | Facebook | "Facebook rejected this as a duplicate" | | LinkedIn | "LinkedIn rejected this post as a duplicate: identical content already exists on your profile" (or page) | | X | "This is a duplicate of a tweet you already posted." | These refusals also cover content that was posted outside OmniSocials. A retry with the same text fails the same way: change the text first. ## Webhooks for one post can arrive more than once Every delivery carries `X-OmniSocials-Delivery`, a unique id per event that stays the same when a delivery is retried. Deduplicate deliveries on it. One post can still produce several events, each with its own delivery id: - `post.failed` after the first attempt, then `post.published` when a retry (manual or automatic) succeeds. - `post.published` with `data.status: "warning"` after the first attempt, then `post.published` again after each retry. - A planned automatic retry sends nothing until it ends. Keep your state per `data.post_id`, read `data.status` and `data.targets` on every event, and order events by `created_at`. See [Webhooks](https://docs.omnisocials.com/webhooks.md#event-catalog). ## Checklist | Situation | Resend? | What to do | |-----------|---------|------------| | `429 rate_limit_exceeded` | Yes | Wait `Retry-After` seconds | | `503 storage_unavailable` | Yes | Wait `Retry-After` seconds, send the same request | | `400`, `401`, `402`, `403`, `404` | No | Fix the request, key, scope or credits first | | Timeout or `5xx` on `POST /posts/create` | Only after a check | Look for the post's `source` in `GET /posts`; create again only when it is missing | | Timeout or `5xx` on publish or retry | Yes | A refusal with `invalid_status`, `already_published` or `post_failed` means the first call went through. Poll the post | | A channel failed (`warning` or `failed`) | Use retry | Fix the cause, then `POST /posts/:id/retry` | | `409 duplicate_reply` | No | The reply was already sent | For the full status code table and the error body, see [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). --- ## Document: Use the OmniSocials docs from an AI agent Read the OmniSocials docs from an AI agent or coding tool: llms.txt, markdown pages, the docs MCP server, the OpenAPI spec and a Postman collection. URL: https://docs.omnisocials.com/ai-agents # Use the OmniSocials docs from an AI agent Every page of these docs is also available in a form an agent can read without the website. Pick the one your tool supports. | What | URL | Use it for | | --- | --- | --- | | Index | [llms.txt](https://docs.omnisocials.com/llms.txt) | Every page with a one-line summary, grouped like the sidebar | | Full text | [llms-full.txt](https://docs.omnisocials.com/llms-full.txt) | All guide pages in one file | | Markdown pages | Add `.md` to a page URL, like [/introduction.md](https://docs.omnisocials.com/introduction.md) | One page, without the site around it | | Content negotiation | Send `Accept: text/markdown` to a page URL | The same markdown at the normal URL | | Docs MCP server | `https://docs.omnisocials.com/mcp` | Search and read the docs from an MCP client | | OpenAPI spec | [openapi.json](https://docs.omnisocials.com/openapi.json) | Every REST endpoint, for code generation and API tools | | Postman collection | [omnisocials.postman_collection.json](https://docs.omnisocials.com/omnisocials.postman_collection.json) | Import into Postman, Insomnia or Bruno | | Agent skill | [skill.md](https://docs.omnisocials.com/skill.md) | Short working instructions for a coding agent | ## Two MCP servers OmniSocials runs two MCP servers. They do different jobs. - **The docs MCP server** (`https://docs.omnisocials.com/mcp`) reads these docs. It is read-only and needs no sign-in. - **The OmniSocials MCP server** (`https://mcp.omnisocials.com`) acts on your workspace: it creates and schedules posts, reads analytics and answers the inbox. It needs a sign-in or an API key. See [MCP server](https://docs.omnisocials.com/mcp-server.md). A coding agent that builds an integration usually wants both: the docs server to look things up, and the API itself (or the OmniSocials MCP server) to test against. ## Connect the docs MCP server The server speaks MCP over Streamable HTTP and has four tools: | Tool | What it does | | --- | --- | | `search_docs` | Searches the guides, platform pages, SDK pages and the API reference | | `get_page` | Returns one page as markdown, by path or URL | | `get_endpoint` | Returns the full OpenAPI definition of one endpoint, with schemas resolved | | `list_pages` | Returns the index (llms.txt) | ### Claude Code ```bash claude mcp add --transport http omnisocials-docs https://docs.omnisocials.com/mcp ``` ### Cursor Open [this install link](cursor://anysphere.cursor-deeplink/mcp/install?name=omnisocials-docs&config=eyJ1cmwiOiJodHRwczovL2RvY3Mub21uaXNvY2lhbHMuY29tL21jcCJ9) in Cursor, or add the server to `~/.cursor/mcp.json`: ```json { "mcpServers": { "omnisocials-docs": { "url": "https://docs.omnisocials.com/mcp" } } } ``` ### VS Code Add the server to `.vscode/mcp.json` in your project: ```json { "servers": { "omnisocials-docs": { "type": "http", "url": "https://docs.omnisocials.com/mcp" } } } ``` ### Other clients Any MCP client that supports Streamable HTTP can connect to `https://docs.omnisocials.com/mcp`. No headers or keys are needed. ## Fetch a page as markdown Both requests return the same markdown: ```bash curl https://docs.omnisocials.com/creating-posts.md curl -H "Accept: text/markdown" https://docs.omnisocials.com/creating-posts ``` The home page URL with `Accept: text/markdown` returns the llms.txt index. ## From a page in the browser Every guide page has a **Copy page** button. Its menu copies a link, opens the markdown version, or opens the page in Claude or ChatGPT with the page as context. --- ## Document: Create and schedule posts via API Create, schedule, and publish social posts via the OmniSocials API: per-platform content, media, alt text, first comments, hashtag sets, post statuses, and retries. URL: https://docs.omnisocials.com/creating-posts # 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. 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. | 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](https://docs.omnisocials.com/creating-stories.md)). ## 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 | 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 (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. | | **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, 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." } ``` | 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 | LinkedIn 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" } } } } ``` | 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. 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](https://docs.omnisocials.com/creating-reels.md)). 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. ```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`. To see who a post waits for, or who rejected it and why, see [Reading the review](#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. ```bash 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": "sam@example.com", "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": "jordan@example.com", "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" } ] } } ``` | Field | Meaning | | --- | --- | | `status` | `none`, `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. | | `workflow` | `id` and `name` of the workflow. `id` is `null` for a one-off approval that was not made from a saved workflow. | | `requested_by`, `requested_at` | Who sent the post for approval, and when. | | `current_step` | The `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`. | | `rejection` | `null`, 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](https://docs.omnisocials.com/webhooks.md#approval-events). - **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](https://docs.omnisocials.com/platforms.md) section for a per-platform breakdown of supported options. ### Polls X, Threads, Mastodon and Telegram 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 } } } ``` | Channel | Options | Runs for | Extras | |---|---|---|---| | X | 2 to 4, up to 25 characters | `duration_minutes`, 5 to 10080 (7 days), default 1440 | | | Threads | 2 to 4, 1 to 25 characters | 24 hours, set by Threads | | | Mastodon | within 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 86400 | `multiple`, `hide_totals` | | Telegram | 2 to 12, up to 100 characters (question max 300) | `open_period` in seconds, 5 to 2628000; left out, the poll stays open | `multiple` | A poll and media do not go together on these four 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](https://docs.omnisocials.com/platforms/pinterest.md#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 key | Max length | Notes | | --- | --- | --- | | `instagram` | 2200 | Feed posts and reels | | `facebook` | 8000 | Page posts and reels | | `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 | | `telegram` | 4096 | A 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](https://docs.omnisocials.com/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 5-hashtag cap is enforced at create time with a `hashtag_limit_exceeded` error instead of a publish-time failure. ## Comment-to-DM automation An Instagram post 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. Pass `comment_dm_automation` on create (Instagram must be in `accounts`): ```json { "content": { "default": "Want the free guide? Comment GUIDE below" }, "accounts": ["instagram"], "media_ids": ["508"], "schedule_at": "2026-11-01T09:00:00Z", "comment_dm_automation": { "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 } } } ``` The automation starts listening once the post is live on Instagram. The created automation comes back as `comment_dm_automation` on the post, `GET /posts/:id` returns it, and `PATCH /posts/:id` replaces it with a new object or removes it 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](https://docs.omnisocials.com/automations.md). ## 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](#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](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. ## 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. | Channel | Shown uncropped | Default | | --- | --- | --- | | `instagram` | 4:5 to 1.91:1 | `crop`: 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 | | `pinterest` | 2:3 and wider | `keep`: 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](#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](https://docs.omnisocials.com/media.md#check-compatibility-before-posting). **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](https://docs.omnisocials.com/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](https://docs.omnisocials.com/platforms/instagram.md) 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](https://docs.omnisocials.com/platforms/threads.md#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](https://docs.omnisocials.com/platforms/x.md#posting-threads), [Bluesky](https://docs.omnisocials.com/platforms/bluesky.md#posting-threads), [Mastodon](https://docs.omnisocials.com/platforms/mastodon.md#posting-threads), and [Threads](https://docs.omnisocials.com/platforms/threads.md#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. A planned draft keeps its `schedule_at` and shows on the calendar with a dashed outline | | `in_approval` | Waiting for a reviewer in an [approval workflow](#approval-workflows) (`approval_status` is `pending`) | | `rejected` | An approver declined the post; it will not publish. [`GET /posts/:id/approval`](#reading-the-review) says who and why | | `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. 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`](#reading-the-review). 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](https://docs.omnisocials.com/rate-limits-and-errors.md#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 5 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`. | | `403` | `insufficient_permission` | The 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. ```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](https://docs.omnisocials.com/webhooks.md). ## 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. --- ## Document: Post Stories via API to Instagram and Facebook Publish 24-hour stories to Instagram and Facebook via API: media requirements, scheduling, and what happens after a story expires. URL: https://docs.omnisocials.com/creating-stories # Post Stories via API to Instagram and Facebook Stories are ephemeral posts that disappear after 24 hours. OmniSocials supports stories on Instagram and Facebook. Snapchat stories are on the roadmap. Stories use the same `POST /posts/create` endpoint as regular posts. The difference is `type: "story"` and the fact that media is required. A story can carry up to 10 slides: every media item you pass is one slide, and each slide publishes as its own story on the platform, in the order given. ## Supported channels | Channel ID | Platform | Supported | |------------|----------|-----------| | `instagram` | Instagram | ✅ | | `facebook` | Facebook | ✅ | | `snapchat` | Snapchat | _coming soon_ | Calling `POST /posts/create` with `type: "story"` and a channel that does not support stories (LinkedIn, X, Pinterest, etc.) returns `400`. ## Minimal example ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "story", "accounts": ["your-instagram-account-id"], "media_urls": ["https://example.com/story.jpg"] }' ``` Stories always require at least one media file. A story request with no `media_ids` and no `media_urls` returns `400`. ## Multi-slide stories Pass several media items to publish several slides in one post. The order of the array is the publish order. ```json { "type": "story", "accounts": ["your-instagram-account-id"], "media_urls": [ "https://example.com/slide-1.jpg", "https://example.com/slide-2.mp4", "https://example.com/slide-3.jpg" ] } ``` How it works: - Instagram and Facebook have no multi-slide story container. OmniSocials publishes one story per slide, one after the other, so they appear in sequence on the profile. - Max 10 slides per platform. More than 10 returns `400 validation_error`. - Each video slide is limited on its own (60 seconds, see Media requirements). A validation error names the slide that is over the limit. - If a slide fails, the slides before it stay live. A retry (`POST /posts/{id}/retry`) publishes only the slides that did not go out. No slide is published twice. - Each slide publish counts toward Instagram's 100 API publishes per 24 hours. After publishing, the post carries a `story_slides` field with every slide per platform, in publish order: ```json { "published_urls": { "instagram": "https://www.instagram.com/stories/acme/1789..." }, "story_slides": { "instagram": [ { "index": 0, "native_post_id": "1789...", "url": "https://www.instagram.com/stories/acme/1789...", "media_type": "image", "published_at": "2026-08-21T10:00:05Z" }, { "index": 1, "native_post_id": "1790...", "url": "https://www.instagram.com/stories/acme/1790...", "media_type": "video", "published_at": "2026-08-21T10:00:41Z" } ] } } ``` `published_urls` keeps the first slide per platform, so existing integrations keep working. `GET /analytics/posts/{id}` returns the summed metrics per platform plus a `story_slides` array with each slide's own metrics. ## Text on stories Stories do not have captions the way feed posts do. The `content` field is optional on stories and, when provided, is used as internal notes in your OmniSocials dashboard. It is not rendered on the published story. If you want text to appear on the story visual itself, bake it into the image or video before uploading. OmniSocials does not composite text onto media at publish time. ## Cross-posting a story to Instagram and Facebook Pass both account IDs. Both platforms get the same media. ```json { "type": "story", "accounts": [ "your-instagram-account-id", "your-facebook-page-account-id" ], "media_urls": ["https://example.com/story-9x16.jpg"] } ``` If you need different visuals for each (e.g. to tune the crop or overlay), use the per-platform `media_urls` shape with channel IDs as keys: ```json { "type": "story", "accounts": [ "your-instagram-account-id", "your-facebook-page-account-id" ], "media_urls": { "instagram": ["https://example.com/ig-story.jpg"], "facebook": ["https://example.com/fb-story.jpg"] } } ``` ## Media requirements | Platform | Image | Video | |----------|-------|-------| | Instagram | JPEG or PNG, 9:16 recommended | MP4, up to 60 seconds, max 300 MB | | Facebook | JPEG or PNG, 9:16 recommended | MP4, up to 60 seconds | Stories on both platforms prefer a 9:16 vertical aspect ratio (e.g. 1080 × 1920). Images outside 9:16 are still accepted but are center-cropped or letterboxed by the platform. ## Carousels in stories There is no swipeable carousel inside one story. Several media items become several consecutive stories (see Multi-slide stories above). That is how the native apps behave too. ## Scheduling stories Stories are schedulable the same way as feed posts. Add `schedule_at` (ISO 8601 UTC) and OmniSocials publishes the story at the scheduled time. ```json { "type": "story", "accounts": ["your-instagram-account-id"], "media_urls": ["https://example.com/story.jpg"], "schedule_at": "2026-04-18T14:00:00Z" } ``` Because stories only live for 24 hours, scheduling them for a specific moment (e.g. "go live when my campaign starts") is usually more useful than the fire-and-forget pattern of feed posts. ## What happens after 24 hours OmniSocials does not delete the story record from your workspace after the 24 hour window closes. The post still appears in `GET /posts` with `status: "published"`. The `published_urls` field still points at the now-expired story on the platform. Clicking the URL after 24 hours typically returns a 404 or redirects to the account's profile. If you want to archive a story (save it permanently to your profile's Highlights or similar), do that in the native app. OmniSocials does not currently expose a "highlight" action through the API. ## Limitations - A story has no swipeable carousel. Several media items publish as consecutive stories (max 10). - The `content` field is not rendered on the story itself. Any text you want visible must be baked into the media. - Stories cannot be updated after publishing. To change a story, delete and recreate. Deleting a Facebook story removes every slide; Instagram stories must be deleted in the Instagram app. - `first_comment` is ignored on stories (platforms do not support comments on stories), and a hashtag set with `hashtag_placement: "first_comment"` falls back to the caption. - `type: "story"` is rejected on platforms that do not support stories (all platforms except Instagram and Facebook today). Mixing story-capable and non-story-capable channels in the same `accounts` array returns `400`. - Snapchat Spotlight and Stories are on the roadmap, not shipped yet ## Related reading - [Creating Posts](https://docs.omnisocials.com/creating-posts.md) for the full field reference - [Cross-Posting](https://docs.omnisocials.com/cross-posting.md) for the one-request-many-platforms model - [Platforms](https://docs.omnisocials.com/platforms.md) for per-platform media specs --- ## Document: Post Reels via API to Instagram and TikTok Publish short-form vertical video to Instagram Reels, Facebook Reels, YouTube Shorts, and TikTok in one API call. Per-platform options and media specs. URL: https://docs.omnisocials.com/creating-reels # Post Reels via API to Instagram and TikTok Reels are short-form vertical videos. OmniSocials supports reels on Instagram, Facebook, YouTube (as Shorts), and TikTok. A single `POST /posts/create` call with `type: "reel"` can fan out a reel to all four platforms at once. ## Supported channels | Channel ID | Platform | Format | Max duration | |------------|----------|--------|--------------| | `instagram` | Instagram Reels | Vertical 9:16 | 15 minutes | | `facebook` | Facebook Reels | Vertical 9:16 | 240 minutes | | `youtube` | YouTube Shorts | Vertical 9:16 | 3 minutes | | `tiktok` | TikTok | Vertical 9:16 or 16:9 | 10 minutes | Calling with `type: "reel"` and a channel that does not support reels (LinkedIn, X, Pinterest, etc.) returns `400`. ## Minimal example ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "reel", "content": { "default": "New drop 🔥" }, "accounts": ["your-instagram-account-id"], "media_urls": ["https://example.com/reel.mp4"] }' ``` Reels always require a single video file. Image-only or multi-video reel requests return `400`. ## Cross-posting a reel across all four platforms Include every channel ID in `accounts` and use per-platform options for each platform's specific requirements. ```json { "type": "reel", "content": { "default": "Behind the scenes from today's shoot", "tiktok": "POV: behind the scenes 🎬", "youtube": "How we filmed today's shoot #shorts" }, "accounts": [ "your-instagram-account-id", "your-facebook-page-account-id", "your-youtube-account-id", "your-tiktok-account-id" ], "media_urls": ["https://example.com/vertical-video.mp4"], "instagram": { "share_to_feed": true, "thumbnail_type": "from-video", "thumb_offset": 3000 }, "youtube": { "title": "Behind the scenes", "privacy_status": "public", "notify_subscribers": true }, "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE", "disable_duet": false, "disable_stitch": false } } ``` Every platform-specific key only applies to its own channel. Instagram ignores the `youtube` and `tiktok` blocks. YouTube ignores the `instagram` and `tiktok` blocks. And so on. ## Platform-specific options Each reel-capable platform has its own set of options. Pass them under the matching channel ID key. ### Instagram reel options | Field | Type | Description | |-------|------|-------------| | `instagram.share_to_feed` | boolean | Also show the reel on the profile feed grid | | `instagram.thumbnail_type` | string | `from-video` or `from-library` | | `instagram.thumb_offset` | number | Timestamp in milliseconds, used when `thumbnail_type` is `from-video` | | `instagram.cover_url` | string | URL of a custom cover image, used when `thumbnail_type` is `from-library` | | `instagram.audio_id` | string | Licensed music track to attach, from [`GET /audio/search`](#instagram-reel-music) | | `instagram.audio_volume` | integer | Music volume 0–100 (default 100). Only used with `audio_id`. | | `instagram.video_volume` | integer | Volume of the video's own audio 0–100 (default 100). Set `0` for a music-only reel. | | `instagram.is_trial_reel` | boolean | Publish as a [Trial Reel](#instagram-trial-reels): shown to non-followers first to test performance. Not available on every account. | | `instagram.trial_graduation_strategy` | string | `MANUAL` (default) or `SS_PERFORMANCE`. How a Trial Reel graduates to all followers. Only used with `is_trial_reel`. | #### Instagram Trial Reels A Trial Reel is shown to **non-followers first** so you can test how content performs before (optionally) sharing it with your followers. Set `instagram.is_trial_reel: true` on a reel to publish it as a trial: ```json { "type": "reel", "content": { "default": "Testing a new format 👀" }, "accounts": ["your-instagram-account-id"], "media_urls": ["https://example.com/reel.mp4"], "instagram": { "is_trial_reel": true, "trial_graduation_strategy": "SS_PERFORMANCE" } } ``` `trial_graduation_strategy` controls how the trial graduates to all your followers: - `MANUAL` (default): you decide when (or whether) to share it with followers, from the Instagram app. - `SS_PERFORMANCE`: Instagram automatically shares it with your followers if it performs well. `GET /posts/{id}` echoes both fields back under `instagram`, so you can verify a batch of scheduled Trial Reels programmatically. **Eligibility:** Trial Reels are **not available on every account**. Instagram requires roughly 1,000+ followers and enables the feature per account; you can check eligibility by creating a reel in the Instagram app and looking for the "Trial" toggle. Ineligible accounts fail at publish time with a clear per-platform error on the post's `errors` map. #### Instagram reel music Search Meta's licensed catalog with `GET /audio/search?q=...` (omit `q` for currently trending audio; add `type=original_sound` to search original sounds instead of licensed music). Pass the chosen result's `audio_id` as `instagram.audio_id` and mix levels with `audio_volume` / `video_volume`: ```json { "type": "reel", "content": { "default": "New drop 🎵" }, "accounts": ["your-instagram-account-id"], "media_urls": ["https://example.com/reel.mp4"], "instagram": { "audio_id": "587784541076604", "audio_volume": 80, "video_volume": 0 } } ``` Notes: - **Reels only.** Meta's API does not support adding music to feed posts, carousels, or Stories; that remains app-only. - Only tracks Meta has **licensed for third-party publishing** appear in search results, so the selection can differ from the native app. - Requires a **Facebook account connected** to the workspace whose Facebook Page is linked to the same Instagram account (music publishing runs through Facebook's side of the Instagram API). If it isn't, `GET /audio/search` returns `needsFacebook: true` and publishing a reel with `audio_id` fails with a clear error before anything goes live. ### YouTube Shorts options | Field | Type | Description | |-------|------|-------------| | `youtube.title` | string | Video title, max 100 characters. When omitted: the first line of the caption, else `"YouTube Short"`. | | `youtube.tags` | array | Discovery tags. Max 500 characters total. | | `youtube.privacy_status` | string | `public`, `private`, or `unlisted` | | `youtube.category_id` | string | YouTube category. Defaults to `"22"` (People & Blogs). | | `youtube.made_for_kids` | boolean | COPPA disclosure. Disables comments and interactions when true. | | `youtube.notify_subscribers` | boolean | Send the subscriber notification when published | | `youtube.contains_synthetic_media` | boolean | Disclose AI-generated content | ### TikTok options | Field | Type | Description | |-------|------|-------------| | `tiktok.privacy_level` | string | `PUBLIC_TO_EVERYONE` (default), `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY` | | `tiktok.disable_comment` | boolean | Disable comments on the reel | | `tiktok.disable_duet` | boolean | Disable duets | | `tiktok.disable_stitch` | boolean | Disable stitches | | `tiktok.video_cover_timestamp_ms` | integer | Timestamp (ms) of the frame to use as the cover | | `tiktok.is_aigc` | boolean | Disclose AI-generated content | | `tiktok.brand_content_toggle` | boolean | Mark as a paid partnership promoting a third-party brand | | `tiktok.brand_organic_toggle` | boolean | Mark as promoting your own business / brand | `privacy_level` defaults to `PUBLIC_TO_EVERYONE`. When the creator's account does not allow the chosen level, the video is published as `SELF_ONLY` (private). ### Facebook Reels Facebook has no specific reel option fields. The default reel body works. ## Different video per platform Sometimes you want a slightly different cut for each platform (e.g. a shorter version for YouTube Shorts where the limit is 3 minutes, and a longer cut for TikTok where the limit is 10 minutes). Use the per-platform `media_urls` shape: ```json { "type": "reel", "content": { "default": "Full tutorial" }, "accounts": [ "your-youtube-account-id", "your-tiktok-account-id" ], "media_urls": { "youtube": ["https://example.com/tutorial-60s.mp4"], "tiktok": ["https://example.com/tutorial-full.mp4"] }, "youtube": { "title": "Quick tutorial #shorts", "privacy_status": "public" }, "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE" } } ``` ## Media requirements | Platform | Format | Aspect | Duration | File size | |----------|--------|--------|----------|-----------| | Instagram | MP4 | 9:16 | Up to 15 minutes | Up to 300 MB | | Facebook | MP4 | 9:16 | Up to 240 minutes | Up to 1 GB | | YouTube Shorts | MP4 | 9:16 | Up to 3 minutes | Up to 1 GB (the OmniSocials upload limit) | | TikTok | MP4 or MOV | 9:16 or 16:9 | 3 seconds to 10 minutes (the creator's own TikTok limit: 3, 5 or 10 minutes) | Up to 1 GB (the OmniSocials upload limit) | Videos outside these constraints are rejected at publish time. OmniSocials does not transcode, trim, or re-encode. The safest format for cross-platform reels: MP4, H.264 codec, 1080 × 1920 vertical, under 3 minutes (YouTube Shorts is the tightest), under 300 MB (Instagram is the tightest). That passes every platform's requirements. ## Thumbnails and cover images The simplest way to set a cover is the top-level `video_cover` object, which works for every platform in one place (see [Video covers](https://docs.omnisocials.com/creating-posts.md#video-covers)): ```json { "type": "reel", "content": { "default": "Behind the scenes" }, "accounts": ["your-instagram-account-id", "your-facebook-page-id", "your-tiktok-account-id"], "media_urls": ["https://example.com/reel.mp4"], "video_cover": { "type": "custom", "cover_url": "https://example.com/cover.jpg" } } ``` Per platform: - **Instagram**: default is the first frame. `video_cover` applies, or override with `instagram.thumb_offset` (pick a video frame) or `instagram.cover_url` (supply a custom image). - **Facebook**: default is the first frame. `video_cover` applies, or use `facebook.thumbnail_type` with `thumb_offset` or `cover_url`. The thumbnail is set after the reel is live; if Facebook refuses it, the reel still publishes with the default frame. - **YouTube Shorts**: `video_cover` applies (a custom image or a video frame, sent with `thumbnails.set` at up to 1080 x 1920), but YouTube shows it on Shorts only on channels where it has enabled custom Shorts thumbnails (rolling out since July 2026, YouTube Partner Program channels first). On other channels YouTube stores the cover but displays a frame from the video on the Shorts tab and in the Shorts feed; there the thumbnail can only be changed by picking a frame in the YouTube app. Channels that are not verified cannot set custom thumbnails at all. - **TikTok**: frame only. TikTok's API takes a timestamp, not an image, so a `custom` cover is skipped and the frame from `video_cover` (or `tiktok.video_cover_timestamp_ms`) is used. Set `video_cover.overrides.tiktok` to `{ "type": "frame", "thumb_offset": 2000 }` to pick a different frame for TikTok when the other platforms get an image. ## Scheduling reels Reels are schedulable the same way as feed posts. Add `schedule_at` (ISO 8601 UTC). ```json { "type": "reel", "content": { "default": "Friday feature" }, "accounts": [ "your-instagram-account-id", "your-tiktok-account-id" ], "media_urls": ["https://example.com/reel.mp4"], "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE" }, "schedule_at": "2026-04-18T14:00:00Z" } ``` ## Limitations - Reels must have exactly one video. Multi-video or image reels are rejected. - `type: "reel"` is rejected on platforms that do not support reels. Mixing reel-capable and non-reel-capable channels in the same `accounts` array returns `400`. - TikTok branded-content reels (`brand_content_toggle: true`) go through TikTok's review workflow and may take longer to publish. ## Related reading - [Creating Posts](https://docs.omnisocials.com/creating-posts.md) for the full field reference - [Cross-Posting](https://docs.omnisocials.com/cross-posting.md) for the one-request-many-platforms model - [Creating Stories](https://docs.omnisocials.com/creating-stories.md) for the ephemeral story workflow - [Instagram](https://docs.omnisocials.com/platforms/instagram.md), [TikTok](https://docs.omnisocials.com/platforms/tiktok.md), [YouTube](https://docs.omnisocials.com/platforms/youtube.md) for full per-platform specs --- ## Document: Cross-post to 12 channels in one API call Publish one post to 12 social channels in a single API call: per-platform content overrides, media targeting, character limits, and partial-failure handling. URL: https://docs.omnisocials.com/cross-posting # Cross-post to 12 channels in one API call The biggest reason to use OmniSocials instead of calling every platform's API directly is that a single `POST /posts/create` call can target any number of connected channels at once. You write one request, OmniSocials fans it out, handles each platform's quirks, and tells you what happened. This guide walks through the patterns that matter when you're posting the same idea to multiple platforms at the same time. ## The one-request-many-platforms mental model Every post has an `accounts` array. Put as many account IDs in it as you want, and OmniSocials publishes the same post to each of them. ```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": "New feature launched today 🚀" }, "accounts": [ "your-facebook-account-id", "your-instagram-account-id", "your-linkedin-account-id", "your-linkedin-page-account-id", "your-x-account-id" ], "media_urls": ["https://example.com/launch.jpg"] }' ``` That single call produces one post record in OmniSocials, as a draft. Add `"publish_now": true` (or a `schedule_at`) and it publishes to Facebook, Instagram, both LinkedIn slots, and X; `GET /posts/:id` then carries the resulting URL for each platform in `published_urls` once publishing completes. ## Same text everywhere (flat string) The simplest shape. A single string in `content` is used on every channel in the `accounts` array. ```json { "content": "New blog post is live: https://example.com/blog", "accounts": ["fb-id", "x-id", "linkedin-id"] } ``` Use this when the message is short, generic, and reads well on every platform. The downside is there is no tuning per platform. ## Different text per platform Pass an object keyed by channel ID. The `default` key is the fallback for channels that don't have an explicit entry. ```json { "content": { "default": "We just shipped something cool. Check the blog for details.", "x": "New drop 🔥 https://example.com/blog", "linkedin": "Today we shipped a new capability. Here's what it unlocks for teams managing multi-platform content at scale...", "linkedin_page": "[Official] Our engineering team just shipped a major capability. Read the full write-up on our blog." }, "accounts": [ "your-facebook-account-id", "your-instagram-account-id", "your-x-account-id", "your-linkedin-account-id", "your-linkedin-page-account-id" ] } ``` Facebook and Instagram fall back to the `default` copy. X gets a punchy version under its tighter character limit. The two LinkedIn channels each get their own voice: personal tone on the profile, official tone on the company page. You only need to override the channels you actually want to customize. Everything else inherits `default`. ## Same media, different per-platform content Media attachment works the same way: flat array (same on every channel) or object keyed by channel ID. ```json { "content": { "default": "Behind the scenes from the photoshoot", "linkedin": "A look at the creative process behind our latest campaign. The full story is on our blog." }, "accounts": [ "instagram-id", "linkedin-id", "linkedin-page-id" ], "media_urls": [ "https://example.com/photo1.jpg", "https://example.com/photo2.jpg", "https://example.com/photo3.jpg" ] } ``` All three channels get the same three photos. LinkedIn channels get the custom copy, Instagram inherits `default`. ## Different media per platform When platforms need different image aspect ratios or video formats, key the media object by channel ID. The `default` key is the fallback. ```json { "content": { "default": "Spring collection is live" }, "accounts": [ "instagram-id", "pinterest-id", "facebook-id", "x-id" ], "media_urls": { "default": ["https://example.com/spring-16x9.jpg"], "instagram": ["https://example.com/spring-square.jpg"], "pinterest": ["https://example.com/spring-2x3-tall.jpg"], "x": [] } } ``` - Instagram gets the square crop - Pinterest gets the tall 2:3 crop for feed visibility - Facebook falls back to the 16:9 `default` - X is opted out of media entirely (empty array) so the post goes out as text-only The empty-array trick is how you say "this platform specifically should not have media" without having to remove it from `accounts`. ## Handling character limits Every platform has its own character limit. OmniSocials validates against the strictest limit of every channel in the `accounts` array and rejects the whole post with `400` if any platform would exceed it. | Platform | Limit | |----------|-------| | X (standard) | 280 (weighted: every link = 23, emoji and CJK characters = 2) | | X (Premium) | 25,000 (applied automatically when the connected X account has Premium; you cannot choose the tier per post) | | Bluesky | 300 (graphemes) | | Threads | 500 (per post, and per part of a thread) | | Mastodon | Per-instance (read from your instance at connect time; 500 on mastodon.social) | | Pinterest | 500 | | LinkedIn | 3,000 | | TikTok | 2,200 for videos, 4,000 for photo posts | | Instagram | 2,200 | | Facebook | 63,206 | | Google Business | 1,500 | | Telegram | 4,096 (1,024 under media; a longer text goes as a reply under the media) | Two strategies to avoid rejection: 1. **Write to the strictest limit.** If you're posting to X along with LinkedIn and Facebook, cap your copy at 280 characters. 2. **Use per-platform content** to give X a shorter variant and let the others use a longer one. Strategy 2 is usually the better call because you write once in `default` and only override X. ## Platform-specific options apply automatically Options that live under a channel-ID key (`pinterest.board_id`, `youtube.title`, `tiktok.privacy_level`, etc.) only apply to that channel. They're ignored by every other channel in the same request. ```json { "content": { "default": "New video drop" }, "accounts": [ "tiktok-id", "youtube-id", "instagram-id" ], "media_urls": ["https://example.com/vertical-video.mp4"], "type": "reel", "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE" }, "youtube": { "title": "Behind the scenes", "privacy_status": "public" } } ``` TikTok picks up the `tiktok` options. YouTube picks up the `youtube` options. Instagram ignores both and uses its own defaults. No per-channel request splitting required. Two more per-platform tools compose with cross-posting: - **Hashtag sets per channel.** Apply a saved set with `hashtag_set`, and restrict which channels receive the tags with `hashtag_platforms` (for example hashtags on Instagram and X, a clean caption on LinkedIn). See [Hashtag sets](https://docs.omnisocials.com/creating-posts.md#hashtag-sets). - **Thread posts on X, Bluesky, Mastodon, and Threads.** Pass `x.thread_parts`, `bluesky.thread_parts`, `mastodon.thread_parts`, or `threads.thread_parts` to publish a chained thread on those channels while every other channel in `accounts` gets the normal single post. See the [X](https://docs.omnisocials.com/platforms/x.md#posting-threads), [Bluesky](https://docs.omnisocials.com/platforms/bluesky.md#posting-threads), [Mastodon](https://docs.omnisocials.com/platforms/mastodon.md#posting-threads), and [Threads](https://docs.omnisocials.com/platforms/threads.md#posting-threads) pages. ## What happens when one platform fails Cross-posting introduces a partial-failure mode: the post publishes to some platforms and fails on others. When that happens, OmniSocials sets the post `status` to `warning` (not `posted` or `failed`). The live URLs of the platforms that succeeded go in `published_urls`, and the per-platform error messages of the ones that failed go in `errors`. Both are objects keyed by platform identifier: succeeded platforms are omitted from `errors`, failed platforms are omitted from `published_urls`. ```json { "id": "123", "status": "warning", "published_urls": { "facebook": "https://www.facebook.com/123456789/posts/987654321", "instagram": "https://www.instagram.com/p/Cxyz123ABC/", "linkedin": "https://www.linkedin.com/feed/update/urn:li:share:7191234567890" }, "errors": { "x": "Duplicate content posted within the last 24 hours" } } ``` (A post where *every* platform fails gets `status: "failed"` with all channels in `errors`; a post where every platform succeeds gets `status: "published"` and `errors: null`.) Two ways to handle a partial failure: 1. **Fix and retry.** Address the cause first if needed (reconnect the account, or edit the offending copy with `PATCH /posts/:id` to dodge a duplicate filter), then call `POST /posts/:id/retry`. It re-publishes only the platforms that failed; channels that already succeeded are never posted again. It works on both `warning` and `failed` posts, and each platform can be retried at most 3 times. `POST /posts/:id/publish` deliberately refuses `warning` and `failed` posts; retry is the endpoint for them. See [Retrying failed posts](https://docs.omnisocials.com/creating-posts.md#retrying-failed-posts). 2. **Listen via webhooks.** A partial failure fires the `post.published` event with `data.status: "warning"`; inspect `data.targets` for the per-platform outcome. See [Webhooks](https://docs.omnisocials.com/webhooks.md). ## Schedule once, publish everywhere Cross-posting works the same way for scheduled posts. Add `schedule_at` (ISO 8601 UTC) and OmniSocials publishes to every channel in the `accounts` array at the scheduled time. ```json { "content": { "default": "Friday reading list", "x": "This week's top reads 🧵" }, "accounts": ["linkedin-id", "x-id", "threads-id"], "schedule_at": "2026-04-18T14:00:00Z" } ``` Every channel publishes within a one-minute window of the scheduled time. Scheduling a cross-post does not lock anything in. You can still `PATCH /posts/:id` to change content, `DELETE /posts/:id` to cancel, or add and remove channels before the scheduled time arrives. ## Practical recipes ### A product launch You want to announce a launch across every channel with platform-appropriate voice. ```json { "content": { "default": "It's here. [Feature Name] is live.", "x": "Just shipped: [Feature Name] 🚀\n\nLink in replies 👇", "linkedin": "We're thrilled to announce the launch of [Feature Name]. For the past six months, our team has been...", "linkedin_page": "[Feature Name] is now generally available. Key capabilities include...", "threads": "We shipped [Feature Name] today. Open to questions." }, "accounts": [ "fb-id", "ig-id", "linkedin-id", "linkedin-page-id", "x-id", "threads-id" ], "media_urls": ["https://example.com/launch-hero.jpg"] } ``` ### A visual campaign Same photo, different crops, one request. ```json { "content": { "default": "Spring 2026 lookbook" }, "accounts": ["ig-id", "pinterest-id", "fb-id"], "media_urls": { "default": ["https://example.com/spring-16x9.jpg"], "instagram": [ "https://example.com/spring-square-1.jpg", "https://example.com/spring-square-2.jpg", "https://example.com/spring-square-3.jpg" ], "pinterest": ["https://example.com/spring-tall.jpg"] }, "pinterest": { "board_id": "your-board-id", "title": "Spring 2026 lookbook" } } ``` Instagram gets a three-image carousel of square crops. Pinterest gets the tall 2:3 crop with board metadata. Facebook falls back to the 16:9 default. One request. ### Schedule a week of daily posts Cross-posting composes naturally with scheduling. For a week of daily posts, call `POST /posts/create` once per day with a different `schedule_at`, or batch them in a loop on your side. ```javascript const posts = [ { day: "Monday", copy: "Monday thoughts..." }, { day: "Tuesday", copy: "Tuesday tip..." }, // ... ] for (const post of posts) { await fetch("https://api.omnisocials.com/v1/posts/create", { method: "POST", headers: { "Authorization": `Bearer ${process.env.OMNISOCIALS_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: { default: post.copy }, accounts: ["linkedin-id", "x-id", "threads-id"], schedule_at: scheduleFor(post.day), }), }) } ``` Each call creates a separate scheduled post. Each one fans out across the three channels at its scheduled time. Rate limits apply per key (100 req/min) so batching more than 100 posts in a single burst will start hitting 429 responses. Back off and retry, or split across multiple keys. ## Related reading - [Creating Posts](https://docs.omnisocials.com/creating-posts.md) for the full field reference - [Platforms](https://docs.omnisocials.com/platforms.md) for per-platform options and limits - [Rate Limits and Errors](https://docs.omnisocials.com/rate-limits-and-errors.md) for `429` and partial-failure (`warning`) handling - [Webhooks](https://docs.omnisocials.com/webhooks.md) for real-time status notifications --- ## Document: Upload images, video and PDFs via API Upload images, video, and PDFs to the OmniSocials media library: direct upload to 100 MB, URL ingest to 1 GB, PDF-to-carousel, alt text, and supported formats. URL: https://docs.omnisocials.com/media # Upload images, video and PDFs via API Upload and manage media files for use in posts. Every media file is stored in your workspace's media library and can be referenced by multiple posts. ## Upload a file Use `POST /media/upload` with a multipart form. Maximum file size is **100 MB** for a direct upload. To ingest larger files (up to **1 GB**), use [Upload from a URL](#upload-from-a-url), which downloads asynchronously. ```bash curl -X POST https://api.omnisocials.com/v1/media/upload \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -F "file=@photo.jpg" ``` Response: ```json { "data": { "id": "123", "url": "https://cdn.omnisocials.com/workspace-42/photo.jpg", "thumbnail_url": null, "type": "image", "name": "photo.jpg", "filename": "photo-1a2b3c.jpg", "folder_id": null, "size": "0.80 MB", "width": 1080, "height": 1350, "status": "ready", "created_at": "2026-04-10T14:22:00Z" }, "compatibility": { "overall": "ok", "platforms": {} }, "message": "File uploaded successfully. Use the 'id' in your post request." } ``` The media record is nested under `data`. Note that `size` is a human-readable string (e.g. `"0.80 MB"`), not a byte count. `width` and `height` are the image's pixel size (`null` for videos and for files uploaded before July 2026); they also come back on `GET /media` and `GET /media/:id`. The `compatibility` block is a preflight check of the file against your connected platforms. Save `data.id`; you reference media on posts through the `media_ids` field. `status` is `ready` for normal uploads. Large asynchronous URL ingests start as `processing`; poll `GET /media/:id` until `status` is `ready` before using the file in a post. A source that can't be fetched or validated becomes `failed`. ## Upload from a URL Skip the download-and-reupload dance by pointing OmniSocials at a public URL. The server fetches the file (up to 1 GB) and stores it in your media library. ```bash curl -X POST https://api.omnisocials.com/v1/media/upload-from-url \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/photo.jpg" }' ``` The response shape matches `POST /media/upload`. Files above ~100 MB are ingested asynchronously and come back with `status: "processing"`. ## Upload a PDF as a carousel Upload a PDF and OmniSocials rasterizes it into one image slide per page (up to 20 pages), keeps the original file, and returns a media ID for every slide. Pass **all** of the returned IDs to `media_ids` on a post to publish the deck as a carousel: on LinkedIn it posts as a native swipeable document made from your original PDF (text stays sharp, in-document links keep working, viewers download the real file, every page is included); on Instagram, TikTok, Threads and Pinterest as an image carousel. See [LinkedIn: Original PDF](https://docs.omnisocials.com/platforms/linkedin.md#original-pdf) for the exact rule and the `document_source` opt-out. PDFs are accepted on every upload path: `POST /media/upload`, `upload-from-url`, `upload-from-base64`, the presigned `upload-by-token` flow, and `media_urls` on a post (where the PDF expands in place into one slide per page). The upload response carries the whole set: ```json { "data": { "id": "201", "type": "image", "status": "ready", "pdf": { "id": "9d2c…", "name": "deck.pdf", "page": 1, "total_pages": 3, "rendered_pages": 3 } }, "slides": [ { "id": "201" }, { "id": "202" }, { "id": "203" } ], "media_ids": ["201", "202", "203"], "pdf": { "total_pages": 3, "rendered_pages": 3, "truncated": false, "id": "9d2c…", "name": "deck.pdf", "url": "https://cdn.omnisocials.com/file/library-omnisocials/…/9d2c….pdf", "size_bytes": 184320 } } ``` `data` mirrors the first slide for backward compatibility; use `media_ids` to attach the full carousel. Every slide's `Media` object carries `pdf` (the kept file's `id`, `name`, `url`, and this slide's `page`, `total_pages`, `rendered_pages`), so a slide in `GET /media` can always be traced to its file and page. Deleting the last slide of a PDF removes the kept file too. ### Keep the PDF as one item Pass `pdf_mode: "document"` on any upload endpoint (a form field on the multipart and presigned paths) and the PDF stays **one** media item instead of one item per page. This is what the OmniSocials web library does. The item has `type: "document"`, its page images in `pdf.pages` (in order), page 1 as `thumbnail_url`, and the kept PDF as `url`. Its single id in `media_ids` expands into every page at post time, so one id attaches the whole deck: ```json { "data": { "id": "301", "type": "document", "name": "deck.pdf", "pdf": { "id": "9d2c…", "total_pages": 3, "rendered_pages": 3, "pages": [ { "url": "…/p1.png" }, { "url": "…/p2.png" }, { "url": "…/p3.png" } ] } }, "media_ids": ["301"], "pdf": { "total_pages": 3, "rendered_pages": 3, "truncated": false, "id": "9d2c…", "name": "deck.pdf", "url": "…/9d2c….pdf" } } ``` Uploading the same PDF again returns the existing document item. `GET /media` lists document items with `type: "document"`. Deleting a document item removes its page images and, when no split slide still points at it, the kept PDF. `POST /media/{id}/split` turns a document item into one image item per page (copies of the page images, each carrying `pdf.page`), for the case where a single page is needed as a normal image. The document item stays. A full, ordered set of those slides still publishes the original PDF on LinkedIn. The default stays `pdf_mode: "slides"` so existing integrations that read one media id per page keep working. ## Inline URLs on a post If you only need the media for a single post, skip the media library entirely. Pass URLs directly in `media_urls` on the post body. Note the size split: `media_urls` on a post caps at 100 MB per file, while `upload-from-url` handles up to 1 GB; for big videos, upload first and pass the returned media id. ```json { "content": { "default": "Check this out" }, "accounts": ["your-account-id"], "media_urls": ["https://example.com/photo.jpg"] } ``` The API downloads, validates, and attaches the file automatically. The maximum number of items is platform-dependent (Instagram, Threads, Telegram and Bluesky up to 10, TikTok up to 35, X and Mastodon up to 4, Pinterest up to 5, Google Business 1). Use the media library (via `upload` or `upload-from-url`) when the same file appears on multiple posts, to save on download bandwidth. Any entry, in `media_urls` or `media_ids`, can be an object instead of a bare string to carry an accessibility description (alt text, max 1500 characters): `{ "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" }` (or `{ "id": "123", "alt": "..." }` for library media). See [Attaching media](https://docs.omnisocials.com/creating-posts.md#attaching-media) for which platforms receive it. ## Presigned uploads When the uploading party cannot send an `Authorization` header (a browser form, an AI sandbox, a third-party service), mint a one-time upload URL instead of sharing your API key: ```bash curl -X POST https://api.omnisocials.com/v1/media/create-upload-url \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` The response contains an `upload_url` of the form `https://api.omnisocials.com/v1/media/upload-by-token?upload_token=...`. POST a multipart form with a `file` field to that URL, with no auth header; the token in the URL is the auth. Tokens are single-use and expire after 10 minutes. The upload response matches `POST /media/upload` (including the PDF `media_ids` expansion). ## Folders Organize the library into folders. Reading uses `media:read`, writing uses `media:write`. | Endpoint | Description | |----------|-------------| | `GET /folders` | List folders | | `POST /folders` | Create a folder | | `PATCH /folders/:id` | Rename a folder | | `DELETE /folders/:id` | Delete a folder | Move a file into a folder with `PATCH /media/:id` and a `folder_id`; filter the library by folder with `GET /media?folder_id=`. ## Searching the library `GET /media` takes four optional query parameters: | Parameter | Description | |-----------|-------------| | `limit` | Items per page, 1 to 100 (default 20) | | `offset` | Skip this many items | | `search` | Case-insensitive match on the media name and filename | | `folder_id` | Only items in this folder | Use `search` to find an existing asset by name instead of re-uploading it. ## Supported formats | Media type | Formats | |------------|---------| | Images | JPEG, PNG, GIF, WebP, HEIC/HEIF (converted to JPEG on upload) | | Videos | MP4, MOV, AVI | | Documents | PDF (rasterized into page images; one item per page, or one document item with `pdf_mode: "document"`; see above) | Platform-specific size and duration requirements are enforced when a post is scheduled or published, not at upload. A video can upload successfully and still fail to publish to Instagram if it exceeds Instagram's length limit. Aspect ratio is handled at post time too, in two steps: a feed image outside what a channel shows uncropped is cropped or kept per channel and reported in the post response (`media_fit`, see [Image fit per channel](https://docs.omnisocials.com/creating-posts.md#image-fit-per-channel)), and the hard limits that remain (Instagram feed images outside 9:16 to 1.91:1, Pinterest carousel slides at mixed ratios) return `400 validation_error`. See the [Platforms](https://docs.omnisocials.com/platforms.md) section for per-platform media specs. ## Check compatibility before posting `POST /media/check` tells you, before you post, how a file does on your connected channels. Pass one of `url` (size and type are read with a HEAD request), `media_id` (a library file) or `size_bytes` plus `mime`. It returns `connected_platforms`, a `compatible` boolean and per-platform `warnings` (size and format, with reasons like `max 300 MB`). Every upload response carries the same `compatibility` block. For a library image (`media_id`) the response also carries `file.width` / `file.height` and a `fit` array: one row per connected channel with an image fit rule, saying how that channel shows the image as a feed post. ```json { "file": { "size_bytes": 812345, "mime": "image/jpeg", "size_known": true, "width": 1080, "height": 1920 }, "connected_platforms": ["instagram", "pinterest", "linkedin"], "compatible": true, "warnings": [], "summary": null, "fit": [ { "platform": "instagram", "display_name": "Instagram", "fits": false, "ratio": "9:16", "shown": "cut", "target": "4:5", "default_action": "crop", "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. A post crops it to 4:5 unless media_fit says \"keep\"." }, { "platform": "pinterest", "display_name": "Pinterest", "fits": false, "ratio": "9:16", "shown": "cut", "target": "2:3", "default_action": "keep", "message": "Pinterest: This image is 9:16. Pinterest may cut a Pin taller than 2:3 in the home feed. The Pin page shows it whole. Pinterest cuts it to fit." } ] } ``` `fits: false` means the ratio is outside the window the channel shows uncropped; `default_action` is what a post create does when `media_fit` is not set (`crop` on Instagram, `keep` on Pinterest, `null` when the image fits). Only images with a known size get rows, so videos, `url` checks and files uploaded before July 2026 answer an empty `fit`. The check is advisory: the file still posts to every channel that accepts it. ## Managing media | Endpoint | Description | |----------|-------------| | `GET /media` | List everything in the library | | `GET /media/:id` | Fetch a single media record | | `PATCH /media/:id` | Rename a media file or move it to a folder | | `DELETE /media/:id` | Delete from the library | | `POST /media/check` | Preflight-check a file's compatibility with your connected platforms, including how each channel shows an image as a feed post (`fit`, see above) | | `POST /media/upload-from-base64` | Upload a base64-encoded file | | `POST /media/:id/split` | Turn a PDF document item into one image item per page | | `POST /media/create-upload-url` | Mint a one-time presigned upload URL (see above) | Reading media (`GET /media`, `GET /media/:id`) requires the `media:read` scope; all writes require `media:write`. Deleting a media file that is still attached to a scheduled or in-progress post returns `409 media_in_use`. Deleting one that is only on already-published posts succeeds and does not remove it from those live posts. --- ## Document: Social media analytics API: post and account metrics Pull post-level metrics, workspace overviews, follower counts, and data-driven best posting times for 12 social channels through one API. Includes bulk retrieval for up to 100 posts per call. URL: https://docs.omnisocials.com/analytics # Social media analytics API: post and account metrics Read the performance of everything you publish: per post, per platform, per account, and as a workspace roll-up. The API also computes recommended posting times from your own history. All analytics endpoints require the `analytics:read` scope on your API key. Every endpoint reads stored snapshots from the OmniSocials database. No endpoint calls the social platforms live, so responses are fast and never consume platform rate limits. | Endpoint | What it returns | |----------|-----------------| | `GET /analytics/posts/:id` | Latest per-platform metrics for one post | | `GET /analytics/posts?ids=` | Bulk version: up to 100 posts in one call | | `GET /analytics/overview` | Workspace totals, engagement rate, platform breakdown | | `GET /analytics/accounts` | Account-level snapshots (followers, profile views, and more) | | `GET /analytics/best-times` | Recommended posting slots per platform | ## How metrics are collected OmniSocials collects metrics on a schedule, starting right after a post publishes: | Post age | Refresh interval | |----------|------------------| | 0-7 days | Every 2 hours | | 8-30 days | Every 24 hours | | 31-365 days | Every 7 days | | Stories | Every hour for the story's 24-hour lifetime | **X is the exception.** X charges for every read of a post, so an X post is read a fixed number of times: on its first refresh after it publishes, and then once after day 1, day 3, day 7 and day 30. After day 30 the numbers stay as they are. In our data an X post has 82% of its final views after a day and a half, 98% after a week and all of them before day 30. Two consequences: 1. A post published minutes ago can legitimately report zeros. The platforms themselves need time to count views, so check back after the first refresh window. 2. Metrics for a 6-month-old post can be up to a week old. `collected_at` on every response tells you exactly when the numbers were captured. Account-level snapshots (followers, profile views) are collected once per day. ## Get analytics for one post Use the numeric OmniSocials post id (the `id` field from `GET /posts`), not a platform post id such as a tweet id or YouTube video id. A platform id returns `400 invalid_post_id`. ```bash curl https://api.omnisocials.com/v1/analytics/posts/1024 \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` Response: ```json { "data": { "post_id": "1024", "platforms": { "instagram": { "platform": "instagram", "platform_post_id": "17895695668004550", "metrics": { "likes": 214, "comments": 12, "shares": 9, "impressions": 4880, "reach": 3990, "engagement": 235 }, "thread_parts": 1, "collected_at": "2026-08-19T14:00:11Z" }, "linkedin": { "platform": "linkedin", "platform_post_id": "urn:li:share:7231...", "metrics": { "likes": 88, "comments": 6, "impressions": 2143 }, "thread_parts": 1, "collected_at": "2026-08-19T14:00:12Z" } } } } ``` One entry per platform the post went to. The metric fields inside `metrics` are platform-specific, stored as the platform reports them. A missing key means the platform does not report it; a `0` is a measured zero. A post with no collected analytics yet returns an empty `platforms` object, not a 404. Two keys that are easy to misread: - `reach` (unique accounts reached) is absent, never `0`, on platforms that have no reach metric: X, Pinterest, YouTube, Threads, Bluesky, Mastodon and Telegram, and on Facebook: Meta retired Facebook post reach in June 2026 and its replacement metric returns no data, so no Facebook post has a usable reach through the API. Sum reach only over the entries that carry it. - `link_clicks` is clicks on links in the post: X `url_link_clicks` (posts under 30 days old), LinkedIn profile `LINK_CLICKS`, Facebook link clicks from `clicks_breakdown`, Pinterest outbound clicks, Instagram story link sticker taps. `clicks` is the platform's broadest click count (Facebook `post_clicks`, LinkedIn Page `clickCount`, which LinkedIn cannot split into link clicks). ### Metric keys by platform | Platform | Keys you can expect | |----------|---------------------| | Instagram | `views`, `reach`, `likes`, `comments`, `saves`, `shares`, `reposts`, `engagement`, `profile_visits`, `follows`, `profile_activity` + `profile_activity_breakdown`. Reels add `avg_watch_time`, `total_watch_time`, `watch_time_percentage`, `skip_rate`, `duration`. Stories add `replies`, `navigation` + `navigation_breakdown`, `link_clicks`, `completion_analysis`. | | Facebook | `views`, `likes`, `total_reactions`, `reactions` (by type, including `care`), `comments`, `shares`, `clicks`, `clicks_breakdown`, `link_clicks`, `video_views`, `avg_watch_time`, `total_watch_time`. Reels add `replays`, `follows`. | | LinkedIn profile | `impressions`, `reach`, `likes`, `reactions` (by type), `comments`, `top_level_comments`, `shares`, `saves`, `sends`, `clicks`, `link_clicks` (same number: LinkedIn's click metric is link clicks), `follows`, `profile_visits`. | | LinkedIn page | `impressions`, `unique_impressions`, `likes`, `reactions`, `comments`, `shares`, `clicks`, `engagement_rate`, plus video keys for video posts. | | YouTube | `views`, `likes`, `comments`, `duration`. Watch time, shares, engaged views, subscriber changes and traffic sources are not collected. | | TikTok | `views`, `likes`, `comments`, `shares`, `duration`, `share_url`. With the Business API authorization: `average_time_watched`, `full_video_watched_rate`, `completion_rate`, `watch_time_percentage`, `total_time_watched`, `favorites`, `reach`, `follows`, `profile_visits`, `impression_sources`, `audience_types`, `audience_countries` (see [TikTok watch-depth analytics](https://docs.omnisocials.com/platforms/tiktok.md#watch-depth-analytics)). | | X | `views`, `likes`, `comments`, `reposts`, `quotes`, `bookmarks`. For posts under 30 days old: `link_clicks`, `profile_visits`, `engagement`. Video posts add `video_views`, `completion_rate`, `duration`. | | Pinterest | `impressions`, `saves`, `pin_clicks`, `outbound_clicks`, `link_clicks` (same as outbound clicks), `likes`, `comments`, `profile_visits`, `follows`, `save_rate`, `pin_click_rate`, `outbound_click_rate`, `engagement`. Video pins add `video_views`, `video_starts`, `completion_rate`, `avg_watch_time`, `total_watch_time`. | | Threads | `views`, `likes`, `replies`, `reposts`, `quotes`, `shares`, `engagement`. | | Bluesky | `likes`, `comments`, `reposts`, `quotes`, `bookmarks`. Bluesky exposes no views. | | Mastodon | `likes`, `comments`, `reposts`, `quotes`, `poll` (votes per option), `tags`. Mastodon exposes no views. | | Telegram | `likes` (all reactions together), `reactions` (count per emoji), `poll` (votes per option). Telegram gives bots no views, forwards or comment counts. | | Google Business | No per-post metrics (Google discontinued them in 2023). The entry carries `state`, `search_url`, `metrics_unavailable: true` and a `note`. | Durations are in seconds, rates in percent (0 to 100). Breakdown objects (`reactions`, `clicks_breakdown`, `impression_sources`, ...) map a category to a count or a percentage. ### Threads count as one post On X, Bluesky, Mastodon, and Threads a post can publish as a thread of several parts. The API sums each part's metrics into the platform entry, so you get the thread's true total (the same number the OmniSocials dashboard shows). `thread_parts` tells you how many parts were summed, 1 for a normal post, and `platform_post_id` is always the thread's root part. Multi-slide stories work the same way: each slide is its own story on Instagram or Facebook, so the platform entry sums the slides and `thread_parts` is the number of slides with stats. The entry also carries a `story_slides` array with each slide's own metrics in publish order: ```json "instagram": { "platform": "instagram", "platform_post_id": "1789...", "metrics": { "views": 910, "reach": 620, "replies": 3, "content_type": "story" }, "thread_parts": 2, "story_slides": [ { "index": 0, "platform_post_id": "1789...", "metrics": { "views": 540, "reach": 400, "replies": 2 }, "collected_at": "2026-08-21T12:00:00Z" }, { "index": 1, "platform_post_id": "1790...", "metrics": { "views": 370, "reach": 220, "replies": 1 }, "collected_at": "2026-08-21T12:00:00Z" } ], "collected_at": "2026-08-21T12:00:00Z" } ``` `story_slides` is absent on single-slide stories and on every other post type. ## Bulk: up to 100 posts per call One request to `GET /analytics/posts` replaces up to 100 single-post calls. It exists so a nightly sync doesn't burn the [100 requests/minute rate limit](https://docs.omnisocials.com/rate-limits-and-errors.md). ```bash curl "https://api.omnisocials.com/v1/analytics/posts?ids=1024,1025,1026" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` The response `data` array returns one entry per requested id, in request order. Ids with no analytics yet come back with an empty `platforms` object rather than being omitted, so you can map results 1:1 to what you sent. More than 100 ids returns `400 too_many_ids`; page through larger sets in batches. ## Workspace overview Roll-up across every platform for a time window: ```bash curl "https://api.omnisocials.com/v1/analytics/overview?period=30d" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` ```json { "data": { "total_posts": 42, "total_platforms": 5, "total_engagement": 3811, "total_impressions": 92150, "average_engagement_rate": 4.13, "top_performing_platform": "instagram", "platform_breakdown": { "instagram": { "posts": 18, "total_engagement": 2410, "total_impressions": 61200, "average_engagement": 133.9, "engagement_rate": 3.94 } } }, "period": "30d", "start_date": "2026-07-21", "end_date": "2026-08-20", "current_date": "2026-08-20" } ``` Pick the window one of two ways: - `period`: `7d`, `30d` (default), or `90d`. - `start_date` + `end_date`: accepts `YYYY-MM-DD`, a `YYYY-MM` month shorthand (`2026-04` expands to the whole month), or a full ISO 8601 datetime. The response echoes the resolved `start_date`, `end_date`, and the server's `current_date`, so a client (or an AI agent) can verify what was actually queried instead of guessing. ## Account analytics Daily snapshots of account-level metrics for every connected account. Each snapshot holds stock values (`followers`, `following`, `posts`, and platform extras such as Pinterest `monthly_views`, YouTube `total_views`, X `listed`, Google Business `average_rating` and `review_count`) and day values for the snapshot date (`impressions`, `reach`, `engagement`, `likes`, `comments`, `shares`, `profile_views`, `link_clicks`, `follows_gained`, `follows_lost`, and platform extras such as Google Business `calls`, `direction_requests`, `website_clicks`). Rows with day values carry `period: "daily"`. Audience data, when the platform provides it: `demographics` (age, gender, country, city on Instagram, Threads and TikTok, which need 100 or more followers; function, seniority, industry and company size on LinkedIn pages, no minimum) with `demographics_unit`, and `online_followers` (UTC hour to followers online) for Instagram and TikTok Business accounts. ```bash curl "https://api.omnisocials.com/v1/analytics/accounts?platform=instagram" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` `date` (`YYYY-MM-DD`) returns the latest snapshot on or before that date; it defaults to today. To measure growth over a window, diff two snapshots. For example, compare `date=2026-08-01` against `date=2026-07-01`. ### Metric semantics vary by platform The scope of an account metric is platform-defined, and it differs. Where the semantics are non-obvious, the `metrics` object carries a `note` field explaining them. The important ones: - **LinkedIn profile impressions.** Profiles keep a lifetime cumulative total under `impressions_lifetime` (all of the account's content, including posts published outside OmniSocials). When LinkedIn serves a daily breakdown, `impressions` is that day's value and the row carries `period: "daily"`; otherwise it carries `period: "lifetime"`. Pages always report day values. - **Instagram and Threads day values** come from the platform insights for that date. A missing field means "not reported by the platform for this account", not zero. Accounts under 100 followers get no demographics or audience-online data. - **YouTube** account rows hold stock values only: `subscribers` (when the channel shows its count), `total_views` and `total_videos`. - **TikTok day values** need the TikTok Business authorization. Without it the snapshot holds the stock values only. - **Telegram** account rows hold the subscribers of a channel, or the members of a group, as `followers`. Telegram gives bots no day values. - **Google Business Profile has no follower concept**, and Google deprecated per-post insights. Account rows hold a 30-day `daily` series (impressions split by surface in `impressions_breakdown`, calls, direction requests, website clicks, bookings, food orders, menu clicks), `average_rating`, `review_count`, and monthly `search_keywords`. Google's data lags 2 to 3 days; `data_date` says which day the top-level values describe. ## Best posting times Recommended day/hour slots for a platform, computed from your workspace's own posting history: publish time crossed with engagement for every analyzed post, recency-weighted and outlier-damped. ```bash curl "https://api.omnisocials.com/v1/analytics/best-times?platform=tiktok&timezone=Europe/Amsterdam" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` The response contains a `grid` (one scored cell per day/hour that has data, scores relative 0-100 within the platform) and `recommendations`: the top 3 slots, never two on the same day within 3 hours of each other. Check the `basis` field before trusting the numbers: - `own_data`: computed from your workspace's posts. - `own_data_and_audience`: your posts blended with when your followers are online (Instagram, TikTok Business). The response also carries `audience_online` (hour to followers online, in the response timezone). - `audience`: fewer than 15 analyzed posts, so the recommendation comes from the audience-online profile only. - `defaults`: a static industry-average table, returned when the workspace has fewer than 15 analyzed posts on the platform and no audience data. `posts_needed` says how many more published posts unlock personalized recommendations. Times are expressed in the `timezone` you pass (any IANA identifier). It defaults to your account's timezone, then UTC. ## How engagement is calculated Every OmniSocials surface (dashboard, API, CLI, MCP server) normalizes metrics the same way: - **Engagement** is the sum of the platform's real interaction fields. Where a platform provides a precomputed total (Instagram's `total_interactions`, for example) we use it; otherwise we sum likes, comments, shares, reposts, quotes, and the platform's other interaction counts. For LinkedIn this includes link clicks, matching LinkedIn's own definition. For X it includes quotes and bookmarks. - **Impressions** is the best available "how many times was this seen" number: total views where the platform provides them, falling back to impressions, then reach. - **Engagement rate** is engagement divided by impressions, as a percentage capped at 100. Below 10 impressions the rate is reported as 0, because a tiny denominator produces a meaningless percentage, not a signal. ## Troubleshooting | Symptom | Cause | |---------|-------| | `400 invalid_post_id` | You passed a platform post id (tweet id, YouTube video id). Use the numeric `id` from `GET /posts`. | | Empty `platforms` object | No analytics collected yet. The post is very fresh, still scheduled, or failed to publish. | | Zeros right after publishing | Normal. Platforms need time to count; the first refresh lands within 2 hours. | | Google Business entry has `metrics_unavailable: true` | Google deprecated per-post insights; see the `note` in the response. Account-level metrics still work. | | A metric key is missing for one platform | The platform does not report it (for example views on Bluesky and Mastodon), or the account lacks the permission (TikTok Business authorization). | | LinkedIn `impressions_lifetime` looks far too big | It is a lifetime total, not a window. Use `impressions` with `period: "daily"`, or diff two snapshots. | ## Other ways to read analytics - **CLI**: [`omnisocials analytics:overview`, `analytics:post`, `analytics:best-times`](https://docs.omnisocials.com/cli/analytics.md) return the same data in your terminal, with `--json` for pipelines. - **MCP server**: the [`get_post_analytics`, `get_posts_analytics`, `get_analytics_overview`, `get_account_analytics`, and `get_best_times` tools](https://docs.omnisocials.com/mcp-server.md) let Claude, ChatGPT, and other AI agents answer "how did last week perform?" directly. - **Webhooks**: subscribe to post events instead of polling. See [Webhooks](https://docs.omnisocials.com/webhooks.md). --- ## Document: Social inbox API: DMs, comments and mentions Read and reply to DMs, comments, and mentions across platforms with the OmniSocials Social Inbox API: conversations, pagination, the next-unanswered work queue, hiding and deleting comments, reply limits, and errors. URL: https://docs.omnisocials.com/inbox # Social inbox API: DMs, comments and mentions Read and reply to your Instagram and Facebook DMs, comments, and mentions, LinkedIn company-page comments and mentions, TikTok and YouTube video comments, and Threads replies and mentions, all from the API. Conversations are served from OmniSocials, so you read at your own pace without hitting each platform's rate limits, and replies go out through your connected accounts. There is also a work queue, `GET /inbox/next`, that hands you the next thing that needs an answer together with its conversation and post, and comment moderation (hide, delete) on the platforms that allow it. See [Working through the inbox](#working-through-the-inbox) and [Hiding and deleting comments](#hiding-and-deleting-comments). X (Twitter) DMs are also available as a per-workspace opt-in, see [X direct messages](#x-direct-messages). Threads replies and mentions are covered too, see [Threads replies and mentions](#threads-replies-and-mentions). ## Enabling inbox access The inbox scopes are opt-in. When you create an API key, turn on **Social Inbox access** to grant both inbox scopes. Existing keys are not given these scopes automatically. | Scope | Grants | |-------|--------| | `inbox:read` | List conversations, read messages, and pull the next unanswered item | | `inbox:write` | Mark conversations read, send replies, hide and delete comments | When you connect through OAuth or the MCP server, inbox access is governed by the `access_social_inbox` workspace permission instead of scopes. Company owners and admins have it by default. ## Listing conversations Returns one entry per conversation, newest activity first. ```bash curl "https://api.omnisocials.com/v1/inbox/conversations?platform=instagram&unread=true&limit=25" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` Response: ```json { "data": [ { "conversation_id": "instagram_17841400000_17841464825884163", "platform": "instagram", "type": "dm", "participant": { "id": "17841400000", "name": "Amelia Rivera", "username": "amelia.rivera", "profile_picture": "https://..." }, "unread_count": 2, "last_message": { "id": "78027900", "direction": "incoming", "text": "Do you ship internationally?", "timestamp": "2026-07-19T17:05:51Z", "is_read": false }, "post": null } ], "pagination": { "next_cursor": "MjAyNi0wNy0xOF...", "has_more": true, "limit": 25 } } ``` Query parameters (all optional): | Parameter | Description | |-----------|-------------| | `platform` | `instagram`, `facebook`, `linkedin`, `tiktok`, `youtube`, `x`, or `threads` | | `type` | `dm`, `comment`, or `mention` | | `unread` | `true` returns only conversations with unread incoming messages | | `unanswered` | `true` returns only conversations that still need an answer: the person's latest DM has no reply after it (Instagram and Facebook DMs past the 24-hour messaging window included: they cannot be answered through the API, but the person is still waiting), or a comment or mention has not been replied to and is not hidden. Replies sent from the native apps count as answers. Read state is ignored here; use `GET /inbox/next` for a work queue. See [What counts as unanswered](#what-counts-as-unanswered) | | `limit` | Conversations per page, 1 to 100 (default 25) | | `cursor` | The `next_cursor` from a previous response | `post` is present for comment and mention conversations (the post the interaction is on) and `null` for DMs. See [Post context on comments](#post-context-on-comments). ## Cursor pagination The inbox list endpoints use cursor pagination, not offset. Every response carries a `pagination` object: ```json "pagination": { "next_cursor": "MjAyNi0w...", "has_more": true, "limit": 25 } ``` To fetch the next page, pass `next_cursor` back as the `cursor` query parameter. When `has_more` is `false`, `next_cursor` is `null` and you have reached the end. ## Reading a conversation Returns the full message history, oldest first. Also cursor-paginated (default `limit` 50). ```bash curl "https://api.omnisocials.com/v1/inbox/conversations/CONVERSATION_ID/messages" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` Response: ```json { "data": [ { "id": "20457105", "conversation_id": "instagram_17841400000_17841464825884163", "platform": "instagram", "type": "dm", "direction": "incoming", "text": "Do you ship internationally?", "timestamp": "2026-07-19T17:05:51Z", "is_read": false, "is_replied": false, "reaction": null, "parent_comment_id": null, "hidden": null, "permalink": null, "attachment": null, "sender": { "id": "17841400000", "name": "Amelia Rivera", "username": "amelia.rivera", "profile_picture": "https://..." }, "post": null } ], "pagination": { "next_cursor": null, "has_more": false, "limit": 50 } } ``` `direction` is `incoming` for messages from the other person and `outgoing` for your replies. Replies sent from the platform's own app show up as `outgoing` too, see [Replies sent from the native apps](#replies-sent-from-the-native-apps). `permalink` links to the reply or mentioning post on the platform when one is known, `null` otherwise. `hidden` is set on comments and mentions: `true` when the comment is hidden on the platform (see [Hiding and deleting comments](#hiding-and-deleting-comments)), `false` when it is visible. It is `null` on DMs. ### Media on a message `attachment` is set on messages that carry media, and `null` otherwise. It carries `url` and `type` (`image`, `video`, `audio`, or `file`): ```json "attachment": { "url": "https://cdn.omnisocials.com/file/library-omnisocials/social-inbox/985468/inbound/1756540800000-a1b2c3.jpg", "type": "image" } ``` This applies to inbound Instagram and Facebook DM photos, videos, voice messages, and Instagram story mentions, and to any reply you sent with `attachment_url`. Instagram/Facebook's own attachment URL is short-lived (it typically stops resolving within hours); we download it once when the message arrives and re-host it on our own CDN, so `attachment.url` keeps working indefinitely. `type` reflects the fetched bytes, not the platform's label: a photo the platform sent under an unlabelled attachment type is `image`, and a story mention that is really an MP4 is `video`. `type: "file"` means we fetched the media but could not classify it (treat it as an opaque download). `type: "link"` means the platform's URL did not point at media, for example a shared post that resolves to an instagram.com page; `url` is then the platform's own link, not re-hosted, and may require login or expire. ### A note on conversation IDs Conversation IDs are opaque strings. Some, LinkedIn especially, contain characters like `:` and `()` (for example `linkedin_comment_urn:li:activity:7484628878586646528`). Always URL-encode the ID when you place it in a request path. Our SDKs handle this for you. ## Post context on comments Comment and mention conversations carry the post they belong to, on the conversation and on every message in it, so a reply can be drafted with the post in view: "what size is this?" needs the caption and the picture. DMs have `post: null`. ```json "post": { "id": "17895695668004550", "caption": "New autumn colours are in. Which one is yours?", "thumbnail": "https://...", "url": "https://www.instagram.com/p/C9xYz1AbCdE/", "media_type": "CAROUSEL_ALBUM" } ``` | Field | Description | |-------|-------------| | `id` | The platform's id of the post | | `caption` | The post text, when known | | `thumbnail` | Image URL of the post, or the video's cover | | `url` | Public link to the post on Instagram, Facebook, YouTube, TikTok, LinkedIn, or Threads, when the platform provides one. `null` otherwise | | `media_type` | The platform's own label for the post, for example `IMAGE`, `VIDEO`, or `CAROUSEL_ALBUM` on Instagram, when known. `null` otherwise | Instagram stores the link and media type when the comment arrives. Instagram conversations that reached the inbox before September 6, 2026 get `url` and `media_type` filled in the first time you read them through the API. ## Marking a conversation read Marks every unread incoming message in the conversation as read. ```bash curl -X POST "https://api.omnisocials.com/v1/inbox/conversations/CONVERSATION_ID/read" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` Response: ```json { "conversation_id": "instagram_17841400000_17841464825884163", "marked_read": 2 } ``` Marking read also takes the conversation out of the [work queue](#working-through-the-inbox) for good, so this is how you skip an item you do not want to answer. ## Replying Sends a reply in an existing conversation. Works for DMs, comments, and mentions. The reply `text` caps at 2,000 characters (150 on TikTok comments); longer text returns `400 validation_error`. ```bash curl -X POST "https://api.omnisocials.com/v1/inbox/conversations/CONVERSATION_ID/reply" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Yes, we ship worldwide!" }' ``` Response: ```json { "data": { "id": "22380029", "conversation_id": "instagram_17841400000_17841464825884163", "platform": "instagram", "type": "dm", "direction": "outgoing", "text": "Yes, we ship worldwide!", "timestamp": "2026-07-19T15:25:03Z", "is_read": true, "is_replied": false, "sender": { "id": "you", "name": "You", "username": "you", "profile_picture": null }, "post": null } } ``` On Facebook and Instagram DMs you can attach media by passing `attachment_url` together with `attachment_type` (`image`, `video`, `audio`, or `file`). `text` is optional when `attachment_url` is set: an attachment-only reply is allowed. Other platforms are text-only; `attachment_url` is ignored there. ### Replying to a specific comment Every comment on a post shares one conversation. Pass `message_id`, the inbox id of the specific incoming comment or mention you are answering (`message.id` from `GET /inbox/next`, or a message `id` from [Reading a conversation](#reading-a-conversation)), so the reply lands under that comment. Without it the reply is posted under the newest incoming comment on the post, which may be a different person than the one you drafted for. Always pass it when replying to an item served by the queue. It is ignored for DMs: a DM reply goes to the conversation. The outgoing message's `parent_comment_id` is the platform id of the comment you answered. ```bash curl -X POST "https://api.omnisocials.com/v1/inbox/conversations/CONVERSATION_ID/reply" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Back in stock next week!", "message_id": "101581150" }' ``` A `message_id` that is not an incoming message of this conversation returns `404 not_found`; one that is not numeric returns `400 validation_error`. ### Chaining to the next item Pass `include_next: true` in the body and the response also carries `next`, the next conversation that needs an answer (same shape as `GET /inbox/next`, with its default order and filters), and `remaining`, the number of unanswered items still waiting after it. This saves the extra call when you are working through the inbox. ```bash curl -X POST "https://api.omnisocials.com/v1/inbox/conversations/CONVERSATION_ID/reply" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Yes, we ship worldwide!", "message_id": "101581150", "include_next": true }' ``` ```json { "data": { "id": "22380029", "direction": "outgoing", "text": "Yes, we ship worldwide!", "...": "..." }, "next": { "conversation": { "conversation_id": "instagram_comment_17895695668004550", "type": "comment", "...": "..." }, "message": { "id": "20457211", "text": "Is the green one back in stock?", "...": "..." }, "messages": [ "..." ], "reply_window": { "open": true, "closes_at": null } }, "remaining": 11 } ``` `next` is `null` and `remaining` is `0` when nothing is waiting. The reply itself is never affected: if the queue lookup fails, the reply still goes out and `next` comes back `null`. ### Reply rules Platform and policy limits apply. Handle these responses: | Situation | Response | |-----------|----------| | The conversation does not exist in this workspace | `404 not_found` | | `message_id` is not an incoming message of this conversation | `404 not_found` | | `message_id` is not numeric | `400 validation_error` | | The comment or message you reply to was deleted, hidden, or restricted on the platform | `404 not_found` | | `text` is longer than the platform allows (2,000 overall, 1,000 on Instagram DMs, 150 on TikTok comments) | `400 validation_error` | | Replying to an Instagram or Facebook DM more than 24 hours after the person's last message (Meta policy). Checked before the send, so nothing goes out; the message names when the window closed, and `GET /inbox/next` reports the same in `reply_window` | `422 outside_messaging_window` (answer that DM from the Instagram or Facebook app, which is mirrored into the inbox, or mark the conversation read to skip it; do not retry) | | The same reply (same text and attachment) already went out to this comment or DM conversation in the last 10 minutes, or is being sent right now | `409 duplicate_reply` (nothing was sent again; `data` carries the earlier message) | | The platform refuses replies to this item for good: Facebook `#1705` (the comment sits on a share or boosted copy of the post, or the commenter restricts replies), or a YouTube thread whose `canReply` is false (comments are off on the video, or the comment was removed or held for review) | `422 reply_not_allowed` (the item is dropped from `GET /inbox/next`; do not retry) | | The workspace has sent 1,000 replies in the last 24 hours | `429 reply_limit_reached` | | The key or role lacks inbox access | `403 insufficient_scope` | | The platform rejected the reply | `502 platform_error` | | The connection lost a needed permission (TikTok comments, LinkedIn, or the Threads reply permission) | `401 reauth_required` | | An X DM reply, but the credit balance cannot cover it | `402 insufficient_credits` | | The workspace's X inbox is paused because credits ran out | `402 x_inbox_suspended` | You can only reply to conversations that already exist in your inbox. The API cannot start a new conversation: Instagram requires the person to message you first, and cold outreach is against platform policy. Direct-message replies must be sent within the platform's 24-hour messaging window. Each workspace can send up to 1,000 replies per rolling 24 hours; contact robert@omnisocials.com if you need a higher limit. ## Working through the inbox `GET /inbox/next` is a work queue. It returns the one item that should be answered next, together with the conversation so far and the post it belongs to, so you can draft a reply from a single call. Items are served in this order: 1. DMs that can still be answered: Instagram and Facebook DMs inside Meta's 24-hour messaging window, the one whose window closes soonest first, and X DMs, which have no window. 2. Instagram and Facebook DMs whose 24-hour window has closed. They are served with `reply_window.open = false`: the API cannot reply to them, but the person is still waiting. Answer them from the Instagram or Facebook app (that reply is mirrored into the inbox and clears the item), or mark the conversation read to skip it. 3. Comments and mentions, oldest first. `order=newest` reverses the order within each group. ```bash curl "https://api.omnisocials.com/v1/inbox/next" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` Response: ```json { "data": { "conversation": { "conversation_id": "instagram_comment_17895695668004550", "platform": "instagram", "type": "comment", "participant": { "id": "17841400000", "name": "Amelia Rivera", "username": "amelia.rivera", "profile_picture": "https://..." }, "unread_count": 1, "last_message": { "id": "20457211", "direction": "incoming", "text": "Is the green one back in stock?", "timestamp": "2026-09-05T09:12:40Z", "is_read": false }, "post": { "id": "17895695668004550", "caption": "New autumn colours are in. Which one is yours?", "thumbnail": "https://...", "url": "https://www.instagram.com/p/C9xYz1AbCdE/", "media_type": "CAROUSEL_ALBUM" } }, "message": { "id": "20457211", "conversation_id": "instagram_comment_17895695668004550", "direction": "incoming", "text": "Is the green one back in stock?", "is_read": false, "is_replied": false, "hidden": false, "...": "..." }, "messages": [ "..." ], "reply_window": { "open": true, "closes_at": null } }, "remaining": 11 } ``` - `message` is the unanswered incoming message itself: the person's latest DM, or the specific comment. Its `conversation_id` is what the reply endpoint takes, and its `id` is the `message_id` to pass along with the reply for comment threads, so the reply lands under this comment and not under the newest one on the post. The same `id` is what the hide and delete endpoints take. - `messages` is the conversation so far, oldest first (the most recent 50 messages for long DM threads). - `reply_window` says whether a reply can still be sent through the API. Only Instagram and Facebook DMs have a window (`closes_at` is the person's last message plus 24 hours); every other item has `open: true` and `closes_at: null`. When `open` is `false` the item is still served, because the person is still waiting, but the reply endpoint answers `422 outside_messaging_window`: answer it from the native app or mark the conversation read. - `remaining` is how many unanswered items are still waiting after this one, capped at 500. - `data` is `null` and `remaining` is `0` when the queue is empty. ### What counts as unanswered - A DM needs an answer when the person's latest message has no reply after it. Instagram and Facebook DMs past Meta's 24-hour messaging window are included too, flagged with `reply_window.open = false`: the API cannot reply to them, but the person is still waiting. - A comment or mention needs an answer when nobody replied to it and it is not hidden. - Replies sent from the native apps count as answers (see [Replies sent from the native apps](#replies-sent-from-the-native-apps)), so a thread a colleague answered on their phone is not served again. - Instagram mentions are skipped, there is no reply path for them. - The queue looks at the last 30 days of activity. The same rules power the `unanswered=true` filter on `GET /inbox/conversations`, which lists everything waiting at once rather than serving one item at a time. The list filter ignores read state; the queue serves unread items only by default. ### Skipping an item Only unread items are served by default, so marking a conversation read (`POST /inbox/conversations/{id}/read`) is the durable skip: the item drops out of the queue and stays out. Pass `include_read=true` to also serve items that were marked read but never answered, for example for an end-of-day sweep. For a temporary skip within one session, pass `exclude` with a comma-separated list of conversation ids (up to 100); those are left out of that call only. ### Query parameters All optional. | Parameter | Description | |-----------|-------------| | `platform` | Only items from one platform: `instagram`, `facebook`, `linkedin`, `tiktok`, `youtube`, `x`, or `threads` | | `type` | Only items of one type: `dm`, `comment`, or `mention` | | `order` | `oldest` (default) serves the item that has waited longest first; `newest` serves the most recent first. Both apply within each group: answerable DMs still come before expired Instagram and Facebook DMs, which come before comments and mentions | | `include_read` | `true` also serves items that were marked read but never answered | | `exclude` | Comma-separated conversation ids to leave out of this call, up to 100 | An unknown `platform`, `type`, or `order` returns `400 validation_error`. ### The loop 1. Call `GET /inbox/next`. 2. Draft a reply from `data.message`, `data.messages`, and `data.conversation.post`. If `data.reply_window.open` is `false`, skip the draft: answer that DM from the Instagram or Facebook app, or mark the conversation read, then call `GET /inbox/next` again. 3. Send it with `POST /inbox/conversations/{conversation_id}/reply`, passing `message.id` as `message_id` (so a comment reply lands under the served comment, not under the newest one on the post) and `include_next: true`. The response carries the sent reply plus `next` and `remaining`. 4. Repeat from step 2 with `next` until it is `null`. To skip an item, mark it read (for good) or add it to `exclude` (this session only). To deal with spam, hide or delete it; a hidden comment no longer counts as unanswered. ## Hiding and deleting comments Comments people leave on your posts can be moderated from the API, as the post owner. Both endpoints take the message `id` from the conversation messages list or from `GET /inbox/next`, not the conversation id, and need the `inbox:write` scope (API keys) or the `access_social_inbox` permission (OAuth/MCP). | Platform | Hide | Delete | |----------|------|--------| | Instagram | Yes | Yes | | Facebook | Yes | Yes | | TikTok | Yes | Yes | | YouTube | Yes, through YouTube's moderation status | No. YouTube's API does not let a channel delete other people's comments; hide instead | | Threads | Yes, top-level replies on your posts only | No | | LinkedIn | No | No | ### Hiding a comment ```bash curl -X POST "https://api.omnisocials.com/v1/inbox/messages/MESSAGE_ID/hide" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "hide": true }' ``` The body is optional; `hide` defaults to `true`, and `{ "hide": false }` unhides. The response returns the updated message under `data` with `hidden` flipped. The comment keeps its place in the conversation, disappears from public view on the platform until you unhide it, and no longer counts as unanswered. On YouTube, hiding sets the comment's moderation status to rejected, which removes the comment and its replies from public view; unhiding publishes it again. On Threads, only incoming top-level replies can be hidden; Threads does not allow hiding nested replies. On Instagram, the API reads the comment back after the call and reports Instagram's own hidden state, so `hidden` on the returned message is what Instagram actually shows. When Instagram accepted the call but still reports the old state, the response is `502 hide_not_applied` and the inbox is left unchanged. | Situation | Response | |-----------|----------| | The message is not an incoming comment on a platform that supports hiding | `400 unsupported_platform` | | The Threads reply is nested, or Threads refused | `400 not_hideable` | | The Threads reply permission or the TikTok comments authorization is missing or expired | `401 reauth_required` | | The account was connected without the comment-moderation permission (Facebook `pages_manage_engagement`, Instagram `instagram_business_manage_comments`); reconnect it under Settings -> Organisation -> Workspaces | `403 reconnect_required` | | The message does not exist in this workspace | `404 not_found` | | No matching account connected to the workspace | `404 account_not_connected` | | Facebook does not let the Page hide (or unhide) this particular comment: the commenter blocked the Page, their account is deactivated or restricted, or the comment sits on a shared copy of the post | `422 cannot_hide` (retrying does not change it; handle it in Facebook, or mark the conversation read to skip it) | | Instagram accepted the hide (or unhide) but still reports the comment in the old state when read back. This happens with comments Instagram shows under "Comments from Facebook" on a reel that is also shared to Facebook: they live on Facebook, and Instagram's hide does not reach them | `502 hide_not_applied` (the inbox is left unchanged; hide it in the Instagram or Facebook app, do not retry) | | YouTube's daily API quota is used up; retry after midnight Pacific | `429 quota_exceeded` | | The platform rejected the call | `502 platform_error` | ### Deleting a comment Deletes the comment on the platform and from the inbox. Facebook, Instagram, and TikTok comments only. This cannot be undone. ```bash curl -X DELETE "https://api.omnisocials.com/v1/inbox/messages/MESSAGE_ID" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` Response: ```json { "data": { "id": "20457211", "conversation_id": "instagram_comment_17895695668004550", "removed_reply_ids": ["20457218", "20457230"] } } ``` Replies under the deleted comment go with it: the platforms cascade the delete and the inbox mirrors that, and their inbox ids come back in `removed_reply_ids`. A comment that is already gone on the platform is still removed from the inbox. | Situation | Response | |-----------|----------| | The message is not an incoming Facebook, Instagram, or TikTok comment | `400 unsupported_platform` | | The TikTok comments authorization expired | `401 reauth_required` | | The account was connected without the comment-moderation permission; reconnect it under Settings -> Organisation -> Workspaces | `403 reconnect_required` | | The message does not exist in this workspace | `404 not_found` | | No matching account connected to the workspace | `404 account_not_connected` | | The platform rejected the call | `502 platform_error` | ## Replies sent from the native apps Replies you or a colleague send from the platform's own app are mirrored into the inbox as `outgoing` messages, so the thread in OmniSocials is complete and the person's message counts as answered. This covers Instagram DMs sent from the Instagram app, Messenger replies sent from Messenger, Meta Business Suite, or another tool, and comment replies posted natively on Instagram and Facebook. The mirrored reply marks the person's message as replied, which also takes it out of the [work queue](#working-through-the-inbox) and the `unanswered` filter. Facebook Pages connected before September 6, 2026 need a one-time reconnect under Settings -> Organisation -> Workspaces to start receiving Messenger replies sent from the native apps, because the Page's webhook subscription gained a new field (or contact support to have it switched on without a reconnect). Instagram works without a reconnect. ## Platform notes | Platform | What is supported | |----------|-------------------| | Instagram | DMs, comments, mentions, and story mentions. The person must have messaged you first to open a DM thread. DM replies can include a media attachment. Comments can be hidden and deleted. Replies sent from the Instagram app are mirrored into the inbox. | | Facebook | Page DMs (Messenger), comments, and mentions. DM replies can include a media attachment. Comments can be hidden and deleted, and liked in the app (not through the API). Replies sent from Messenger or Business Suite are mirrored into the inbox (Pages connected before September 6, 2026 need a reconnect). | | LinkedIn | Company page comments and mentions only. LinkedIn has no DM API, and personal profiles are not supported. Replies post as the organization, text only. Comments can be liked as the Page in the app (not through the API). No hide or delete. Commenters show as "LinkedIn Member", because LinkedIn does not share their names. | | TikTok | Video comments only, after comments are enabled on the TikTok channel. Replies are text only, 150 characters max, and TikTok holds fresh comments and replies in spam review for roughly 15 to 20 minutes. Comments can be hidden and deleted. See [TikTok comments](#tiktok-comments). | | YouTube | Video comments only, YouTube has no DMs. Conversations group per video, and replies are supported. Comments can be hidden through YouTube's moderation status, but not deleted. YouTube has no comment webhooks, so OmniSocials checks each channel for new comments once per day; a new comment can take up to a day to reach the inbox. The standard YouTube connection already covers comments, no extra authorization needed. | | X (Twitter) | DMs only, after the workspace opts in. Uses credits. See [X direct messages](#x-direct-messages). | | Threads | Replies people leave on your posts, and mentions. Replies publish as native Threads replies, and top-level replies on your posts can be hidden. See [Threads replies and mentions](#threads-replies-and-mentions). | ## X direct messages X DMs work differently from the other platforms because X charges for its DM API. They are off by default, and each workspace opts in separately. ### Turning it on In the app, open the Social Inbox and use the **X DMs** card. Enabling requires: - An X account connected to the workspace, with DM permissions on the connection. Accounts connected before DM support shipped need a quick reconnect first; the card tells you when that is the case. - The billing permission, since X DMs spend your organisation's credits. - A credit balance of at least 25 credits. ### What it costs X bills its DM API per use. We pass those rates through as [credits](https://docs.omnisocials.com/billing.md#credits-and-x-link-posts) (purchased at €0.01 per credit), rounded up to whole credits: | Action | Cost | |--------|------| | DM received | 1 credit ($0.010, X's delivery rate) | | DM reply sent | 2 credits (covers X's $0.015 send fee) | A reply that fails to send is refunded automatically. ### Running out of credits If the balance hits zero, the X inbox pauses itself: the message that used the last credit still lands in your inbox, but new DMs stop arriving and the person who enabled X DMs gets an email. Top up and re-enable X DMs to resume. DMs sent to you while paused are not recovered. ### No history, DMs only The inbox receives DMs from the moment you enable it. Conversations from before you enabled are not imported, and neither are messages that arrived while the inbox was paused. X conversations are always type `dm`: comments and mentions on X are not part of the inbox, and replies are DM replies only. ## TikTok comments TikTok comments need their own authorization on top of the regular TikTok connection, so they are off until you enable them per channel. ### Turning it on In the app, go to **Settings -> Organisation -> Workspaces** and press **Enable comments** on the TikTok channel card. It runs a second TikTok authorization for the comment permissions. From then on, new comments on your videos land in the inbox in real time. ### Comments only TikTok conversations are always type `comment`: DMs and mentions on TikTok are not part of the inbox. Commenter names and avatars can briefly show as "TikTok User" until TikTok releases the commenter's identity. You can also like and unlike comments from the inbox in the app; liking is not available through the API yet. Comments can be hidden and deleted through the API, see [Hiding and deleting comments](#hiding-and-deleting-comments). ### Reply rules Replies post from the connected TikTok account and are text only, capped at 150 characters. A longer reply, or one with an attachment, returns `400 validation_error`. If the comments authorization expires, replies return `401 reauth_required`; press **Enable comments** on the channel card again to fix it. ### Spam review delay TikTok holds fresh comments and replies in spam review for roughly 15 to 20 minutes before they are publicly visible on TikTok. A reply that is accepted by the API will appear under the video, just not immediately. ## Threads replies and mentions :::caution **Reconnect once.** The Threads inbox needs the Threads reply permissions. Threads accounts connected before these permissions were added need a one-time reconnect under Settings -> Organisation -> Workspaces; until then Threads conversations do not appear and Threads replies and hides answer `401 reauth_required`. ::: Threads brings two conversation types into the inbox: - **Replies on your posts** (`type: "comment"`): every reply someone leaves under one of your Threads posts. Conversations group per root post (`conversation_id` = `threads_comment_`). - **Mentions** (`type: "mention"`): posts that mention your Threads profile (`conversation_id` = `threads_mention_`). Threads has no DM API, so there are no Threads conversations of type `dm`. ### Reply rules Replying to a Threads conversation publishes a native Threads reply from your connected account: under the person's reply on your post, or under the post that mentioned you. Replies are text only. If the Threads connection is missing the reply permission (connections made before the permission was added), replies return `401 reauth_required`; reconnect Threads under Settings -> Organisation -> Workspaces to fix it. ### Hiding a reply As the post owner, you can hide (or unhide) a reply someone left on one of your Threads posts with the same `POST /inbox/messages/{id}/hide` endpoint described under [Hiding and deleting comments](#hiding-and-deleting-comments). Threads specifics: only incoming top-level replies can be hidden (a nested reply returns `400 not_hideable`), a missing reply permission returns `401 reauth_required`, and there is no delete for Threads. ## Using it from an AI assistant The inbox is available through the OmniSocials MCP server, so you can connect Claude, ChatGPT, Cursor, or any MCP client and manage your inbox in natural language. The tools are: | Tool | What it does | |------|--------------| | `list_inbox_conversations` | List conversations; accepts `platform`, `type`, `unread`, and `unanswered` | | `get_inbox_conversation` | Read a thread with the post it belongs to (caption, link, image); every message shows its `#id`, which the hide and delete tools take | | `get_next_unanswered` | The work queue: the next item that needs an answer, with the thread and the post, in one call. Accepts `platform`, `type`, `order`, `include_read`, and `exclude` | | `mark_inbox_read` | Mark a conversation read, which also skips it in the queue for good | | `reply_to_inbox` | Send a reply; pass `include_next: true` to get the next unanswered item back in the same call | | `hide_inbox_comment` | Hide or unhide a comment on Instagram, Facebook, TikTok, YouTube, or Threads | | `delete_inbox_comment` | Delete an Instagram, Facebook, or TikTok comment, with its replies. Irreversible, so the assistant confirms first | Ask for "work through my unanswered comments" and the assistant runs the loop from [Working through the inbox](#working-through-the-inbox): `get_next_unanswered`, draft a reply with the post in view, `reply_to_inbox` with `include_next: true`, repeat until nothing is waiting. See [MCP Server](https://docs.omnisocials.com/mcp-server.md), [Claude](https://docs.omnisocials.com/integrations/claude.md), and [ChatGPT](https://docs.omnisocials.com/integrations/chatgpt.md). Every SDK also ships an `inbox` resource. See [SDKs](https://docs.omnisocials.com/sdks.md). --- ## Document: Comment-to-DM Automations API: Instagram keyword DMs Send a DM to everyone who comments a keyword on an Instagram post with the OmniSocials Automations API: triggers, scopes, public replies, limits, the activity log, and the comment_dm_automation field on posts. URL: https://docs.omnisocials.com/automations # Comment-to-DM Automations API: Instagram keyword DMs A comment-to-DM automation answers a keyword with a private message. When somebody comments "guide" on your Instagram post, the account sends them the guide in a DM, once per person, and can post a public reply under the comment ("Sent you a DM!"). The same automation can listen to a keyword in a DM to the account or in a reply to a story. Automations are managed with the [Automations endpoints](https://docs.omnisocials.com/api/automations) (`GET/POST /automations`, `GET/PATCH/DELETE /automations/:id`, `GET /automations/:id/events`), or attached to a new post with `comment_dm_automation` on [`POST /posts/create`](https://docs.omnisocials.com/creating-posts.md#comment-to-dm-automation). Everything you create here shows up on the Automations page in the dashboard, and the other way round. ## Enabling automation access The scopes are opt-in, like the inbox scopes: an automation sends DMs in your account's name, and its activity carries what people wrote. When you create an API key, turn on **Automations** to grant `automations:read` (list, get, activity) and `automations:write` (create, update, delete, and `comment_dm_automation` on posts). Existing keys are not given these scopes automatically; without them every endpoint answers `403 insufficient_scope`. ## Creating an automation ```json POST /automations { "name": "Free guide", "post_id": "83778183", "trigger": { "keywords": ["guide", "link"], "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 } } ``` | Field | Meaning | | --- | --- | | `trigger.kinds` | What starts the automation: `comment` (default) = a comment on a post or reel, `dm` = a keyword in a DM to the account, `story_reply` = a keyword in a reply to a story. One automation can listen to several. | | `trigger.scope` | For the `comment` kind: `post` (default) = one post, `next_post` = the next Instagram post the workspace publishes, `any` = every post of the account. | | `post_id` | For scope `post`: an OmniSocials post that goes to Instagram, scheduled or already live. A scheduled post starts listening once it is live. One automation per post. | | `instagram_media_id` | For scope `post`: the numeric id of a post or reel that was published outside OmniSocials (from Instagram's API, `GET /me/media`). OmniSocials reads it from Instagram to check it belongs to the connected account. Pass `post_id` or `instagram_media_id`, not both. | | `trigger.keywords` | Up to 20 keywords, matched case-insensitively. Required unless `match` is `any`. | | `trigger.match` | `word` (default): the keyword stands on its own ("guide", "Guide!" match; "linkedin" does not match "link"). `contains`: the keyword appears anywhere. `any`: every comment or message with text. | | `actions.dm.text` | The DM, max 1000 characters. Sent once per person per automation. | | `actions.public_reply` | A public reply under the comment, for the `comment` kind only. One of `variations` (up to 10) is picked at random per comment. | | `limits.max_sends` | Stop after this many DMs; the automation becomes `completed`. | | `limits.stop_at` | Stop at this time (ISO 8601). | | `status` | `active` (default) or `paused` to create it without turning it on. | The response is the automation with `instagram_media_id` (set once the post is live), a `post` snapshot (`source` is `omnisocials` or `instagram`, with `caption`, `thumbnail_url`, `permalink`) and `stats` (`matched`, `sent`, `skipped`, `failed`, `last_event_at`). A keyword in any DM or story reply, no post needed: ```json POST /automations { "trigger": { "kinds": ["dm", "story_reply"], "keywords": ["menu"] }, "actions": { "dm": { "text": "Our menu: https://example.com/menu" } } } ``` ## Instagram rules - One private reply per comment, and only within 7 days of the comment. A comment older than 7 days is skipped (`window_passed`). - A follow-up message is possible only after the person answers (Meta's 24-hour window). - The DM goes to each person once per automation (`already_sent`). - OmniSocials sends at most 180 DMs an hour per account (`rate_limited`); Instagram's own limit for private replies is 750 an hour. - Public replies belong to comments; a DM or story-reply trigger has none. ## Updating, pausing, deleting `PATCH /automations/:id` takes the same fields. `status: "paused"` pauses, `"active"` resumes. `trigger`, `actions` and `limits` are merged into the stored sections: keys you omit keep their value, so `{ "trigger": { "keywords": ["pdf"] } }` keeps the kinds, the scope and the match, and a new `dm.text` keeps the public reply. `public_reply.enabled: false` (or `variations: []`) turns the public reply off; `limits.max_sends: null` removes that limit. Another `post_id` or `instagram_media_id` moves the automation to that post. A `completed` automation starts again when its limits or trigger change. `DELETE /automations/:id` removes it and its activity; DMs already sent stay sent. ## Activity `GET /automations/:id/events` returns one row per comment or message the automation acted on, newest first, with `counts` per outcome and offset pagination: ```json { "data": [{ "id": "1", "kind": "comment", "outcome": "sent", "person": { "id": "1784...", "username": "jane" }, "trigger_text": "guide please", "message": "Here is the guide: https://example.com/guide", "public_reply": "Sent you a DM!", "sent_at": "2026-10-10T21:40:12.000Z" }], "counts": { "sent": 12, "skipped": 3 }, "pagination": { "total": 15, "limit": 25, "offset": 0, "has_more": false } } ``` `outcome` is `sent`, `skipped` (with `reason`: `already_sent`, `limit_reached`, `stopped`, `window_passed`, `rate_limited`, and `reason_label` in plain words), `failed` (with Instagram's error in `reason`) or `pending`. Filter with `?outcome=`. The text of a DM or story reply is private: `trigger_text` on `dm` and `story_reply` rows is returned only to a key with the `inbox:read` scope; a comment is public and always returned. ## On a new post Pass `comment_dm_automation` on `POST /posts/create` to bind an automation to the post you are creating (Instagram must be in `accounts`). It is the create body without `post_id`, `scope` and `kinds`: always this post, comment trigger. The created automation comes back as `comment_dm_automation` on the post, `GET /posts/:id` returns it, and `PATCH /posts/:id` replaces it with a new object or removes it with `null`. See [Creating posts](https://docs.omnisocials.com/creating-posts.md#comment-to-dm-automation). ## Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `validation_error` | Bad input; the message says what (for example "Write the DM", "Add at least one keyword, or choose any comment") | | 400 | `post_not_instagram` | The post does not go to Instagram | | 400 | `post_required` | Scope `post` without `post_id` or `instagram_media_id` | | 403 | `insufficient_scope` | The key has no `automations:read` / `automations:write` | | 404 | `post_not_found`, `instagram_media_not_found`, `not_found` | No such post, Instagram media or automation in this workspace | | 409 | `instagram_not_connected`, `instagram_needs_reauth` | Connect or reconnect Instagram first | | 409 | `automation_exists` | The post already has an automation; the response carries its `automation_id` | ## Using it from an AI assistant The [MCP server](https://docs.omnisocials.com/mcp-server.md) has `list_automations`, `get_automation`, `create_automation`, `update_automation`, `delete_automation` and `get_automation_activity`, and `create_post` takes `comment_dm_automation`. The [CLI](https://docs.omnisocials.com/cli/management.md#automations-comment-to-dm) has the `automations:*` commands and the `--dm-*` flags on `posts:create`. --- ## Document: Webhooks for post events Subscribe to post.published, post.failed, post.approved, post.rejected and other OmniSocials webhook events. Payload schemas, HMAC signature verification, and secret rotation. URL: https://docs.omnisocials.com/webhooks # Webhooks for post events Subscribe to events to get real-time notifications when posts are published, scheduled, or fail, and when a reviewer approves or rejects a post in an approval workflow. Webhooks are the right choice when you want to know about state changes without polling the API. ## Event catalog | Event | When it fires | `data.status` | Main payload fields | |-------|---------------|---------------|---------------------| | `post.scheduled` | A post is created or updated into the scheduled state through the API (`POST /posts/create` or `PATCH /posts/:id`). Not sent for posts scheduled in the dashboard, or for a post that waits for approval | `scheduled` | `scheduled_at`. `targets` is empty and `published_at` is `null` | | `post.published` | A publish run ends with at least one channel live. Sent again after every retry run, manual or automatic | `posted`, or `warning` when some channels failed | `published_at`, and `targets[]` with `platform`, `status`, `native_post_id`, and `error` on a failed channel | | `post.failed` | A publish run ends with no channel live. An automatic retry can still follow | `failed` | `targets[]` with `platform`, `status` and `error` | | `post.approved` | The last step of a post's approval workflow approves it | `scheduled`, or `posting` when the publish time has passed | `approval` with `status`, `decided_by` and `reason` (`null`). `targets` is empty | | `post.rejected` | An approver rejects the post. It will not publish | `rejected` | `approval` with `status`, `decided_by` and the `reason`. `targets` is empty | Every event uses the same envelope: `id` (the same value as the `X-OmniSocials-Delivery` header), `type`, `created_at`, and `data` with `post_id`, `workspace_id`, `status`, `post_type` (`Post`, `Story` or `Reel`), `scheduled_at`, `published_at` and `targets`. `data.status` is the stored status, so a published post reads `posted`, not `published`. One post can send several events, for example `post.failed` and later `post.published` when a retry succeeds. Keep your state per `data.post_id`; see [Retries and duplicate posts](https://docs.omnisocials.com/retries.md#webhooks-for-one-post-can-arrive-more-than-once). ## Creating a webhook ```bash curl -X POST https://api.omnisocials.com/v1/webhooks \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/omnisocials", "events": ["post.published", "post.failed"] }' ``` Response: ```json { "data": { "id": "5f3b0c1e-9a2d-4e77-b1c4-8a2f6d9e0b12", "url": "https://your-app.com/webhooks/omnisocials", "events": ["post.published", "post.failed"], "is_active": true, "created_at": "2026-04-10T14:22:00Z", "secret": "a1b2c3d4e5f6...c7d8e9f0" }, "message": "Webhook created. Save the secret - it will only be shown once." } ``` The `id` is a UUID and the `secret` is a 64-character hex string returned exactly once on creation. Store the secret. You will need it to verify incoming webhook signatures. ## Available events Subscribe to any of the five events in the [Event catalog](#event-catalog). These are the only five valid events. Creating or updating a webhook with any other value (including a `"*"` wildcard) returns `400`. There is no Social Inbox event (a new DM, comment, or mention) yet. Poll [`GET /inbox/conversations`](https://docs.omnisocials.com/inbox.md) instead. **Partial publishes** fire `post.published`, not a separate event. When a post succeeds on some platforms and fails on others, the payload's `data.status` is `"warning"` and the per-platform outcomes are in `data.targets` (each with its own `status`). Inspect `targets` to see exactly which platforms succeeded or failed. ## Payload shape ```json { "id": "8c1e0a2b-4d5f-4a67-9b0c-1d2e3f4a5b6c", "type": "post.published", "created_at": "2026-04-10T14:22:00Z", "data": { "post_id": "123", "workspace_id": 456, "status": "posted", "post_type": "Post", "scheduled_at": "2026-04-10T14:20:00Z", "published_at": "2026-04-10T14:22:00Z", "targets": [ { "platform": "instagram", "status": "success", "native_post_id": "18023456789012345" }, { "platform": "linkedin", "status": "failed", "native_post_id": null, "error": "The LinkedIn token has expired. Reconnect the account." } ] } } ``` `data.targets` holds one entry per selected platform. `native_post_id` is the platform's own post ID (present when that platform succeeded); `error` appears only on a non-successful target. `data.status` is the overall post status (`posted`, `warning` for a partial success, or `failed`). Always branch on the top-level `type` before accessing `data`. `post.scheduled`, `post.approved` and `post.rejected` are delivered before anything is published, so their `data.targets` array is empty and `published_at` is `null`. ### Approval events `post.approved` and `post.rejected` use the same envelope and carry one more object, `data.approval`, that says how the review ended: ```json { "id": "e7c9a1b2-3d4e-5f6a-7b8c-9d0e1f2a3b4c", "type": "post.rejected", "created_at": "2026-10-02T09:00:05.000Z", "data": { "post_id": "123456", "workspace_id": 789, "status": "rejected", "post_type": "Post", "scheduled_at": "2026-10-03T14:00:00.000Z", "published_at": null, "targets": [], "approval": { "status": "rejected", "decided_by": "c4a09e1d-7b52-4f38-a6d1-3e9f8b2c5d60", "reason": "Wrong product photo" } } } ``` | Field | Value | |-------|-------| | `data.approval.status` | `approved` or `rejected` | | `data.approval.decided_by` | User id of the approver who gave the last approval, or who rejected the post | | `data.approval.reason` | The reason the approver gave with a rejection. `null` on `post.approved` and when the approver gave no reason | On `post.approved`, `data.status` is `scheduled`, or `posting` when the publish time has already passed. On `post.rejected` it is `rejected`. `post.approved` fires once, when the last step is approved; an approval of an earlier step sends no event. The payload names the outcome only. For every step, every approver and the comment thread, call [`GET /posts/:id/approval`](https://docs.omnisocials.com/creating-posts.md#reading-the-review) with the `post_id` from the event. ## Verifying signatures Every outbound webhook call is signed with HMAC-SHA256 using your webhook's secret. Each delivery carries these headers: | Header | Value | |--------|-------| | `X-OmniSocials-Signature` | `t=,v1=` | | `X-OmniSocials-Timestamp` | The same unix timestamp, on its own | | `X-OmniSocials-Event` | The event type (e.g. `post.published`) | | `X-OmniSocials-Webhook-Id` | The webhook's ID | | `X-OmniSocials-Delivery` | A unique ID per event, stable across retry attempts. Use it to deduplicate. | The signature is computed over `` `${timestamp}.${rawBody}` `` (the timestamp, a literal dot, then the raw JSON body), the same scheme Stripe uses. To verify: parse `t` and `v1` out of the header, recompute the HMAC over `timestamp.rawBody`, and compare against `v1`. A mismatch means the request is not from OmniSocials. Reject it. ```javascript import crypto from 'crypto' function verifyWebhook(rawBody, signatureHeader, secret) { // signatureHeader looks like "t=1744294920,v1=abc123..." const parts = Object.fromEntries( signatureHeader.split(',').map((kv) => kv.split('=')) ) const { t: timestamp, v1: signature } = parts if (!timestamp || !signature) return false const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`, 'utf8') .digest('hex') // timingSafeEqual throws when lengths differ, so check first const sigBuf = Buffer.from(signature) const expBuf = Buffer.from(expected) if (sigBuf.length !== expBuf.length) return false return crypto.timingSafeEqual(sigBuf, expBuf) } ``` Verify against the **raw** request body, before any JSON parsing or reserialization. Always compare with a constant-time function like `timingSafeEqual`. Optionally, reject deliveries whose `t` timestamp is more than a few minutes old to guard against replay. Keep that window at 5 minutes or more: the timestamp is set when the event is created and reused on retries, so a retried delivery arrives with the original timestamp. ## Delivery and retries - **Respond with any `2xx` within 10 seconds.** That counts as delivered. Anything else (including a timeout after 10 seconds) counts as a failure. Do your processing after you respond, not before. - **Failed deliveries retry twice**: the first retry after 2 seconds, the second after 5 more. Only `5xx`, `429`, and network errors are retried; any other `4xx` fails immediately without a retry. - **Delivery is at-least-once.** If your endpoint processed the event but the response timed out, the retry delivers a duplicate. Deduplicate on `X-OmniSocials-Delivery`; it stays the same across retries of one event. - **Ordering is not guaranteed.** Deliveries fire asynchronously and concurrently; a retrying delivery can arrive after a newer event. Order on the payload's `created_at`. - **After the final failed attempt the event is dropped.** It is not re-sent later, and there is no replay endpoint. The webhook records the failure: `failure_count` increments and `last_failure` (time, status, error) is stored; read both via `GET /webhooks/:id`. A webhook is never disabled automatically, so a recovered endpoint receives the next event normally. Reconcile missed events by polling `GET /posts?status=`. - Requests come with `User-Agent: OmniSocials-Webhooks/1.0` and follow up to 3 redirects. **Local testing.** Webhook URLs must be publicly reachable. For local development, expose your dev server with a tunnel (for example `cloudflared tunnel` or `ngrok http 3000`) and register the tunnel URL as the webhook. ## Managing webhooks All webhook endpoints require the `webhooks:manage` scope on your API key. | Endpoint | Description | |----------|-------------| | `GET /webhooks` | List all webhooks for this key | | `GET /webhooks/:id` | Get a single webhook | | `PATCH /webhooks/:id` | Update URL, events, or `is_active` (re-enabling resets `failure_count`) | | `DELETE /webhooks/:id` | Delete a webhook | | `POST /webhooks/:id/rotate-secret` | Rotate the signing secret | Rotating a secret immediately invalidates the old one. Update your verification code to use the new secret before rotating, or accept a short window of failed verifications. --- ## Document: 13 supported social channels Channel identifiers, content-type support, and media specs for all 13 OmniSocials channels, plus the platforms coming next. URL: https://docs.omnisocials.com/platforms # 13 supported social channels OmniSocials supports 13 channels across 12 platforms, with two more in development. Every channel has a stable string identifier that you pass in the `accounts` array (via account IDs) and use as a key on platform-specific options. LinkedIn appears as two independent channels: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected simultaneously. ## Channel matrix | Channel ID | Platform | Feed Post | Story | Reel/Short | |------------|----------|-----------|-------|------------| | [`facebook`](https://docs.omnisocials.com/platforms/facebook.md) | Facebook | ✅ | ✅ | ✅ | | [`instagram`](https://docs.omnisocials.com/platforms/instagram.md) | Instagram | ✅ | ✅ | ✅ | | [`threads`](https://docs.omnisocials.com/platforms/threads.md) | Threads | ✅ | - | - | | [`linkedin`](https://docs.omnisocials.com/platforms/linkedin.md) | LinkedIn Profile | ✅ | - | - | | [`linkedin_page`](https://docs.omnisocials.com/platforms/linkedin.md) | LinkedIn Page | ✅ | - | - | | [`youtube`](https://docs.omnisocials.com/platforms/youtube.md) | YouTube | - | - | ✅ | | [`tiktok`](https://docs.omnisocials.com/platforms/tiktok.md) | TikTok | ✅ | - | ✅ | | [`pinterest`](https://docs.omnisocials.com/platforms/pinterest.md) | Pinterest | ✅ | - | - | | [`bluesky`](https://docs.omnisocials.com/platforms/bluesky.md) | Bluesky | ✅ | - | - | | [`mastodon`](https://docs.omnisocials.com/platforms/mastodon.md) | Mastodon | ✅ | - | - | | [`x`](https://docs.omnisocials.com/platforms/x.md) | X (Twitter) | ✅ | - | - | | [`google_business`](https://docs.omnisocials.com/platforms/google-business.md) | Google Business | ✅ | - | - | | [`telegram`](https://docs.omnisocials.com/platforms/telegram.md) | Telegram (channel or group) | ✅ | - | ✅ (sent as a video message) | | `reddit` | Reddit | _coming soon_ | - | - | | `snapchat` | Snapchat | - | _coming soon_ | - | ## Capability matrix What each channel supports through the API: post types, analytics, the [Social inbox](https://docs.omnisocials.com/inbox.md), and the limits you run into most. The platform pages have the full specs, and [Known issues](https://docs.omnisocials.com/known-issues.md) lists the quirks per channel. | Channel | Post types | Analytics | Inbox | Main limits | |---------|------------|-----------|-------|-------------| | [`instagram`](https://docs.omnisocials.com/platforms/instagram.md) | Feed, carousel (2 to 10 items, images and videos mixed), story, reel | Yes: views, reach, likes, comments, saves, shares; watch time on reels, replies and link clicks on stories | DMs, comments, mentions and story mentions | Caption 2,200 characters, 5 hashtags; media always required | | [`facebook`](https://docs.omnisocials.com/platforms/facebook.md) | Feed (text, photos, one video or a link preview), multi-photo, story, reel | Yes: views, reactions, comments, shares, clicks, video views; no reach | DMs (Messenger), comments, mentions | 63,206 characters; photos or one video, not mixed | | [`threads`](https://docs.omnisocials.com/platforms/threads.md) | Feed, carousel (up to 20 items, mixed), thread, poll | Yes: views, likes, replies, reposts, quotes, shares | Replies on your posts, mentions | 500 characters per post, 5 links; up to 20 media items | | [`linkedin`](https://docs.omnisocials.com/platforms/linkedin.md) | Feed, document carousel or image gallery, poll | Yes: impressions, reach, reactions, comments, shares, saves, clicks | Not supported | 3,000 characters; images or one video, not mixed | | [`linkedin_page`](https://docs.omnisocials.com/platforms/linkedin.md) | Feed, document carousel or image gallery, poll | Yes: impressions, unique impressions, reactions, comments, shares, clicks, engagement rate | Comments, mentions | 3,000 characters; images or one video, not mixed | | [`youtube`](https://docs.omnisocials.com/platforms/youtube.md) | Short (`type: "reel"`) only | Yes: views, likes, comments | Video comments | One vertical video up to 3 minutes; title 100, description 5,000 characters | | [`tiktok`](https://docs.omnisocials.com/platforms/tiktok.md) | Video, photo post (1 to 35 images), reel | Yes: views, likes, comments, shares; watch depth and reach once comments are enabled | Video comments, once comments are enabled | Caption 2,200 characters on videos, 4,000 on photo posts; one video or 1 to 35 images | | [`pinterest`](https://docs.omnisocials.com/platforms/pinterest.md) | Pin (image or video), carousel pin (2 to 5 images) | Yes: impressions, saves, pin clicks, outbound clicks | Not supported | Description 800, title 100 characters; `board_id` required | | [`bluesky`](https://docs.omnisocials.com/platforms/bluesky.md) | Feed (up to 10 images, or one video), thread | Yes: likes, comments, reposts, quotes, bookmarks; no views | Not supported | 300 graphemes; images up to 2 MB after compression | | [`mastodon`](https://docs.omnisocials.com/platforms/mastodon.md) | Feed, thread, poll | Yes: likes, comments, reposts, quotes, poll votes; no views | Not supported | The server's own limit (500 on mastodon.social); up to 4 images or one video | | [`x`](https://docs.omnisocials.com/platforms/x.md) | Feed, thread, poll | Yes: views, likes, comments, reposts, quotes, bookmarks | DMs, opt-in, uses credits | 280 characters, 25,000 on Basic, Premium and Premium+; up to 4 images or one video | | [`google_business`](https://docs.omnisocials.com/platforms/google-business.md) | Local post: standard, event or offer, with an optional button | Location level only: views, calls, direction requests, website clicks; no per-post metrics | Not supported | 1,500 characters, no phone numbers or links in the caption; one image, no video | | [`telegram`](https://docs.omnisocials.com/platforms/telegram.md) | Text, photo, video or file, album (2 to 10), poll, reel (sent as a video message) | Yes: reactions, poll votes; no views | Not supported | 4,096 characters, 1,024 under media; files up to 50 MB | ## How channel IDs are used Channel IDs show up in four places: 1. **As keys in per-platform content** on the post body: `{ "content": { "default": "...", "linkedin": "..." } }` 2. **As keys in per-platform media**: `{ "media_urls": { "default": [...], "instagram": [...] } }` 3. **As top-level keys for platform options** on the post body: `{ "pinterest": { "board_id": "..." } }` 4. **In webhook payloads**, as `data.targets[].platform` (one entry per selected platform) The `accounts` array on a post takes the account `id` you get from `GET /accounts` (for example `368204_instagram`), or the bare channel ID (`instagram`). A workspace has one account per channel, so both point to the same connected account. ## Connecting accounts All account connections happen in the OmniSocials dashboard (**Settings → Organisation → Workspaces**, then the workspace), not through the API: OAuth for most platforms, and for Telegram a pick of the channel or group in the OmniSocials bot. Once connected, accounts are available to every API key in the workspace. The dashboard handles token refresh, re-authorization prompts, and revocation. ## Per-platform reference Each platform has its own page with the channel ID, supported content types, platform-specific options, media requirements, and limitations: - [Facebook](https://docs.omnisocials.com/platforms/facebook.md) - [Instagram](https://docs.omnisocials.com/platforms/instagram.md) - [Threads](https://docs.omnisocials.com/platforms/threads.md) - [LinkedIn (Profile and Page)](https://docs.omnisocials.com/platforms/linkedin.md) - [YouTube](https://docs.omnisocials.com/platforms/youtube.md) - [TikTok](https://docs.omnisocials.com/platforms/tiktok.md) - [Pinterest](https://docs.omnisocials.com/platforms/pinterest.md) - [Bluesky](https://docs.omnisocials.com/platforms/bluesky.md) - [Mastodon](https://docs.omnisocials.com/platforms/mastodon.md) - [X (Twitter)](https://docs.omnisocials.com/platforms/x.md) - [Google Business](https://docs.omnisocials.com/platforms/google-business.md) - [Telegram](https://docs.omnisocials.com/platforms/telegram.md) --- ## Document: Known issues and platform limits Platform quirks that make OmniSocials posts fail or behave unexpectedly, per channel: the error you see, why it happens, and what to do about it. URL: https://docs.omnisocials.com/known-issues # Known issues and platform limits The platform behavior that surprises developers most often, per channel. Every item gives the symptom as you see it, the cause, and what to do, with a link to the full section. A failed channel's message is in `errors.` on `GET /posts/:id` and in `data.targets[].error` on a webhook. The quotes below are the start of those messages. After you fix the cause, publish the failed channels again with `POST /posts/:id/retry` (see [Retries and duplicate posts](https://docs.omnisocials.com/retries.md)). ## Every channel ### Posts fail with "connection expired" - **You see:** the account has `needs_reconnect: true` in `GET /accounts`, and posts to it fail with a message about an expired connection. - **Cause:** the platform token was revoked or expired, for example after a password change or a removed app permission. - **What to do:** reconnect the account in the dashboard under **Settings → Organisation → Workspaces**, then retry the post. The API cannot reconnect accounts. See [Connecting accounts](https://docs.omnisocials.com/platforms.md#connecting-accounts). ### A video uploads fine but fails to publish - **You see:** `POST /media/upload` succeeds, and the post fails or is refused later. - **Cause:** platform size and duration limits are checked when a post is scheduled or published, not at upload. - **What to do:** run `POST /media/check` first; it returns per-platform `warnings` for your connected channels. See [Check compatibility before posting](https://docs.omnisocials.com/media.md#check-compatibility-before-posting). ### The platform refuses a duplicate - **You see:** "Facebook rejected this as a duplicate", "LinkedIn rejected this post as a duplicate: identical content already exists on your profile", or "This is a duplicate of a tweet you already posted." - **Cause:** Facebook, LinkedIn and X block identical content that is already on the account, also when it was posted outside OmniSocials. - **What to do:** change the text before you try again; a retry with the same text fails the same way. See [Retries and duplicate posts](https://docs.omnisocials.com/retries.md#duplicate-content). ## Instagram ### Reels with music fail after a Facebook reconnect - **You see:** "Instagram music needs the Facebook Page that is linked to this Instagram account, and the Facebook login connected to this workspace no longer gives OmniSocials access to that Instagram account." - **Cause:** a Reel with licensed music is published through the workspace's Facebook login. Facebook's permission window replaces the whole grant of a login, so when the same Facebook login is reconnected in another workspace and this Instagram account is not selected there, this workspace loses access to it. - **What to do:** reconnect Facebook in this workspace and select this Page and this Instagram account in Facebook's window. If the same login is connected in other workspaces, select their Pages and Instagram accounts too. Then retry the Reel. See [Instagram reel music](https://docs.omnisocials.com/creating-reels.md#instagram-reel-music). ### Reels with music fail without a Facebook connection - **You see:** "Instagram music needs a connected Facebook account." `GET /audio/search` returns `needsFacebook: true`. - **Cause:** music needs a Facebook account in the workspace whose Page is linked to the same Instagram account. - **What to do:** connect that Facebook Page to the workspace, or publish the Reel without `instagram.audio_id`. See [Instagram reel music](https://docs.omnisocials.com/creating-reels.md#instagram-reel-music). ### "Licensed music isn't available for this workspace yet" - **You see:** "Licensed music isn't available for this workspace yet: the Facebook account connected to it hasn't been granted Instagram publishing permission by Meta." - **Cause:** Meta has not given the connected Facebook account the Instagram publishing permission that music needs. Reconnecting does not change it. - **What to do:** publish the Reel without licensed music. See [Instagram reel music](https://docs.omnisocials.com/creating-reels.md#instagram-reel-music). ### The account is restricted from posting - **You see:** "Instagram has temporarily restricted this account from posting (anti-abuse protection). Reconnecting won't help. This usually clears within 24 to 48 hours." - **Cause:** Instagram's own anti-abuse protection on the account. - **What to do:** post less often and retry later. Do not reconnect. ### Daily posting limit reached - **You see:** "You've reached Instagram's daily posting limit for this account." - **Cause:** Instagram allows 100 API publishes per account per 24 hours, and every slide of a multi-slide story counts as one. - **What to do:** wait a few hours, then retry. See [Multi-slide stories](https://docs.omnisocials.com/creating-stories.md#multi-slide-stories). ### Trial Reel refused - **You see:** "Trial Reels need a minimum follower count Instagram doesn't think this account has. Turn off Trial Reel and try again." - **Cause:** Instagram enables Trial Reels per account, roughly from 1,000 followers. - **What to do:** publish without `instagram.is_trial_reel`. See [Instagram Trial Reels](https://docs.omnisocials.com/creating-reels.md#instagram-trial-reels). ### Too many hashtags - **You see:** `400 hashtag_limit_exceeded` when you apply a hashtag set, or Instagram's "Too many hashtags". - **Cause:** Instagram rejects a caption or comment with more than 5 hashtags. - **What to do:** keep each caption and first comment at 5 hashtags or fewer. See [Hashtag sets](https://docs.omnisocials.com/creating-posts.md#hashtag-sets). ### A tall feed image comes back cropped - **You see:** an `image_fit` entry in the response `warnings` with `"action": "cropped"`. - **Cause:** Instagram shows feed images between 4:5 and 1.91:1, so OmniSocials crops a taller image to 4:5 around the subject by default. Feed images outside 9:16 to 1.91:1 are refused with `400 validation_error`. - **What to do:** to send the original, pass `media_fit: { "instagram": "keep" }`. See [Image fit per channel](https://docs.omnisocials.com/creating-posts.md#image-fit-per-channel). ### A DM reply is refused after 24 hours - **You see:** `422 outside_messaging_window` on `POST /inbox/conversations/:id/reply`, and `reply_window.open: false` on `GET /inbox/next`. - **Cause:** Meta allows replies to Instagram and Facebook DMs only within 24 hours of the person's last message. - **What to do:** answer from the Instagram or Facebook app (the reply is mirrored into the inbox), or mark the conversation read. Do not retry. See [Reply rules](https://docs.omnisocials.com/inbox.md#reply-rules). ### Hiding a comment returns `502 hide_not_applied` - **You see:** `502 hide_not_applied` on `POST /inbox/messages/:id/hide`. - **Cause:** the comment shows under "Comments from Facebook" on a Reel that is also shared to Facebook. It lives on Facebook, and Instagram's hide does not reach it. - **What to do:** hide it in the Instagram or Facebook app. Do not retry. See [Hiding a comment](https://docs.omnisocials.com/inbox.md#hiding-a-comment). ### Comment-to-DM skips some comments - **You see:** automation events with `outcome: "skipped"` and `reason: "window_passed"` or `"rate_limited"`. - **Cause:** Instagram allows one private reply per comment, within 7 days of the comment, and OmniSocials sends at most 180 DMs an hour per account. - **What to do:** expect these skips on old comments and busy posts. Every skip is listed in the activity log (`GET /automations/:id/events`) with its reason. See [Instagram rules](https://docs.omnisocials.com/automations.md#instagram-rules). ## Facebook ### A reconnect in one workspace breaks Facebook in another - **You see:** in the other workspace, the Facebook account has `needs_reconnect: true` and posts fail. Its `reauth_reason` reads "The Facebook login for this workspace reconnected in another workspace and did not keep the Page ... selected in the Facebook window, so Facebook removed posting access." - **Cause:** Facebook's permission window replaces the whole grant of a login. A Page that is not selected during a reconnect loses access in every workspace that uses the same login. - **What to do:** reconnect Facebook in the affected workspace and select all your Pages in the Facebook window. The same rule can drop an Instagram account; see [Reels with music fail after a Facebook reconnect](#reels-with-music-fail-after-a-facebook-reconnect). ### Facebook posts have no reach - **You see:** no `reach` key in Facebook post analytics. - **Cause:** Meta retired Facebook post reach in June 2026, and its replacement metric returns no data. - **What to do:** use `views` and the click metrics instead, and sum reach only over entries that carry it. See [Analytics](https://docs.omnisocials.com/analytics.md#get-analytics-for-one-post). ### Mixed images and video are refused - **You see:** `400 validation_error` when you schedule or publish. - **Cause:** a Facebook post takes photos or one video, not both. - **What to do:** send images only or a single video. See [Facebook media requirements](https://docs.omnisocials.com/platforms/facebook.md#media-requirements). ### Messenger replies from the app don't show in the inbox - **You see:** replies sent from Messenger or Business Suite are missing from the inbox thread. - **Cause:** Pages connected before September 6, 2026 lack a webhook field for these replies. - **What to do:** reconnect the Page once under **Settings → Organisation → Workspaces**. See [Replies sent from the native apps](https://docs.omnisocials.com/inbox.md#replies-sent-from-the-native-apps). ### Scheduled posts go out a few minutes late - **Cause:** Facebook's API does not guarantee delivery timing for scheduled posts. - **What to do:** expect publish times within a few minutes of `schedule_at`. See [Facebook limitations](https://docs.omnisocials.com/platforms/facebook.md#limitations). ## LinkedIn ### Daily limit for unverified profiles - **You see:** "This LinkedIn profile reached LinkedIn's daily posting limit for profiles that are not verified, so LinkedIn refused this post. The earlier posts of the day went out." It can appear on `linkedin` and `linkedin_page`. - **Cause:** LinkedIn gives a member it has not verified a small number of posts per UTC day. It is not the connection and not an OmniSocials quota, so reconnecting does not help. - **What to do:** retry after midnight UTC, verify your identity on your LinkedIn profile, or schedule fewer LinkedIn posts per day. See [LinkedIn](https://docs.omnisocials.com/platforms/linkedin.md). ### Media upload refused on a company page - **You see:** "LinkedIn refused the media upload for this Page: the connected LinkedIn member is not a Super admin or Content admin of the Page (any more)." - **Cause:** media uploads to a page need a Super admin or Content admin role. - **What to do:** ask a page super admin to check the role, then reconnect the page. See [LinkedIn limitations](https://docs.omnisocials.com/platforms/linkedin.md#limitations). ### The connection expires after 60 days - **Cause:** LinkedIn access tokens last 60 days. OmniSocials renews them when LinkedIn issued a refresh token, up to LinkedIn's one-year refresh limit; otherwise the workspace gets an email 7 days before the expiry. - **What to do:** reconnect the channel when that email arrives. Reconnecting either LinkedIn channel refreshes both. See [LinkedIn](https://docs.omnisocials.com/platforms/linkedin.md). ### @mentions publish as plain text - **Cause:** the API, the MCP server and the CLI have no mentions field. - **What to do:** add mentions in the OmniSocials web composer. See [Mentions](https://docs.omnisocials.com/platforms/linkedin.md#mentions). ### A poll drops the media - **Cause:** on LinkedIn a poll excludes media and a link share. When a request carries both, the poll takes priority at publish time without an error. - **What to do:** send either a poll or media for each LinkedIn channel. See [Polls](https://docs.omnisocials.com/platforms/linkedin.md#polls). ## TikTok ### The video is published as private - **You see:** a post you sent with `PUBLIC_TO_EVERYONE` is only visible to the creator. - **Cause:** TikTok decides per creator which privacy levels an account may use. A level the account can't use publishes as `SELF_ONLY`. - **What to do:** check the account's allowed levels in the TikTok app. See [TikTok options](https://docs.omnisocials.com/platforms/tiktok.md#platform-specific-options). ### The first comment fails - **You see:** `first_comment_result.status` is `failed` on the TikTok options of the post. - **Cause:** TikTok comments need a second authorization, **Enable comments** on the TikTok channel card. The video must also be public and allow comments. - **What to do:** press **Enable comments** under **Settings → Organisation → Workspaces**. While TikTok has not returned the final video id yet, `first_comment_result.pending` is `true` and the comment follows automatically. See [Automatic first comment](https://docs.omnisocials.com/creating-posts.md#automatic-first-comment). ### Inbox replies return `401 reauth_required` - **Cause:** the TikTok comments authorization expired. - **What to do:** press **Enable comments** on the channel card again. See [TikTok comments](https://docs.omnisocials.com/inbox.md#tiktok-comments). ### Replies don't show on TikTok right away - **Cause:** TikTok holds fresh comments and replies in spam review for roughly 15 to 20 minutes. - **What to do:** nothing; an accepted reply appears under the video after the review. See [Spam review delay](https://docs.omnisocials.com/inbox.md#spam-review-delay). ### Watch-depth analytics are missing - **You see:** no `average_time_watched`, `reach` or audience keys in TikTok metrics. - **Cause:** these need the comments authorization. Channels that enabled comments before 22 August 2026 also need the insights scopes. - **What to do:** click **Unlock analytics permissions** on the TikTok channel card. See [Watch-depth analytics](https://docs.omnisocials.com/platforms/tiktok.md#watch-depth-analytics). ### Published videos can't be deleted - **Cause:** TikTok's API has no delete, and `DELETE /posts/:id` only removes the post from OmniSocials. - **What to do:** remove the video in the TikTok app. See [TikTok limitations](https://docs.omnisocials.com/platforms/tiktok.md#limitations). ## YouTube ### "OmniSocials briefly reached its YouTube posting limit for the day" - **You see:** "This didn't go out because OmniSocials briefly reached its YouTube posting limit for the day. This is on our side, not your channel or your video." - **Cause:** YouTube's daily API quota is shared by all OmniSocials customers. It resets at midnight Pacific time. - **What to do:** retry the post with `POST /posts/:id/retry` later. See [YouTube limitations](https://docs.omnisocials.com/platforms/youtube.md#limitations). ### "YouTube's upload limit was reached" - **You see:** "YouTube's upload limit was reached. Wait and try again, or space your scheduled YouTube posts further apart." - **Cause:** the channel's own upload limit, set by YouTube. - **What to do:** wait, then retry, and spread your YouTube posts out. ### A Short over 3 minutes is refused - **You see:** `400` when you schedule or publish. - **Cause:** OmniSocials publishes Shorts only and caps them at 3 minutes. The file is not converted, so a video that is not 9:16 plays with bars. - **What to do:** trim the video, or send a separate cut for YouTube in per-platform `media_urls`. See [YouTube media requirements](https://docs.omnisocials.com/platforms/youtube.md#media-requirements). ### The upload landed on another channel - **You see:** "We detected that this upload was about to land on a different YouTube channel than the one you connected, and removed it." - **Cause:** the Google account manages several channels, and the upload did not reach the connected one. - **What to do:** disconnect YouTube, reconnect, and pick the right channel in Google's account picker. ### YouTube permissions are missing - **You see:** "YouTube is missing required permissions. Reconnect your YouTube account and keep every permission box checked." A new connection is refused for the same reason. - **Cause:** a YouTube permission was unticked on Google's consent screen. - **What to do:** reconnect and leave every YouTube permission ticked. See [YouTube](https://docs.omnisocials.com/platforms/youtube.md). ### The custom thumbnail doesn't show - **Cause:** YouTube shows a custom Shorts thumbnail only on channels where it has enabled the feature. Other channels show a frame from the video. - **What to do:** nothing on the API side. See [Video covers](https://docs.omnisocials.com/creating-posts.md#video-covers). ### New comments arrive late - **Cause:** YouTube has no comment webhooks, so OmniSocials checks each channel once a day. - **What to do:** expect up to a day before a comment reaches the inbox. See [Social Inbox](https://docs.omnisocials.com/platforms/youtube.md#social-inbox). ## X (Twitter) ### Link posts use credits - **You see:** a `warnings` entry with code `x_url_post_credits` on create, `402 x_credits_insufficient` when you schedule or publish, or a failed X target when the balance runs short at publish time. - **Cause:** X bills posts whose text contains an `http://` or `https://` link at a premium, passed through as 20 credits per tweet with a link. Scheduled link posts reserve their cost until they publish. - **What to do:** top up at [app.omnisocials.com/credits](https://app.omnisocials.com/credits) and retry. Only the X target fails; other channels publish normally. A bare domain is free. See [Link posts use credits](https://docs.omnisocials.com/platforms/x.md#link-posts-use-credits). ### A post is refused as too long - **You see:** `400` at create, although the text looks short enough. - **Cause:** X weighs characters: every link counts as 23, emoji and CJK characters as 2. The limit is 280, or 25,000 on Basic, Premium and Premium+, and the tier is read when you connect. - **What to do:** shorten the text, or reconnect X after upgrading the account. See [Character limits](https://docs.omnisocials.com/platforms/x.md#character-limits). ### "We can't confirm the post went live" - **You see:** "X accepted the request but did not return a post id, so we can't confirm the post went live. Check your X profile before retrying, to avoid a duplicate." - **What to do:** look at the X profile first. Retry only when the post is not there. See [Retries and duplicate posts](https://docs.omnisocials.com/retries.md). ### The X inbox stopped receiving DMs - **You see:** `402 x_inbox_suspended` on replies, and no new X DMs. - **Cause:** the credit balance hit zero, so the X inbox paused itself. DMs sent while paused are not recovered. - **What to do:** top up and switch X DMs on again in the Social Inbox. See [X direct messages](https://docs.omnisocials.com/inbox.md#x-direct-messages). ## Threads ### Threads, inbox or replies need a reconnect - **You see:** `thread_parts` refused with a `400` that names the reconnect, Threads conversations missing from the inbox, or `401 reauth_required` on replies and hides. - **Cause:** accounts connected before the Threads reply permissions were added lack them. - **What to do:** reconnect Threads once under **Settings → Organisation → Workspaces**. See [Posting threads](https://docs.omnisocials.com/platforms/threads.md#posting-threads). ### Location tags are refused - **You see:** `400` on a post with `threads.location_id`. - **Cause:** Meta has not approved the location permission for OmniSocials yet. - **What to do:** leave the field out. See [Threads limitations](https://docs.omnisocials.com/platforms/threads.md#limitations). ### Too many links - **You see:** `400` before anything publishes. - **Cause:** Threads allows up to 5 unique links per post, and every part of a thread is its own post. - **What to do:** move links to another part. See [Posting threads](https://docs.omnisocials.com/platforms/threads.md#posting-threads). ## Pinterest ### Video Pins refused now and then - **You see:** Pinterest refused this video Pin with the message "This site doesn't allow you to save Pins". The Pin has no link, so no website blocked it. - **Cause:** Pinterest sometimes gives this answer for a video Pin without saying why. OmniSocials tries again for about a minute before it reports the failure. - **What to do:** retry the post later. If it fails again, post a different video. See [Video pins](https://docs.omnisocials.com/platforms/pinterest.md#video-pins). ### The board is not found - **You see:** "The Pinterest board you selected isn't in your connected Pinterest account for this workspace." - **Cause:** `pinterest.board_id` is required and must belong to the connected account. - **What to do:** take a board `id` from the `boards` array of `GET /accounts/:id`. See [Pinterest options](https://docs.omnisocials.com/platforms/pinterest.md#platform-specific-options). ### Carousel slides at different ratios - **You see:** `400 validation_error` with a `mismatched_slides` array when you schedule or publish. - **Cause:** Pinterest carousel pins need every image at the same aspect ratio. Drafts are not checked. - **What to do:** re-crop the listed slides to match slide 1. See [Carousel constraints](https://docs.omnisocials.com/platforms/pinterest.md#carousel-constraints). ### Product tags missing after publishing - **You see:** `pinterest.product_tags_result.skipped` lists products with a reason such as `PIN_NOT_FROM_VERIFIED_DOMAIN`. - **Cause:** Pinterest tags only your own public product Pins that link to a website the account claimed. A refused product never fails the post. - **What to do:** check the reason table. See [Product tag rules](https://docs.omnisocials.com/platforms/pinterest.md#product-tag-rules). ## Bluesky ### Video upload refused - **You see:** "Please verify your email address on Bluesky before uploading videos." or "You have reached your daily video upload limit on Bluesky. Please try again tomorrow." - **Cause:** Bluesky needs a verified email for video uploads and sets a daily video limit per account. - **What to do:** verify the email on bsky.app (Settings, Account), or retry the next day. See [Bluesky media requirements](https://docs.omnisocials.com/platforms/bluesky.md#media-requirements). ## Mastodon ### A post passes validation but the server refuses it - **Cause:** OmniSocials validates media against the mastodon.social defaults, and some servers are stricter. The error then carries the server's own message. - **What to do:** adjust the post to the server's limits. See [Mastodon media requirements](https://docs.omnisocials.com/platforms/mastodon.md#media-requirements). ### The post doesn't show on other servers yet - **Cause:** federation delays between servers. - **What to do:** wait. A post can reach other servers later, even after a successful publish. See [Mastodon limitations](https://docs.omnisocials.com/platforms/mastodon.md#limitations). ## Google Business ### Caption refused for a phone number or link - **You see:** `400 validation_error` when you schedule. - **Cause:** Google rejects local posts whose caption holds a phone number, a URL, a bare domain or an email address. - **What to do:** put the phone number on a `CALL` button and the link on a CTA button. See [Caption rules](https://docs.omnisocials.com/platforms/google-business.md#caption-rules). ### No per-post analytics - **You see:** `metrics_unavailable: true` and a `note` on Google Business post analytics. - **Cause:** Google discontinued per-post insights. - **What to do:** read location metrics with `GET /analytics/accounts`. See [Google Business limitations](https://docs.omnisocials.com/platforms/google-business.md#limitations). ### Old posts disappear from the profile - **Cause:** Google ages local posts out after roughly 6 months. The post record stays in OmniSocials. ## Telegram ### Posts fail after the bot was removed - **You see:** a failed post whose message says OmniSocials was removed from the Telegram chat, or lost the right to post there. The account shows `needs_reconnect: true`. - **Cause:** someone removed the bot from the chat or took away its right to post. - **What to do:** add the bot back as an administrator with Post messages, then reconnect the chat in OmniSocials. See [When the connection breaks](https://docs.omnisocials.com/platforms/telegram.md#when-the-connection-breaks). ### The post was not pinned - **You see:** `telegram.result.pinned: false` with `pin_error: "no_right"`. - **Cause:** the bot lacks the **Edit messages** right in a channel, or **Pin messages** in a group. The post itself is still sent. - **What to do:** give the bot that right. See [Pinning](https://docs.omnisocials.com/platforms/telegram.md#pinning). ### A post can't be removed from Telegram - **Cause:** Telegram lets a bot remove a message only in the first 48 hours. - **What to do:** after 48 hours, remove it in Telegram yourself. See [Removing a post](https://docs.omnisocials.com/platforms/telegram.md#removing-a-post). --- ## Document: Bluesky API: posting reference Post to Bluesky via API: threads, images with alt text, automatic link cards, automatic image compression to the 2 MB cap, and the 300-grapheme limit. URL: https://docs.omnisocials.com/platforms/bluesky # Bluesky API: posting reference Bluesky is supported via the AT Protocol. OmniSocials posts to the specific account that completed OAuth: you enter your handle and sign in on Bluesky, with no app password to create. Both `bsky.social` and self-hosted PDS accounts work, and so do custom-domain handles. For an overview and use cases, see [Bluesky API](https://omnisocials.com/bluesky-api). **Channel ID:** `bluesky` ## Supported content types | Type | Supported | |------|-----------| | Feed post | ✅ | | Story | - | | Reel | - | ## Minimal example ```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": "Skeeting from the API 🦋" }, "accounts": ["your-bluesky-account-id"] }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `bluesky.thread_parts` | array | Publish as a chained thread instead of a single post. See [Posting threads](#posting-threads). | ## Posting threads To publish a thread (a reply chain), pass `bluesky.thread_parts`. Each part becomes its own post, chained as a reply to the previous one. ```json { "accounts": ["your-bluesky-account-id"], "content": { "default": "" }, "bluesky": { "thread_parts": [ { "text": "1/3: kicking off a thread on the AT Protocol 🦋" }, { "text": "2/3: every post is a record you own" }, { "text": "3/3: portable identity is the whole point" } ] } } ``` **Rules:** - 2 to 25 parts. For a single post, omit `thread_parts` and use `content.bluesky` (or `content.default`) instead. - Each `text` is required, non-empty, and ≤ 300 graphemes. Parts that exceed the limit are rejected with a `400`. - `media_urls` is optional per part: one video or up to 10 images per part (5 or more post as a gallery). A part without media gets its own [link card](#link-cards) when its text has a link. - Links, @mentions, and #hashtags are auto-detected and made clickable via AT Protocol rich-text facets; no manual markup needed. ## Link cards A post with a link in its text and no media gets a link card for the first link, built from the page's Open Graph data: title, description and the page's Open Graph image as the thumbnail. You don't need to pass anything extra. A post with media shows the media instead of a card. A page without Open Graph data gets no card, and a card that can't be built never fails the post. ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, or WebP. Max 2 MB per image after compression (see below). Up to 10 per post: 1 to 4 post as an image grid, 5 to 10 as a gallery. | | Video | MP4 or MOV, up to 10 minutes, max 300 MB. One per post. | Bluesky's servers reject image blobs over 2 MB. You do not need to pre-compress: OmniSocials automatically compresses oversized images down to fit the 2 MB cap at upload time, so you can pass images up to 10 MB and let the API handle it. Video uploads need a verified email address on the Bluesky account (bsky.app, Settings, Account), and Bluesky sets a daily video upload limit per account. Images keep their real aspect ratio in the post. Bluesky does not support mixing images and video in the same post: attach images only, or a single video. Mixed media returns a `400 validation_error` at create/schedule time. **Alt text.** Bluesky renders per-image alt text natively. Pass it by using an object entry instead of a bare string: `{ "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" }` in `media_urls` (or `{ "id": "...", "alt": "..." }` in `media_ids`, including per-part thread media; max 1500 characters). It is sent as the image/video embed's `alt`. ## Character limit Bluesky enforces a 300 **grapheme** limit per post (emoji count as 1 grapheme, not their UTF-8 byte length). OmniSocials counts graphemes correctly when validating. ## Limitations - Links, mentions, and hashtags are made clickable automatically (via rich-text facets); you do not need to add any markup - Bluesky does not expose post scheduling as a first-class concept. Scheduled OmniSocials posts are held on our side and published at the scheduled time. - Polls are not supported on Bluesky - Bluesky replies and mentions are not part of the Social Inbox --- ## Document: Facebook API: posting reference Post to Facebook Pages via the OmniSocials API: feed posts, multi-photo and link posts, Reels, stories, first comments, media specs, and character limits. URL: https://docs.omnisocials.com/platforms/facebook # Facebook API: posting reference Facebook is supported via the Facebook Graph API. OmniSocials posts to Facebook Pages (not personal profiles or Groups). The Facebook Page must be administered by the user who connects the account, and a workspace posts to one Page: when the sign-in gives access to several Pages, you pick one right after connecting. For an overview and use cases, see [Facebook API](https://omnisocials.com/facebook-api). **Channel ID:** `facebook` ## Supported content types | Type | Supported | Notes | |------|-----------|-------| | Feed post | ✅ | Text with photos, one video, or a link preview (not mixed) | | Story | ✅ | Image or video, no caption. Up to 10 slides per post, each published as its own story in order | | Reel | ✅ | Vertical video | ## Minimal example ```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": "Weekly update from the team" }, "accounts": ["your-facebook-page-id"] }' ``` Unlike Instagram, Facebook accepts text-only posts with no media. ## Platform-specific options Set these under the `facebook` key on the post body: | Field | Type | Description | |-------|------|-------------| | `facebook.first_comment` | string | Text auto-posted as the first comment right after the post publishes (max 8000 characters). Feed posts and reels; not posted for stories. | | `facebook.thumbnail_type` | string | Video thumbnail for feed videos and reels: `from-video` or `from-library`. The top-level [`video_cover`](https://docs.omnisocials.com/creating-posts.md#video-covers) does the same for every platform at once. | | `facebook.thumb_offset` | number | Timestamp in milliseconds, used when `thumbnail_type` is `from-video`. | | `facebook.cover_url` | string | URL of a custom cover image (JPEG or PNG, max 10 MB), used when `thumbnail_type` is `from-library`. | Facebook has no *required* option fields. Aside from the optional first comment and thumbnail, the default create-post body is sufficient for most use cases. The thumbnail is set right after the video is live (`POST /{video_id}/thumbnails`). If Facebook refuses it, the post still publishes with the default frame and the refusal is logged. ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, GIF, or WebP (WebP is transcoded to JPEG at post time). Max 10 MB. | | Video | MP4 or MOV, up to 240 minutes, max 1 GB | | Story image | JPEG or PNG, 9:16 recommended | | Story video | MP4, up to 60 seconds per slide (OmniSocials' cap) | | Reel video | MP4, vertical 9:16, up to 240 minutes (same as a feed video; Facebook removed the 90 second Reel limit) | | Multiple photos | 2 or more images in one feed post | Facebook does not support mixing images and video in the same post: attach images only, or a single video. Mixed media returns a `400 validation_error` at create/schedule time. **Alt text.** Per-media `alt` entries are accepted but not delivered; the platform's API does not support alt text on third-party posts. It is safe to send the same media objects you use for other platforms. ## Character limit 63,206 characters per post. In practice you will never hit it; write for readers, not the cap. ## Limitations - Personal Facebook profiles (not Pages) are not supported - Posts to Groups are not currently supported - One Facebook Page per workspace - Link previews are generated automatically from the first URL in the post content when no media is attached. Once Facebook has drawn the preview card, OmniSocials removes the URL from the post text; when Facebook draws no card, the URL stays in the text - Mentioning another Page is possible in the OmniSocials composer only, not through the API - Facebook reports no reach for posts; see [Analytics](https://docs.omnisocials.com/analytics.md) for the metrics that are collected - Facebook's API does not guarantee delivery timing for scheduled posts. Expect publish times within a few minutes of the scheduled time. --- ## Document: Google Business Profile API: posting reference Publish Google Business Profile posts via the OmniSocials API: CTA buttons, image rules, and what its analytics do and do not report. URL: https://docs.omnisocials.com/platforms/google-business # Google Business Profile API: posting reference Google Business is supported via the Google Business Profile API. OmniSocials publishes "local posts" (the short updates that appear on your Google Business Profile) to a specific business location, which the workspace owner selects when connecting the account. For an overview and use cases, see [Google Business Profile API](https://omnisocials.com/google-business-api). **Channel ID:** `google_business` ## Supported content types | Type | Supported | Notes | |------|-----------|-------| | Local post (feed post) | ✅ | Text-only, or text + a single image, with an optional call-to-action button | | Story | - | Not supported by Google Business | | Reel | - | Not supported by Google Business | ## Minimal example ```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": "Spring sale: 20% off everything until Sunday." }, "accounts": ["your-google-business-account-id"], "media_urls": ["https://example.com/sale-banner.jpg"] }' ``` Media is optional. A caption-only post is a valid Google Business post. ## Caption rules Google Business rejects posts whose caption contains a phone number or a URL; the API returns a generic 400 with no specific field, so a post that contains either silently fails at publish time. OmniSocials catches this at scheduling and returns a `validation_error` 400 with a clear message so you can fix the input before saving the post. | Rule | Why | |------|-----| | **No phone numbers in the caption.** Put the phone number on a `CALL` ("Call now") CTA button instead; Google uses the phone number from your Business Profile automatically. | Google's content policy. | | **No inline URLs, bare domains, or email addresses in the caption.** Put the link on a `LEARN_MORE`, `BOOK`, `SHOP`, `SIGN_UP`, or `ORDER` CTA button instead. | Google's content policy. | | **Caption max 1500 characters.** | Google's hard limit. | | **A Google Business location must be selected on the workspace** (Settings → Organisation → Workspaces → Google Business). | Local posts target a single location; we surface this at scheduling instead of failing hours later at publish. | ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, or WebP (WebP is converted to JPEG at post time). 10 KB to 5 MB. Recommended 720 × 540 minimum, 4:3 aspect ratio. | | Video | Not supported. Google Business has been quietly disabling video on local posts; passing a video URL is rejected at scheduling with a 400. | Google Business local posts accept **at most one** image per post. Posts with multiple media URLs (carousels) are rejected; only one image fits in a local post's Google Business UI. ## Post types Google Business supports three post types beyond a plain caption. Pass `google_business.topic_type` to switch; `STANDARD` is the default. ### Call-to-action button (works on every post type) Add a button rendered under the caption. `actionType` is required; `url` is required for every value except `CALL` (which uses the location's phone number from the business profile). ```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": "Spring sale: 20% off everything until Sunday." }, "accounts": ["your-google-business-account-id"], "media_urls": ["https://example.com/sale-banner.jpg"], "google_business": { "cta": { "actionType": "LEARN_MORE", "url": "https://example.com/sale" } } }' ``` `actionType` enum: `LEARN_MORE` · `BOOK` · `ORDER` · `SHOP` · `SIGN_UP` · `CALL`. Google shows them as the buttons Learn more, Book, Order online, Buy, Sign up and Call now; the OmniSocials composer uses the same labels. ### EVENT posts ```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": "Join us for our grand opening." }, "accounts": ["your-google-business-account-id"], "google_business": { "topic_type": "EVENT", "event": { "title": "Grand opening", "schedule": { "startDate": { "year": 2026, "month": 6, "day": 1 }, "startTime": { "hours": 14, "minutes": 0 }, "endDate": { "year": 2026, "month": 6, "day": 1 }, "endTime": { "hours": 18, "minutes": 0 } } } } }' ``` `event.title` is required (max 58 chars). `schedule.startDate` is required; `endDate` (if present) must be ≥ `startDate`. ### OFFER posts ```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": "Spring sale: 20% off your first order" }, "accounts": ["your-google-business-account-id"], "google_business": { "topic_type": "OFFER", "event": { "title": "Spring sale", "schedule": { "startDate": { "year": 2026, "month": 4, "day": 1 }, "endDate": { "year": 2026, "month": 6, "day": 30 } } }, "offer": { "couponCode": "SPRING20", "redeemOnlineUrl": "https://example.com/spring", "termsConditions": "Valid through June 30. One per customer." } } }' ``` Google requires an `event` on offers too: `event.title` is the offer's name (max 58 chars) and `schedule.startDate` its first day; `endDate` (if present) must be ≥ `startDate`. An `OFFER` without `event.title` or `event.schedule.startDate` returns `400 validation_error`. The `offer` must include at least one of `couponCode` (max 58 chars) or `redeemOnlineUrl` (must be HTTPS). `termsConditions` is optional, max 4000 chars. ## Limitations - **Per-post analytics are not available.** Google deprecated per-post insights on local posts. The OmniSocials analytics API returns no numbers for individual Google Business posts: the entry carries `metrics_unavailable: true` and an explanatory `note` instead. - **Account-level metrics are available** for the location via Google's Performance API: views (split across Desktop / Mobile, Maps / Search), calls, direction requests, website clicks, bookings, food orders and menu clicks, plus the rating and the review count. Read them with `GET /analytics/accounts` (MCP: `get_account_analytics`) or on the analytics page in the dashboard, as a daily series over the last 30 days. - **No follower concept.** Google Business does not expose a follower count, so the account analytics carry no `followers` value. - **Location-bound.** Posts target the single location selected during connection; a workspace posts to one location. To post to multiple locations, connect each location as a separate workspace. - **Posts disappear after 6 months.** This is a Google Business product behavior: local posts age out of the profile after roughly 6 months. OmniSocials still has the original post record in your post history. ## Connecting Connect Google Business via **Settings → Organisation → Workspaces → connect Google Business** in the OmniSocials dashboard. Right after the Google sign-in you pick the business location the workspace publishes to; when the account manages only one location, it is selected automatically. A Google account without any Business Profile location cannot be connected. The workspace cannot schedule Google Business posts until a location is selected. If you manage multiple Google Business locations, you can connect each one to a separate OmniSocials workspace and switch between them via the workspace selector. --- ## Document: Instagram API: posting reference Publish posts, carousels, Reels, and stories to Instagram via API: media specs, collaborators, location tags, user tags, alt text, comment-to-DM automations, and the 5-hashtag cap. URL: https://docs.omnisocials.com/platforms/instagram # Instagram API: posting reference Instagram is supported via the Instagram API with Instagram Login. Connected accounts must be Instagram Business or Creator accounts; no Facebook Page is needed to publish. Personal Instagram accounts are not supported by Meta's API and cannot be connected. For an overview and use cases, see [Instagram API](https://omnisocials.com/instagram-api). **Channel ID:** `instagram` ## Supported content types | Type | Supported | Notes | |------|-----------|-------| | Feed post | ✅ | Single image, video, or carousel (up to 10 items) | | Story | ✅ | Requires media (image or video). No caption is sent. Up to 10 slides per post, each published as its own story in order | | Reel | ✅ | Requires a vertical video | ## Minimal example ```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": "Golden hour 🌅" }, "accounts": ["your-instagram-account-id"], "media_urls": ["https://example.com/sunset.jpg"] }' ``` Instagram posts always require media. A post with no media returns `400`. ## Instagram options Set these under the `instagram` key on the post body: | Field | Type | Description | |-------|------|-------------| | `instagram.first_comment` | string | Text auto-posted as the first comment right after the post publishes (max 2200 characters). Common for keeping hashtags out of the caption. Works on feed posts and reels; not posted for stories. | | `instagram.share_to_feed` | boolean | Reel only. Also display the reel on the profile grid. | | `instagram.thumbnail_type` | string | Video thumbnail (reels and single-video feed posts): `from-video` or `from-library`. The top-level [`video_cover`](https://docs.omnisocials.com/creating-posts.md#video-covers) does the same for every platform at once. | | `instagram.thumb_offset` | number | Timestamp in milliseconds, used when `thumbnail_type` is `from-video`. | | `instagram.cover_url` | string | URL of a custom cover image, used when `thumbnail_type` is `from-library`. | ## Instagram-only post options These are set as **top-level** fields on the post body (not under the `instagram` key) and are ignored by other platforms: | Field | Type | Description | |-------|------|-------------| | `location_id` | string | Facebook Place/Page ID to tag the post's location. Single-image and carousel feed posts only. Find a Place ID with `GET /locations/search`. | | `collaborators` | array of strings | Up to 3 public Instagram usernames invited as co-authors (the "Collab" feature). Image, carousel, and reel posts; not stories. | | `user_tags` | array | Tag public accounts at positions on a photo: `[{"username": "name", "x": 0.5, "y": 0.8, "image_index": 0}]`. `x`/`y` are 0–1 from the top-left. Photos only, not video, reels, or stories. | A fourth top-level field, `media_fit`, is not Instagram-only: it is keyed by channel (`{ "instagram": "crop" | "keep" }`) and decides what happens to a feed image outside 4:5 to 1.91:1. See [Image fit per channel](https://docs.omnisocials.com/creating-posts.md#image-fit-per-channel). ## Media requirements | Media | Requirement | |-------|-------------| | Feed image | JPEG, PNG, or WebP, max 8 MB, 320 to 1440 px wide, aspect ratio between 9:16 (portrait) and 1.91:1 (landscape). Instagram shows feed images between 4:5 and 1.91:1 and cuts anything taller to 4:5 itself, so outside 4:5 to 1.91:1 OmniSocials crops the image to 4:5 (tall) or 1.91:1 (wide) around the subject by default and reports it in the response `warnings`; pass `media_fit: { "instagram": "keep" }` to send the original. See [Image fit per channel](https://docs.omnisocials.com/creating-posts.md#image-fit-per-channel) | | Feed video | MP4 or MOV, up to 15 minutes, max 300 MB | | Story image | JPEG or PNG, aspect ratio 9:16 recommended | | Story video | MP4, up to 60 seconds per slide, max 300 MB | | Reel video | MP4, vertical 9:16, up to 15 minutes, max 300 MB | | Carousel | 2 to 10 items; images and videos can be mixed in one carousel | Content that violates aspect ratio or duration is rejected at publish time with a `400` error referencing the Instagram Graph API response. ## Alt text Pass an accessibility description per media item by using an object entry instead of a bare string (works in `media_urls` and `media_ids`). It is sent as Instagram's `alt_text` on image posts and on each image slide of a carousel; every slide carries its own description. Reels and Stories don't support alt text (an Instagram limitation), and Instagram caps descriptions at 1,000 characters, so longer values are clamped at publish time: ```json { "content": { "default": "Morning ride before work" }, "accounts": ["your-instagram-account-id"], "media_urls": [ { "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" } ] } ``` The same `alt` also delivers to Mastodon, Bluesky, X, Pinterest, and LinkedIn (images), so one description covers a cross-platform post. ## Character and hashtag limits Captions cap at 2,200 characters. Instagram also rejects any caption or comment carrying more than **5 hashtags** ("Too many hashtags"). OmniSocials checks the cap when a [hashtag set](https://docs.omnisocials.com/creating-posts.md#hashtag-sets) is applied to the post. A caption or `first_comment` you write yourself is sent as it is, so keep it at 5 hashtags or fewer. ## Comment-to-DM automations An Instagram post can send a DM to everyone who comments a keyword on it, once per person, with an optional public reply under the comment. The same automation can also listen to a keyword in a DM or a story reply. Instagram allows one private reply per comment, within 7 days of the comment, and OmniSocials sends at most 180 DMs an hour per account. Attach one at create time with `comment_dm_automation`, or manage them with the [Automations endpoints](https://docs.omnisocials.com/automations.md). ## Limitations - Personal (non-business) Instagram accounts cannot be connected - Tagging users in media requires each tagged user to be a public account - Stories carry no caption; text has to be part of the image or video --- ## Document: LinkedIn API: posting reference Post to LinkedIn profiles and company pages via API: native document carousels, the original PDF, polls, first comments, and media specs. URL: https://docs.omnisocials.com/platforms/linkedin # LinkedIn API: posting reference LinkedIn appears as two independent channels: one for a personal profile and one for a company page. A single workspace can connect both at the same time and post to each separately. For an overview and use cases, see [LinkedIn Profile API](https://omnisocials.com/linkedin-profile-api) and [LinkedIn Page API](https://omnisocials.com/linkedin-page-api). | Channel ID | Target | |------------|--------| | `linkedin` | Personal profile (the individual who connected the account) | | `linkedin_page` | Company page (the organization whose page was selected during OAuth) | A workspace posts to one company page. When the connecting member administers several Pages, they pick one right after signing in to LinkedIn. Both channels share a single LinkedIn OAuth token under the hood. Reconnecting either refreshes credentials for both. LinkedIn access tokens last 60 days. When LinkedIn issued a refresh token with the connection, OmniSocials renews the token automatically before it expires, until LinkedIn's one-year refresh limit; otherwise the workspace gets an email 7 days before the expiry and the channel has to be reconnected. ## Supported content types | Type | `linkedin` (Profile) | `linkedin_page` (Page) | |------|----------------------|------------------------| | Feed post | ✅ | ✅ | | Story | - | - | | Reel | - | - | LinkedIn does not support stories or reels through its API. Only feed posts work. ## Minimal example **Posting to a personal profile:** ```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": "Excited to share our latest update." }, "accounts": ["your-linkedin-profile-account-id"] }' ``` **Posting to a company page:** ```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": "Company announcement." }, "accounts": ["your-linkedin-page-account-id"] }' ``` **Posting the same content to both simultaneously:** include both account IDs in the `accounts` array. OmniSocials publishes to each independently. ```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": "Same content on both", "linkedin": "Personal voice for the profile", "linkedin_page": "Official company announcement" }, "accounts": [ "your-linkedin-profile-account-id", "your-linkedin-page-account-id" ] }' ``` ## Platform-specific options Set a first comment under the channel's own key: `linkedin` for the profile, `linkedin_page` for the company page: | Field | Type | Description | |-------|------|-------------| | `linkedin.first_comment` | string | Text auto-posted as the first comment on the profile post right after it publishes (max 1250 characters). Common for "link in first comment" to avoid the in-caption link reach penalty. | | `linkedin_page.first_comment` | string | Same, for the company page post (max 1250 characters). | | `linkedin.carousel_as_images` | boolean | `true` publishes 2 or more images as a plain multi-image gallery instead of the default document carousel. See [Media requirements](#media-requirements). | | `linkedin.document_source` | string | `original_pdf` (default) or `slides`. When the images are the unchanged pages of one uploaded PDF, the default sends that original file as the document; `slides` rebuilds it from the slide images. See [Original PDF](#original-pdf). | | `linkedin_page.carousel_as_images`, `linkedin_page.document_source` | | Same, for the company page post. Independent per channel. | | `linkedin_poll` | object | Non-sponsored poll; see [Polls](#polls) below. | | `video_cover` | object | Top-level. Video thumbnail for a video post, applied to both channels; use `video_cover.overrides.linkedin` or `.linkedin_page` to give one channel its own frame or image. See [Video covers](https://docs.omnisocials.com/creating-posts.md#video-covers). | Aside from the optional first comment and poll, LinkedIn has no required option fields; the default create-post body works for both channels. Video thumbnails go through LinkedIn's Videos API (`initializeUpload` with `uploadThumbnail`, then the image is uploaded before the video is finalized). A thumbnail that cannot be uploaded never fails the post; LinkedIn then picks its own frame. ## Polls LinkedIn's Poll API lets you post a non-sponsored poll: a question with 2-4 answer options and a duration. Pass a top-level `linkedin_poll` object keyed by channel: `linkedin` (personal profile) and `linkedin_page` (company page) each carry their own **independent** poll, or none. `content` is still the post's caption, shown above the poll. ```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": "What should we build next?" }, "accounts": ["your-linkedin-account-id"], "linkedin_poll": { "linkedin": { "question": "What should we build next?", "options": ["Mobile app", "Public API", "More integrations"], "duration": "SEVEN_DAYS" } } }' ``` | Field | Type | Description | |-------|------|--------------| | `linkedin_poll.linkedin` | object \| null | Poll for the personal profile post. Omit or null = not a poll. | | `linkedin_poll.linkedin_page` | object \| null | Poll for the company page post. Omit or null = not a poll. | Each poll object: | Field | Type | Description | |-------|------|--------------| | `question` | string | The poll question (max 140 characters). | | `options` | string[] | 2-4 answer options (max 30 characters each). | | `duration` | string | `ONE_DAY` \| `THREE_DAYS` \| `SEVEN_DAYS` \| `FOURTEEN_DAYS`. | A poll is mutually exclusive with media and a link share on that channel's post; if the request also carries `media_urls`/`media_ids` or a link share, the poll silently takes priority at publish time (same precedence LinkedIn's own media-vs-article rule uses), so don't send both. Post a **different** poll to the profile and the page in one call by setting both keys: ```json { "linkedin_poll": { "linkedin": { "question": "What should WE build?", "options": ["A", "B"], "duration": "SEVEN_DAYS" }, "linkedin_page": { "question": "What should our COMPANY build?", "options": ["A", "B", "C"], "duration": "FOURTEEN_DAYS" } } } ``` `linkedin_poll` is also accepted on `PATCH /posts/{id}`; it replaces the whole object wholesale (both channels), so send the full desired state, not just the channel you're changing. `GET /posts/{id}` echoes it back. Set a channel's key to `null` (or send `linkedin_poll: null`) to clear that channel's poll (or every poll) and revert to a normal post. ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, GIF, or WebP (WebP is converted to JPEG at post time), max 10 MB | | Video | MP4 or MOV, up to 30 minutes, max 5 GB | LinkedIn supports text-only posts, a single image, a multi-image carousel, or video. Mixing image and video in the same post is not supported. Posting 2 or more images publishes LinkedIn's native document carousel: OmniSocials renders the images into a single PDF and uploads it as a document, the same swipeable format LinkedIn's own composer produces, instead of a plain multi-image gallery. The API sets no LinkedIn image count of its own; the OmniSocials composer takes up to 9 images per post. When the images are the pages of a PDF you uploaded, LinkedIn receives that original file instead; see [Original PDF](#original-pdf). Prefer the plain multi-image gallery for a specific post? Set `carousel_as_images: true` on the `linkedin` and/or `linkedin_page` option object (the same objects that carry `first_comment`). The flag is independent per channel and is ignored for text-only posts, single images, videos, and polls: ```json { "content": { "default": "Five looks from the spring shoot" }, "accounts": ["your-linkedin-account-id"], "media_urls": ["https://example.com/1.jpg", "https://example.com/2.jpg"], "linkedin": { "carousel_as_images": true } } ``` On `PATCH /posts/{id}` the flag merges per channel: `true` sets it, `false` (or `null`) reverts that channel to the default document carousel, and omitting it keeps the current value. `GET /posts/{id}` echoes it back inside each channel's option object. ## Original PDF [Uploading a PDF](https://docs.omnisocials.com/media.md#upload-a-pdf-as-a-carousel) renders its pages to images and keeps the original file, either as one item per page or as one document item (`pdf_mode: "document"`, the web library's default). When a LinkedIn post's images are exactly the rendered pages of one PDF, in order and unchanged, LinkedIn receives that original file as the document instead of one rebuilt from the page images. Text stays sharp, in-document links keep working, viewers download the real file, and every page is included, even past the 20-slide cap. Nothing to set: attach the slide `media_ids` the upload returned, in order. The check is per channel and per post. Reordering, removing or editing a slide, or adding another image, means the images are no longer that PDF's pages, and the document is rebuilt from the slides as before. The same applies to a PDF over 100 MB or 300 pages (LinkedIn's document limits). To send the rebuilt document on purpose, for example to publish a flattened copy, set `document_source: "slides"` on the channel's option object: ```json { "content": { "default": "Our Q3 strategy deck" }, "accounts": ["your-linkedin-account-id"], "media_ids": ["201", "202", "203"], "linkedin": { "document_source": "slides" } } ``` On `PATCH /posts/{id}` it merges per channel: `"slides"` sets it, `"original_pdf"` (or `null`) reverts to the default, and omitting it keeps the current value. `GET /posts/{id}` echoes `document_source` inside the channel's option object when it is set to `"slides"`. The option is ignored when the images are not a PDF's pages, when `carousel_as_images` is `true`, and for text-only posts, single images, videos and polls. ## Alt text Pass an accessibility description per media item by using an object entry instead of a bare string (works in `media_urls` and `media_ids`). It is sent as LinkedIn's `altText` on images: both single-image posts and multi-image galleries, where each image carries its own description. Video and documents don't support alt text (a LinkedIn limitation): ```json { "content": { "default": "Team offsite highlights" }, "accounts": ["your-linkedin-account-id"], "media_urls": [ { "url": "https://example.com/team.jpg", "alt": "Six people around a whiteboard covered in sticky notes" } ] } ``` The same `alt` also delivers to Mastodon, Bluesky, X, Pinterest, and Instagram (images), so one description covers a cross-platform post. ## Mentions Mentions are available in the OmniSocials web composer only: type `@` to mention a company by its exact LinkedIn name, or a person who follows a Page you administer. The API, the MCP server and the CLI have no mentions field, so an `@name` in `content` is published as plain text, not as a mention. ## Limitations - Stories and reels are not available through LinkedIn's public API - LinkedIn enforces a 3,000 character limit per post. Posts exceeding this are rejected. - Posting to company pages requires the connecting user to be an administrator of the page (media uploads need a Super admin or Content admin role). One page per workspace. - The personal profile channel (`linkedin`) requires the Community Management API product on LinkedIn Developer, which is automatically configured for OmniSocials customers --- ## Document: Mastodon API: posting reference Post to any Mastodon instance via the OmniSocials API: threads, polls, media, and per-instance character limits read at connect time. URL: https://docs.omnisocials.com/platforms/mastodon # Mastodon API: posting reference Mastodon is supported via the standard Mastodon API. OmniSocials posts to the specific instance and account that completed OAuth. Because Mastodon is decentralized, every account lives on a different server and is identified by its full `user@instance.tld` handle. Any Mastodon-compatible server works: you type the server address when you connect, and OmniSocials registers its app on that server for you. For an overview and use cases, see [Mastodon API](https://omnisocials.com/mastodon-api). **Channel ID:** `mastodon` ## Supported content types | Type | Supported | |------|-----------| | Feed post (toot) | ✅ | | Story | - | | Reel | - | ## Minimal example ```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": "Posting from the API 🐘" }, "accounts": ["your-mastodon-account-id"] }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `mastodon.thread_parts` | array | Publish as a chained thread instead of a single toot. See [Posting threads](#posting-threads). | | `mastodon.poll` | object | Attach a poll; the toot text is the question. See [Polls](#polls). | Every toot is published as public. Visibility, content warnings and a language setting are not exposed. ## Posting threads To publish a thread, pass `mastodon.thread_parts`. Each part becomes its own toot, replying to the previous one natively. ```json { "accounts": ["your-mastodon-account-id"], "content": { "default": "" }, "mastodon": { "thread_parts": [ { "text": "1/3: a thread on the fediverse 🐘" }, { "text": "2/3: every instance sets its own rules" }, { "text": "3/3: and that is a feature, not a bug" } ] } } ``` **Rules:** - 2 to 25 parts. For a single toot, omit `thread_parts` and use `content.mastodon` (or `content.default`) instead. - Each `text` is required, non-empty, and within your instance's character limit (500 on mastodon.social; OmniSocials reads your instance's real limit at connect time). - `media_urls` is optional per part (max 4 items per part). ## Polls Pass `mastodon.poll` to attach a poll. The toot text is the question. ```json { "accounts": ["your-mastodon-account-id"], "content": { "mastodon": "Which feature should we ship next?" }, "mastodon": { "poll": { "options": ["Inbox", "Reports", "Ads"], "expires_in": 259200, "multiple": false, "hide_totals": true } } } ``` | Field | Type | Description | |-------|------|-------------| | `options` | array of strings | The answers. Count and length follow your server's limits (Mastodon's defaults: 2 to 4 options of up to 50 characters). Options must differ from each other. | | `expires_in` | integer | How long the poll runs, in seconds, within your server's range (Mastodon's default: 5 minutes to about 1 month). Leave it out and the poll runs for 86400 seconds (one day). The composer in the OmniSocials dashboard offers 5 minutes to 30 days, limited to your server's range. | | `multiple` | boolean | Let people pick several answers (default `false`). | | `hide_totals` | boolean | Hide the vote counts until the poll ends (default `false`). | A poll and media do not go together on Mastodon: a request with both is refused with `400 validation_error`. On a thread (`thread_parts`), the poll goes on the first toot. `GET /posts/:id` returns the poll under `mastodon.poll`; on `PATCH /posts/:id`, `mastodon.poll: null` removes it. Poll results come back in analytics under `metrics.poll`. ## Media requirements Mastodon media limits vary by instance. OmniSocials validates against the default mastodon.social limits: | Media | Requirement (default) | |-------|-----------------------| | Image | JPEG, PNG, GIF, or WebP. Max 16 MB. Up to 4 per post. | | Video | MP4, MOV, or WebM, max 99 MB. Duration is instance-dependent; OmniSocials does not enforce a fixed cap. OmniSocials waits for the server to finish processing a video before it sends the toot. | Mastodon does not support mixing images and video in the same status: attach images only, or a single video. Mixed media returns a `400 validation_error` at create/schedule time. Some instances are more permissive. Some are stricter. A post that passes OmniSocials validation may still be rejected by a custom instance with tighter limits. In that case the error body includes the instance's rejection message. ### Alt text The Mastodon community strongly values alt text on images; several instances even run bots reminding users to add descriptions. Pass it per media item by using an object entry instead of a bare string (works in `media_urls`, `media_ids`, and per-part thread media; max 1500 characters). It is sent as the media's `description`, and the same `alt` also delivers to Bluesky, X, Threads, Pinterest, Instagram (images), and LinkedIn (images): ```json { "content": { "default": "Morning ride before work 🐘" }, "accounts": ["your-mastodon-account-id"], "media_urls": [ { "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" } ] } ``` ## Character limit Your instance's real limit applies. When you connect a Mastodon account, OmniSocials reads the instance's configured `max_characters` and validates against that, so an instance with a 5,000-character limit really gets 5,000. The fallback is 500 (the mastodon.social default) when the instance does not report a limit. Characters are counted the way Mastodon counts them: every `http(s)` link counts as 23 characters (or your server's own URL length), a remote mention counts by its local part (`@user@server` counts as `@user`), and an emoji counts as 1. ## Limitations - Content warnings (CW) are not currently exposed through the API - Posts are always public. Per-post visibility (`unlisted`, `private`, `direct`) is not available, and the account's default visibility is not used. - Mastodon comments and mentions are not part of the Social Inbox - Analytics report likes, comments (replies), reposts (boosts) and quotes per toot, but no views or reach. Quotes are only reported by servers on Mastodon 4.5 or later. - Federation delays may mean the post is not immediately visible on other instances even after a successful publish response --- ## Document: Pinterest API: posting reference Create image, video, and carousel pins via the OmniSocials API: boards, destination links, alt text, product tags, and aspect-ratio rules. URL: https://docs.omnisocials.com/platforms/pinterest # Pinterest API: posting reference Pinterest is supported via the Pinterest API (v5), with one Pinterest account per workspace. OmniSocials publishes Pins to a specific board, which must be provided on every post. For an overview and use cases, see [Pinterest API](https://omnisocials.com/pinterest-api). **Channel ID:** `pinterest` ## Supported content types | Type | Supported | Notes | |------|-----------|-------| | Feed post (Pin) | ✅ | Image or video pin to a specific board | | Carousel pin | ✅ | 2–5 images in one swipeable pin (see [Carousel pins](#carousel-pins)) | | Story | - | Not supported | | Reel | - | Not supported | ## Minimal example ```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": "Spring color palette inspiration" }, "accounts": ["your-pinterest-account-id"], "media_urls": ["https://example.com/palette.jpg"], "pinterest": { "board_id": "1234567890", "title": "Spring palette" } }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `pinterest.board_id` | string | **Required.** The ID of the board to pin to. Get board IDs from `GET /accounts/:id`. Pinterest accounts include a `boards` array with `{ id, name }` entries for every board of the account, group and secret boards included. | | `pinterest.title` | string | Pin title. Highly recommended for discoverability. Max 100 characters. | | `pinterest.link` | string | Destination URL the pin links to when clicked. | | `pinterest.video_cover` | string | Cover image URL for video pins (JPEG/PNG). To use a frame of the video instead, pass the top-level [`video_cover`](https://docs.omnisocials.com/creating-posts.md#video-covers) with `type: "frame"`. If neither is set, Pinterest uses a video keyframe at 1 second. | | `pinterest.alt_text` | string | Accessibility alt text for the pin image (max 500 characters). Improves discoverability and supports screen readers. | | `pinterest.product_tags` | string[] | Products to tag on the Pin: up to 24 product Pins of the connected account, each as a Pin ID or a Pin link. See [Product tags](#product-tags). | Posts without `board_id` are rejected at validation. A new board can be created from the OmniSocials composer (Board, then **New board**); the API, the MCP server and the CLI pick from existing boards only. **Per-media alt fallback.** If `pinterest.alt_text` is not set, a per-media `alt` on the entry itself is used instead, e.g. `"media_urls": [{ "url": "https://example.com/pin.jpg", "alt": "Flat-lay of spring outfit ideas" }]`. Handy when you cross-post the same media (with one shared alt description) to Pinterest alongside Mastodon, Bluesky, X, Instagram, and LinkedIn. `pinterest.alt_text` always wins when both are present. ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, or WebP (WebP is transcoded to JPEG at post time). Max 20 MB. Minimum 600 × 600. | | Video | MP4 or MOV, 4 seconds to 15 minutes, max 2 GB. Converted to H.264 automatically when needed. | Pinterest recommends a 2:3 aspect ratio (e.g. 1000 × 1500) for best visibility in the feed. A Pin taller than 2:3 may be cut in the home feed (the Pin page shows it whole); OmniSocials keeps it as it is by default and reports it in the response `warnings` as `image_fit`. Pass `media_fit: { "pinterest": "crop" }` on the post to crop it to 2:3 around the subject instead. See [Image fit per channel](https://docs.omnisocials.com/creating-posts.md#image-fit-per-channel). ## Video pins Video pins use a cover image. Pass either: - An image URL in `pinterest.video_cover`, or - A frame of the video, with the top-level `video_cover` (`{ "type": "frame", "thumb_offset": 3000 }`, milliseconds; Pinterest takes whole seconds, so the offset is rounded), or - Neither, and Pinterest will use a keyframe at 1 second of the video as the cover (automatic fallback) ## Carousel pins Attach **2–5 images** to a single Pinterest post and OmniSocials publishes them as one swipeable Pinterest carousel pin (Pinterest's v5 `multiple_image_urls` source type), not separate pins. The pin-level `title`, `description`, `link`, and `alt_text` apply to the whole pin; Pinterest's standard carousel format doesn't expose per-slide metadata. ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": { "pinterest": "Spring lookbook: swipe through the set" }, "accounts": ["your-pinterest-account-id"], "media_urls": { "pinterest": [ "https://example.com/slide-1.jpg", "https://example.com/slide-2.jpg", "https://example.com/slide-3.jpg", "https://example.com/slide-4.jpg" ] }, "pinterest": { "board_id": "1234567890", "title": "Spring lookbook", "link": "https://example.com/spring", "alt_text": "Four-slide carousel of spring looks" } }' ``` ### Carousel constraints - **2 to 5 images per pin.** 1 image publishes as a normal image pin; 6+ images return `400 validation_error`. - **Same aspect ratio across all slides.** Pinterest's carousel endpoints reject mixed-ratio sets. OmniSocials probes slide dimensions when you schedule or publish-now and returns a synchronous `400 validation_error` listing the slides that don't match slide 1; you'll never get a deferred publish failure at cron time. Recommended ratios: **1:1** (square) or **2:3** (vertical). - **Images only.** Pinterest's `multiple_image_urls` discriminator doesn't accept videos. When the first media item is a video, the post publishes as a single-video pin and the images are dropped; when the first item is an image, the images publish as the carousel and the videos are dropped. Example aspect-ratio error response: ```json { "error": { "code": "validation_error", "message": "Pinterest carousel pins need every image at the same aspect ratio. Slides 2, 3 don't match slide 1; re-crop or remove them before publishing.", "mismatched_slides": [2, 3] } } ``` The `mismatched_slides` array is 1-indexed and lists every slide whose ratio diverges from slide 1's by more than 1%. Re-crop the listed slides to match slide 1 and re-submit. The same check runs on `POST /posts/create` (with `schedule_at` or `publish_now`), on `POST /posts/{id}/publish`, and on `PATCH /posts/{id}` when the update flips the post to `scheduled` status. **Drafts are exempt**, so you can iterate on media before committing to publish. ## Product tags Tag products on a Pin so people can shop the items in the image. A product tag is the ID of a **product Pin**: a Pin that Pinterest made from your catalog, or one of your Pins that links to a product page on a website you claimed on Pinterest. ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": { "pinterest": "Weekend outfit. Tap a product to shop it." }, "accounts": ["your-pinterest-account-id"], "media_urls": ["https://example.com/outfit.jpg"], "pinterest": { "board_id": "1234567890", "title": "Weekend outfit", "product_tags": ["813744226420795884", "https://www.pinterest.com/pin/813744226420795885/"] } }' ``` ### Find product Pins `GET /pinterest/products` lists the product Pins of the connected account. Use a returned `pin_id` in `pinterest.product_tags`. ```bash curl "https://api.omnisocials.com/v1/pinterest/products?source=catalog" \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" ``` | Parameter | Description | |-----------|-------------| | `source` | `catalog` reads the Pinterest catalog and adds `price`, `currency`, `availability` and `item_id` to each product. `pins` reads the account's own Pins and returns the ones Pinterest marks as product Pins. Default: `catalog` when the connection has catalog access, else `pins`. | | `product_group_id` | Catalog only. A group `id` from `product_groups` in the response. Default: the group named "All Products". | | `bookmark` | Cursor from the previous response, to get the next page. | | `page_size` | Catalog only. 1 to 100, default 25. | The catalog source needs catalog access on the Pinterest connection. Give it one time in the OmniSocials composer: open the Pinterest options of a post, select **Add products**, then **Connect catalog**. Without it, `source=catalog` answers `error.code = pinterest_catalog_access_required`, and the `pins` source still works. One `pins` call scans up to 250 Pins. `products` can be empty while `bookmark` is set: call again with the bookmark. An account with catalog access but without a catalog answers `no_catalog: true` with empty `products` and `product_groups`. Products are added on Pinterest at [pinterest.com/business/catalogs](https://www.pinterest.com/business/catalogs/). `GET /pinterest/products/validate?id=` checks one Pin before you use it. It returns `{ "valid": true, "pin_id": "...", "title": "..." }` for a product Pin of the account, `{ "valid": false, "reason": "..." }` for a Pin that cannot be tagged, and `{ "valid": false, "unverified": true }` when the check could not run (the publish step is then the final check). ### Product tag rules - **Max 24 products per Pin.** More, or an entry that is not a Pin ID or Pin link, returns `400 validation_error`. `pin.it` short links are not accepted. - **Your own products only.** Pinterest accepts a product only when its Pin is public, belongs to the same Pinterest account and links to a website that account claimed. Products of other merchants cannot be tagged through the API. - **Tags are added right after the Pin is published.** A product that Pinterest refuses never fails the post. The other products are still tagged. - **Read the outcome on the post.** After publishing, `GET /posts/{id}` returns `pinterest.product_tags_result`: ```json { "requested": 2, "tagged": ["813744226420795884"], "skipped": [ { "pin_id": "813744226420795885", "reason": "PIN_NOT_FROM_VERIFIED_DOMAIN" } ], "error": null } ``` | Reason | Meaning | |--------|---------| | `PIN_MISSING` | The Pin was not found. | | `PIN_IS_PRIVATE` | The Pin is private. | | `PRODUCT_METADATA_MISSING` | The Pin is not a product Pin. | | `PIN_NOT_FROM_VERIFIED_DOMAIN` | The product link is not on a website the account claimed on Pinterest. | | `PIN_NOT_FROM_SAME_USER_AS_HERO_PIN` | The Pin belongs to another Pinterest account. | `error` is set when the tag request itself failed (for example Pinterest did not answer); `tagged` is then empty. - **`PATCH` replaces the `pinterest` object.** Leave `product_tags` out (or send `[]`) to remove the tags from a draft or scheduled post. ## Character limit Pin descriptions cap at 800 characters. ## Limitations - Pins must target a specific board. Posting to the user's profile is not supported. - Rich pins (product, article, recipe) cannot be created through the API. Existing product Pins can be tagged on a Pin (see [Product tags](#product-tags)). - Collection Pins cannot be created through the API. Pinterest has them in its ads API only. - Pinterest requires HTTPS URLs for `link`. HTTP links are rejected. - Pinterest has no comments or DMs in the Social Inbox. - In the OmniSocials app, Pinterest works with repost automations both ways: image Pins can be reposted to other channels, and posts from other channels can be reposted as Pins. These automations are set up in the dashboard, not through the API. --- ## Document: Telegram API: posting reference Post to a Telegram channel or group via the OmniSocials API: photos, videos, albums, native polls, link buttons, pins and silent posts. URL: https://docs.omnisocials.com/platforms/telegram # Telegram API: posting reference OmniSocials posts to a Telegram **channel** or **group** through the OmniSocials bot. The workspace connects the chat once in the OmniSocials dashboard (Channels, then Add a channel, then Telegram): the person opens the bot in Telegram, picks the channel or group, and Telegram adds the bot as an admin that can post. OmniSocials checks that the person who connects is an admin of that chat. Each workspace connects one Telegram chat. In a channel, a post shows as the channel itself. In a group, a post shows as the OmniSocials bot. For an overview and use cases, see [Telegram API](https://omnisocials.com/telegram-api). **Channel ID:** `telegram` ## Supported content types | Type | Supported | |------|-----------| | Text post | ✅ | | Photo, video, GIF or file | ✅ | | Album (2 to 10 photos and videos) | ✅ | | Native poll | ✅ | | Reel | ✅ (sent as a video message) | | Story | - | ## Minimal example ```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": "The new collection is live." }, "channels": ["telegram"], "media_urls": ["https://example.com/collection.jpg"], "telegram": { "silent": true, "pin": true } }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `telegram.silent` | boolean | Send without a notification sound. | | `telegram.link_preview` | boolean | `false` hides the link preview of a text-only post. The preview shows by default. | | `telegram.pin` | boolean | Pin the post in the chat after it is sent. See [Pinning](#pinning). | | `telegram.protect_content` | boolean | Stop forwarding and saving of the post in Telegram apps. | | `telegram.spoiler` | boolean | Blur the photos and videos until someone taps them. | | `telegram.caption_above_media` | boolean | Show the text above the media instead of below. | | `telegram.buttons` | array | Up to 3 link buttons under the post. See [Buttons](#buttons). | | `telegram.poll` | object | A native Telegram poll; the post text is the question. See [Polls](#polls). | | `telegram.first_comment` | string | A reply the bot sends under the post right after it is published (max 4,096 characters). | On `PATCH /posts/:id`, every key merges into the stored options, and a key set to `null` removes it (for example `"telegram": { "poll": null }`). ## Buttons ```json { "channels": ["telegram"], "content": { "telegram": "Early access opens today." }, "media_urls": ["https://example.com/launch.jpg"], "telegram": { "buttons": [ { "text": "Get early access", "url": "https://example.com/early" }, { "text": "Read the story", "url": "https://example.com/blog/launch" } ] } } ``` **Rules:** - At most 3 buttons. Each button has its own row. - `text` is 1 to 64 characters. `url` starts with `https://`, `http://` or `tg://`. - Telegram shows no buttons under an album (2 or more media) or a poll. A request with buttons on an album or a poll is refused with `400 validation_error`. ## Polls ```json { "channels": ["telegram"], "content": { "telegram": "Which day suits the live Q&A?" }, "telegram": { "poll": { "options": ["Tuesday", "Thursday", "Saturday"], "open_period": 86400, "multiple": false } } } ``` | Field | Type | Description | |-------|------|-------------| | `options` | array of strings | 2 to 12 answers of up to 100 characters. The answers must differ from each other. | | `open_period` | integer | Seconds the poll stays open, 5 to 2628000 (about 30 days). Leave it out and the poll stays open until someone closes it in Telegram. | | `multiple` | boolean | Let people pick several answers (default `false`). | The question is the post text, max 300 characters. A poll carries no media and no buttons: a request with both is refused with `400 validation_error`. The poll is anonymous, the Telegram default for channels. The votes come back in analytics under `metrics.poll`, updated while people vote. ## Pinning `telegram.pin: true` pins the post after it is sent. The bot needs the **Edit messages** right in a channel, or the **Pin messages** right in a group. OmniSocials asks for these rights when the chat is connected. When the bot cannot pin, the post is still sent: `GET /posts/:id` then returns `telegram.result.pinned: false` with `pin_error: "no_right"`. ## Long text with media A message holds up to 4,096 characters, but Telegram limits the text under media to 1,024 characters. When a post with media has a longer text, OmniSocials sends the media first and the full text as a reply under it. Buttons then go on the text message. A text-only post can use all 4,096 characters. ## Media requirements | Media | Requirement | |-------|-------------| | Photo | JPEG and PNG go as they are; other formats such as WebP are sent as JPEG. Telegram allows 10 MB and a width plus height of 10,000 pixels; OmniSocials resizes a larger photo (up to 40 MB) to fit. A photo wider or taller than 20:1 is sent as a file. | | Video | MP4 (H.264), max 50 MB. No duration cap. | | GIF | Max 50 MB. A GIF goes on its own, never in an album. | | File (PDF and similar) | Max 50 MB. Files do not share an album with photos or videos. | An album holds 2 to 10 photos and videos, which can be mixed. OmniSocials uploads every file to Telegram from its own servers, so the 5 MB and 20 MB limits of Telegram's URL upload do not apply. ## Character limit 4,096 characters per message, counted in UTF-16 code units the way Telegram counts them: most emoji count as 2. With media, see [Long text with media](#long-text-with-media). A poll question allows 300 characters. ## Published post `GET /posts/:id` returns under `telegram.result`: | Field | Description | |-------|-------------| | `chat_id` | The Telegram chat the post went to. | | `message_ids` | The ids of the sent messages (one per album item). | | `text_message_id` | The reply that holds the long text, when the text went separately. | | `pinned`, `pin_error` | Whether the pin worked. | | `link_public`, `link_private` | The `t.me` link of a public chat, or the `t.me/c/...` link that only members can open. | | `sent_at` | When the post went out. | `published_urls.telegram` is the public link when the chat has a username, else the private link. ## Analytics Telegram gives bots no view counts. A Telegram post carries: - `likes`: all reactions together - `reactions`: the count per emoji - `poll`: the votes per answer, for a poll The account row carries the subscribers of a channel, or the members of a group. Views, reach, forwards and comments are not reported; open the post in Telegram to see its views. ## Removing a post Telegram lets a bot remove a message only in the first 48 hours after it was sent. In that window, "Delete from Telegram" on the post in the OmniSocials dashboard removes every message of the post. After 48 hours, remove the post in Telegram yourself. `DELETE /posts/:id` removes the post from OmniSocials only and never touches the live message. ## Limitations - One Telegram chat per workspace. Connect more chats through more workspaces. - Text formatting (bold, italic, links in text) is sent as plain text for now. - Topics in forum groups are not supported: the post goes to the General topic. - Telegram messages are not part of the Social Inbox. - A post that was sent cannot be edited through OmniSocials. - Files over 50 MB need a self-hosted Telegram Bot API server and are not supported. ## When the connection breaks When someone removes the bot from the chat or takes away its right to post, the account shows `needs_reconnect: true` in `GET /accounts` and posts to it fail with a message that says what to fix. Add the bot back as an admin with the right to post, then reconnect the chat in OmniSocials. --- ## Document: Threads API: posting reference Post to Threads via the OmniSocials API: text, images, video, mixed carousels, multi-post threads, polls, the Social Inbox, alt text, and the 500-character limit. URL: https://docs.omnisocials.com/platforms/threads # Threads API: posting reference Threads is supported via Meta's Threads API. Only accounts linked to a Threads profile can be connected. The account must be a public profile. For an overview and use cases, see [Threads API](https://omnisocials.com/threads-api). **Channel ID:** `threads` ## Supported content types | Type | Supported | |------|-----------| | Feed post | ✅ | | Story | - | | Reel | - | ## Minimal example ```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": "Thought of the day 💭" }, "accounts": ["your-threads-account-id"] }' ``` Text-only posts are supported. Media is optional. ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `threads.thread_parts` | array | Publish as a chained thread instead of a single post. See [Posting threads](#posting-threads). | | `threads.poll` | object | Attach a poll; the post text is the question. See [Polls](#polls). | Threads has no required option fields. The default create-post body is sufficient for a single post. ## Posting threads To publish a thread (a chain of connected posts), pass `threads.thread_parts`. Each part becomes its own post, published as a reply to the previous one from the same account. :::caution **Reconnect once.** Chaining posts needs the Threads reply permission (`threads_manage_replies`). Threads accounts connected before this permission was added need a one-time reconnect under Settings -> Organisation -> Workspaces; until then `thread_parts` is rejected with a `400` that names the reconnect. ::: ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accounts": ["your-threads-account-id"], "content": { "default": "" }, "threads": { "thread_parts": [ { "text": "1/3: a thread on Threads 🧵" }, { "text": "2/3: every part is its own post, replying to the one before it" }, { "text": "3/3: and the whole chain is scheduled in one call" } ] } }' ``` **Rules:** - 2 to 25 parts. For a single post, omit `thread_parts` and use `content.threads` (or `content.default`) instead. - Each `text` is required, non-empty, and ≤ 500 characters. Parts that exceed the limit are rejected with a `400`. - Threads allows up to 5 unique links in a post, and every part is its own post. A part (or a single post) with more links is rejected with a `400` before anything publishes. - `media_urls` (or `media_ids`) is optional per part, up to 20 items per part. A part with several items publishes as a carousel, and images and videos can be mixed in the same part, exactly like a single Threads post. Any entry can be an object with alt text: ```json { "threads": { "thread_parts": [ { "text": "Photo dump from the trip 📷", "media_urls": [ { "url": "https://example.com/1.jpg", "alt": "Sunrise over the harbour" }, "https://example.com/2.jpg" ] }, { "text": "More to come tomorrow." } ] } } ``` - When `content.threads` is provided alongside `thread_parts`, `thread_parts` wins: the first part is the post's caption. - The first part's URL becomes the post's Threads URL. If a later part fails, `POST /posts/:id/retry` resumes from the first unpublished part instead of re-posting the ones that already landed. To convert a thread back into a single post on update, send `threads.thread_parts: null` to `PATCH /posts/:id`. ## Polls Pass `threads.poll` to attach a poll. The post text is the question. ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": { "threads": "Which channel do you schedule first?" }, "accounts": ["your-threads-account-id"], "threads": { "poll": { "options": ["Threads", "Bluesky", "Mastodon"] } } }' ``` **Rules:** - 2 to 4 `options`, each 1 to 25 characters and different from the others. - There is no duration field: Threads closes the poll 24 hours after the post is published. - A poll can't carry media: a request with a poll and media on Threads 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 `threads.poll`. On `PATCH /posts/:id`, `threads.poll: null` removes it. - Poll results come back in analytics under `metrics.poll`. ## Social Inbox Replies people leave on your Threads posts, and posts that mention you, land in the [Social Inbox](https://docs.omnisocials.com/inbox.md#threads-replies-and-mentions). You can reply (published as a native Threads reply) and hide or unhide replies on your own posts via `POST /inbox/messages/{id}/hide`. :::caution **Reconnect once.** The Threads inbox needs the Threads reply permissions. Threads accounts connected before these permissions were added need a one-time reconnect under Settings -> Organisation -> Workspaces; until then Threads conversations do not appear and Threads replies and hides answer `401 reauth_required`. ::: - Conversation types: `comment` (replies on your posts, grouped per root post) and `mention`. Threads has no DM API. - Replies need the Threads reply permission on the connection; otherwise the reply returns `401 reauth_required`. - Only incoming top-level replies on your posts can be hidden; nested replies return `400 not_hideable`. - Replies can't be deleted from OmniSocials. See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md#threads-replies-and-mentions) for endpoints, payloads, and error handling. ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, or WebP. Max 8 MB. Up to 20 per post. | | Video | MP4 or MOV, up to 5 minutes, max 1 GB | Threads supports a single image, a single video, or a carousel of up to 20 items per post. Carousel items can mix images and videos. **Alt text.** Threads delivers per-media alt text natively. Pass it by using an object entry instead of a bare string: `{ "url": "https://example.com/bike.jpg", "alt": "A red bicycle leaning against a brick wall" }` in `media_urls` (or `{ "id": "...", "alt": "..." }` in `media_ids`, including per-part thread media; we accept up to 1500 characters and send the first 1,000, the most Threads takes). It is sent as the media container's `alt_text` on images and videos, single posts and carousel items alike. ## Limitations - Threads enforces a 500 character limit per post (and per part of a thread) - Posting a reply to an arbitrary post is not supported; `thread_parts` only chains replies to your own previous part, and the [Social Inbox](https://docs.omnisocials.com/inbox.md#threads-replies-and-mentions) replies to replies on your posts and to mentions - Private Threads accounts cannot be connected - Location tags are not available on Threads yet: Meta has not approved the location permission for OmniSocials. A post with `threads.location_id` is rejected with a `400`. - Topic tags, reply controls and quote posts are not supported --- ## Document: TikTok API: posting reference Post videos and image carousels to TikTok via API: privacy levels, media specs up to 1 GB, and the caption caps (2,200 for videos, 4,000 for photo posts). URL: https://docs.omnisocials.com/platforms/tiktok # TikTok API: posting reference TikTok is supported via TikTok's Content Posting API. Both photo posts and video posts (reels) are available. Personal and business TikTok accounts can both connect. Comments, first comments and watch-depth analytics use TikTok's Business (Accounts) API, through a second, optional consent: **Enable comments** (see [Social Inbox](#social-inbox) below). For an overview and use cases, see [TikTok API](https://omnisocials.com/tiktok-api). **Channel ID:** `tiktok` ## Supported content types | Type | Supported | Notes | |------|-----------|-------| | Feed post | ✅ | Photo post (1-35 images) or a single video | | Story | - | Not supported | | Reel | ✅ | Vertical video | ## Minimal example Video reel: ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "reel", "content": { "default": "New drop 🔥" }, "accounts": ["your-tiktok-account-id"], "media_urls": ["https://example.com/video.mp4"], "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE" } }' ``` Photo post: ```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": "Weekend shots" }, "accounts": ["your-tiktok-account-id"], "media_urls": [ "https://example.com/photo1.jpg", "https://example.com/photo2.jpg" ], "tiktok": { "privacy_level": "PUBLIC_TO_EVERYONE" } }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `tiktok.title` | string | Photo carousels only. The title TikTok shows above the caption on Photo Mode posts, max 90 characters. TikTok's photo endpoint takes the title and the caption as separate fields; the caption comes from `content`. Ignored on video posts, which have a single caption field. Read back as `tiktok.title`. | | `tiktok.privacy_level` | string | Who can see the post. One of `PUBLIC_TO_EVERYONE` (default), `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. | | `tiktok.disable_comment` | boolean | Disable comments on the post. | | `tiktok.disable_duet` | boolean | Disable duets (video only). | | `tiktok.disable_stitch` | boolean | Disable stitches (video only). | | `tiktok.video_cover_timestamp_ms` | integer | Video only. Timestamp (ms) of the frame to use as the cover. TikTok only accepts a frame, never an image: a `custom` [`video_cover`](https://docs.omnisocials.com/creating-posts.md#video-covers) is skipped on TikTok and the frame is used instead. | | `tiktok.is_aigc` | boolean | Disclose the content is AI-generated. Reel-type videos only; not sent on photo posts or on a video published as a feed post. | | `tiktok.brand_content_toggle` | boolean | Mark as a paid partnership promoting a third-party brand. | | `tiktok.brand_organic_toggle` | boolean | Mark as promoting your own business / brand. | | `tiktok.auto_add_music` | boolean | Photo carousels only. When `true`, TikTok auto-selects a soundtrack. Defaults to `false`. | | `tiktok.first_comment` | string | Auto-posted as the first comment right after the video publishes, max 150 characters. Needs comments enabled on the channel (see Social Inbox below) and a public video with comments allowed. See [Automatic first comment](https://docs.omnisocials.com/creating-posts.md#automatic-first-comment). | When `privacy_level` is omitted, the post publishes as `PUBLIC_TO_EVERYONE`. TikTok decides per creator which levels an account may use; if the account can't use the level you send, the post publishes as `SELF_ONLY` (private) instead. ## Media requirements | Media | Requirement | |-------|-------------| | Video | MP4, MOV, or WebM, aspect ratio 9:16 or 16:9, 3 seconds up to the account's own limit (3, 5 or 10 minutes, set by TikTok per creator), max 1 GB (OmniSocials' upload ceiling: 1 GB for media added by URL, 100 MB for direct file uploads; TikTok itself allows up to 4 GB) | | Photo | JPEG, PNG, or WebP (PNG is converted to JPEG at post time), 1-35 images per post, max 20 MB per image | Photo posts must contain only images. Video posts must contain exactly one video. Mixing is not allowed. **Alt text.** Per-media `alt` entries are accepted but not delivered; the platform's API does not support alt text on third-party posts. It is safe to send the same media objects you use for other platforms. ## Character limit Captions cap at 2,200 characters on videos (TikTok's video endpoint has a single `title` field) and 4,000 characters on photo posts. Longer returns `400 validation_error` at create time. Photo post titles (`tiktok.title`) cap at 90 characters; longer returns `400 validation_error`. ## Limitations - Posts publish straight to the profile. Sending a video to the creator's TikTok drafts (inbox upload mode) is not supported. - `brand_content_toggle: true` triggers TikTok's branded content review workflow. The post may take longer to publish. - TikTok's API has no native scheduling. Scheduled OmniSocials posts are held on our side and published at the scheduled time. - Custom cover images are not supported: TikTok takes a frame of the video as the cover (`tiktok.video_cover_timestamp_ms`). - Published videos can't be deleted from OmniSocials: TikTok's API has no delete. Remove them in the TikTok app. - Comments, duets, and stitches can be disabled at post time but cannot be changed later ## Social Inbox Comments on your TikTok videos land in the Social Inbox once comments are enabled on the channel. Enabling is a second TikTok authorization, started from the **Enable comments** button on the TikTok channel card under **Settings -> Organisation -> Workspaces**. New comments arrive in real time, with a check every 15 minutes as a safety net. You can reply, like or unlike (liking is app-only for now), and hide or delete comments (`POST /inbox/messages/{id}/hide`, `DELETE /inbox/messages/{id}`; see [Hiding and deleting comments](https://docs.omnisocials.com/inbox.md#hiding-and-deleting-comments)). TikTok DMs are not part of the inbox. Replies post from the connected TikTok account and are text only, capped at 150 characters. TikTok holds fresh comments and replies in spam review for roughly 15 to 20 minutes before they are publicly visible. See [TikTok comments](https://docs.omnisocials.com/inbox.md#tiktok-comments) in the Social Inbox guide. The same authorization unlocks **first comments** (`tiktok.first_comment`, above) and **watch-depth analytics**. ## Watch-depth analytics TikTok's regular posting API only reports views, likes, comments, and shares. Once comments are enabled on the channel, the analytics endpoints (`GET /analytics/posts/:id`, the bulk variant, and `GET /posts/recent-platform`) add these fields to the TikTok `metrics` map, as TikTok reports them: | Metric | Meaning | | --- | --- | | `average_time_watched` | Average seconds a viewer watched | | `full_video_watched_rate` | Share of views that watched to the end | | `total_time_watched` | Total seconds watched across all views | | `favorites` | Times the video was saved to favorites | | `reach` | Unique accounts that saw the video | | `impression_sources` | Where the video was seen (For You, Following, and so on), as percentages | | `audience_types` | Viewer types (new viewers, followers, and so on), as percentages | | `audience_countries` | Top 10 viewer countries, as percentages | They cover roughly the 200 most recent videos of the account. The breakdown keys (`impression_sources`, `audience_types`, `audience_countries`) appear only when TikTok returns them. The same authorization adds account insights to `GET /analytics/accounts`: profile views, video views, reach, accounts engaged, follows gained and lost, bio link clicks, online followers, and audience demographics (country, gender, age). Channels that enabled comments before 22 August 2026 need one more consent for the insights scopes (`video.insights`, `user.insights`): click **Unlock analytics permissions** on the TikTok channel card. When the authorization or scope is missing the keys are absent, not zero. --- ## Document: X (Twitter) API: posting reference Post to X (Twitter) via API: threads, polls, media, weighted character counting (links count as 23), Premium limits, link-post credits, and the DM inbox. URL: https://docs.omnisocials.com/platforms/x # X (Twitter) API: posting reference X (formerly Twitter) is supported via the X API v2. OmniSocials posts to the account that completed the OAuth flow. Both standard and premium accounts are supported, and the character limit adjusts based on the account's subscription tier. You don't need your own X developer account or API plan: posts without a link cost nothing extra, and posts with a link use [credits](#link-posts-use-credits). For an overview and use cases, see [X API](https://omnisocials.com/x-api). **Channel ID:** `x` ## Supported content types | Type | Supported | |------|-----------| | Feed post | ✅ | | Story | - | | Reel | - | ## Minimal example ```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, X 👋" }, "accounts": ["your-x-account-id"] }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `x.reply_settings` | string | Who can reply: `""` (everyone, default), `following`, or `mentionedUsers` | | `x.paid_partnership` | boolean | Mark as a paid partnership disclosure (default `false`) | | `x.made_with_ai` | boolean | Mark as AI-generated content (default `false`) | | `x.poll` | object | Attach a poll; the post text is the question. See [Polls](#polls). | | `x.thread_parts` | array | Publish as a chained thread instead of a single tweet. See [Posting threads](#posting-threads). | ```json { "content": { "default": "Limited reply post" }, "accounts": ["your-x-account-id"], "x": { "reply_settings": "following" } } ``` ## Posting threads To publish as a thread, pass `x.thread_parts` instead of relying on `content.x`. Each part becomes its own tweet, chained via `in_reply_to_tweet_id`. ```json { "accounts": ["your-x-account-id"], "content": { "default": "" }, "x": { "thread_parts": [ { "text": "1/3: kicking off a thread on building scalable APIs" }, { "text": "2/3: keep the boundary small, document at the edges" }, { "text": "3/3: and ship sooner than you think" } ] } } ``` **Rules:** - 2 to 25 parts. For a single tweet, omit `thread_parts` and use `content.x` (or `content.default`) instead. - Each `text` is required, non-empty, and within the account's character limit (280, or 25,000 on Basic, Premium and Premium+), counted the way X counts. Parts that exceed the limit are rejected with a `400`. - `media_urls` is optional per part (max 4) and overrides any top-level `media_urls.x` for that specific tweet: ```json { "x": { "thread_parts": [ { "text": "Photo dump from the trip 📷", "media_urls": ["https://example.com/1.jpg", "https://example.com/2.jpg"] }, { "text": "More to come tomorrow." } ] } } ``` - When `content.x` is provided alongside `thread_parts`, `thread_parts` wins. - The first tweet's URL becomes `published_urls.x` on the post. The full chain is recorded on `x.thread_tweets` (read back with `GET /posts/:id`) for analytics. - `reply_settings`, the labels and a poll apply to the first tweet. To convert a thread back into a single tweet on update, send `x.thread_parts: null` to `PATCH /posts/:id`. ## Polls Pass `x.poll` to attach a poll. The post text is the question. ```json { "content": { "x": "Which SDK should we ship next?" }, "accounts": ["your-x-account-id"], "x": { "poll": { "options": ["Node", "Python", "Go"], "duration_minutes": 1440 } } } ``` **Rules:** - 2 to 4 `options`, each up to 25 characters and different from the others. - `duration_minutes`: a whole number from 5 to 10080 (7 days). Leave it out and the poll runs for 1440 minutes (one day). The composer in the OmniSocials dashboard offers 1 hour, 6 hours, 1 day, 3 days and 7 days. - A poll and media do not go together on X: a request with both is refused with `400 validation_error`. - On a thread (`thread_parts`), the poll goes on the first tweet, which then carries no media. - `GET /posts/:id` returns the poll under `x.poll`. On `PATCH /posts/:id` the `x` object replaces the stored X options as a whole: send `x.poll: null`, or leave `poll` out, to remove it. - Poll results come back in analytics under `metrics.poll`. ## Character limits | Account tier | Character limit | |--------------|-----------------| | Standard (free) | 280 | | Basic | 25,000 | | Premium (Blue) | 25,000 | | Premium+ | 25,000 | OmniSocials detects the connected account's subscription tier and enforces the correct limit at validation time, for single tweets and for every part of a thread sent through the API. The thread composer in the OmniSocials dashboard keeps each part at 280 characters, also on Premium. The tier is read when you connect; reconnect X after upgrading. **Characters are weighted the way X counts them, not by string length.** Every link counts as 23 characters no matter how long the URL is, and emoji and CJK characters count as 2 each (an emoji family joined with ZWJ counts as a single 2-weight unit). A 270-character post containing a 10-character link is over the limit. OmniSocials validates with the weighted count, so a rejection at create time means X would have rejected it too; X itself returns an unhelpful generic `403` for over-length posts. ## Media requirements | Media | Requirement | |-------|-------------| | Image | JPEG, PNG, or WebP. Max 5 MB. Up to 4 per post. | | Video | MP4, H.264 codec. Free and Basic accounts: up to 2 minutes 20 seconds, max 512 MB. Premium and Premium+ accounts: up to 125 minutes, max 16 GB (in practice bounded by OmniSocials' own limits: 1 GB for media added by URL, 100 MB for direct file uploads). The tier is detected from your connected X account; reconnect X after upgrading. | X does not support mixing image and video in the same post. Video posts allow one video. Image posts allow up to four images. **Alt text.** Pass a per-image accessibility description by using an object entry instead of a bare string: `{ "url": "https://example.com/chart.png", "alt": "Bar chart of Q3 signups by week" }` in `media_urls` (or `{ "id": "...", "alt": "..." }` in `media_ids`, including per-part thread media). It is set via X's v2 media metadata on images only (X does not support alt text on video), clamped to X's 1000-character cap. An alt-text failure at X never fails the publish. ## Link posts use credits X's API bills posts whose text contains a URL at a premium ($0.20 per tweet instead of $0.015). OmniSocials passes that fee through as prepaid credits at X's rate card: **20 credits per URL-containing tweet** (credits cost €0.01 each, topped up €5-500 in the dashboard), with threads billed per part that contains a link. Posts without links, analytics and media stay free. X DMs in the inbox are opt-in and use credits too (see [Social Inbox](#social-inbox)). Only a link written with a scheme (`http://...` or `https://...`) counts, because that is what X bills. A `www.` link, a bare domain (`omnisocials.com`) and a spelled-out domain ("brand dot com") are free. X still shortens a bare domain to a t.co link, so it counts as 23 characters. Until 2026-10-03 `www.` links and bare domains used credits too. When a create targets X and the text contains a URL, the `201` response includes a top-level `warnings` array (code `x_url_post_credits`) with `credits_required`, the current `credits_balance`, and the enforcement state; see [Creating Posts](https://docs.omnisocials.com/creating-posts.md#x-link-posts-use-credits) for the full shape. Debiting has been live since **2026-08-14**. If the balance can't cover it at publish time, only the X target fails (other platforms publish normally); top up at **[app.omnisocials.com/credits](https://app.omnisocials.com/credits)** and retry with `POST /posts/:id/retry`. Credits are managed in the dashboard; there is no API endpoint for them. Every company gets 200 starter credits once, when it first connects X. Scheduled X link posts also *reserve* their cost until they publish: scheduling or publishing a new one that would push the reserved total past the balance is refused with `402 x_credits_insufficient` (see [Creating Posts](https://docs.omnisocials.com/creating-posts.md#x-link-posts-use-credits)). Drafts are never gated. ## Social Inbox X direct messages can land in the [Social Inbox](https://docs.omnisocials.com/inbox.md#x-direct-messages). They are off by default: each workspace opts in from the **X DMs** card in the Social Inbox, which needs the billing permission and a balance of at least 25 credits. X bills its DM API per use, so each received DM costs 1 credit and each reply 2 credits. At a zero balance the X inbox pauses until you top up and switch it on again. - DMs only. Mentions and replies on X are not part of the inbox. - No history: DMs arrive from the moment you opt in. Earlier conversations, and DMs sent while the inbox was paused, are not imported. - Accounts connected before DM support shipped need a one-time reconnect for the DM permissions. ## Limitations - No first comment on X. Use a [thread](#posting-threads) instead. - A poll and media can't go in the same post. - Quote posts and Communities are not supported. --- ## Document: YouTube API: posting reference Publish YouTube Shorts via the OmniSocials API: titles, descriptions, privacy status, first comments, comments in the Social Inbox, analytics, and media requirements. URL: https://docs.omnisocials.com/platforms/youtube # YouTube API: posting reference YouTube support is limited to YouTube Shorts. OmniSocials does not publish regular long-form videos, community posts or playlists. Connect with Google and keep all requested YouTube permissions ticked on Google's consent screen; the connect is refused when one is missing. Brand and managed channels work: when the Google account reaches several channels, you pick one while connecting, and after every upload OmniSocials checks the video landed on that channel. An upload that landed on another channel is deleted and the post fails. For an overview and use cases, see [YouTube API](https://omnisocials.com/youtube-api). **Channel ID:** `youtube` ## Supported content types | Type | Supported | Notes | |------|-----------|-------| | Feed post | - | Not supported | | Story | - | Not supported | | Reel (Short) | ✅ | Vertical video, up to 3 minutes | ## Minimal example YouTube posts must be `type: "reel"` and must include a single vertical video. ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "reel", "content": { "default": "Behind the scenes #shorts" }, "accounts": ["your-youtube-account-id"], "media_urls": ["https://example.com/short.mp4"], "youtube": { "title": "Behind the scenes", "privacy_status": "public" } }' ``` ## Platform-specific options | Field | Type | Description | |-------|------|-------------| | `youtube.title` | string | Video title, max 100 characters. When omitted, the first line of the caption is used (cut at a word to 100 characters); `"YouTube Short"` only when there is no caption either. | | `youtube.tags` | array of strings | Tags for discovery. Max 500 characters total across all tags. | | `youtube.privacy_status` | string | `public`, `private`, or `unlisted`. Defaults to `public`. | | `youtube.category_id` | string | YouTube category ID. Defaults to `"22"` (People & Blogs). | | `youtube.made_for_kids` | boolean | Mark as made-for-kids content under COPPA. Defaults to `false`. | | `youtube.notify_subscribers` | boolean | Send subscription notification when published. Defaults to `false`. | | `youtube.contains_synthetic_media` | boolean | Disclose that the video contains AI-generated content. Defaults to `false`. | | `youtube.first_comment` | string | Text auto-posted as the first comment on the video right after it publishes (max 10000 characters). The video must have comments enabled. | The caption (`content`) becomes the video description, cut to 5,000 characters at publish. A custom cover goes in the top-level [`video_cover`](https://docs.omnisocials.com/creating-posts.md#video-covers). The composer in the OmniSocials dashboard has a few more YouTube options: License, Language, Audio language, Allow embedding and Show view counts. These are not in the API. ## Media requirements | Requirement | Value | |-------------|-------| | Format | MP4, MOV, or WebM | | Aspect ratio | 9:16 (vertical) recommended | | Duration | Up to 3 minutes | | File size | Up to 1 GB for media added by URL, 100 MB for direct file uploads (OmniSocials upload ceiling; YouTube itself allows far larger) | | Resolution | 1080 × 1920 recommended | Of these, OmniSocials checks only the length: a video over 3 minutes is refused at create/schedule time. The file goes out untouched, with no conversion or trimming step, so a video that is not 9:16 plays with bars. ## Social Inbox Comments on your Shorts land in the [Social Inbox](https://docs.omnisocials.com/inbox.md#platform-notes); the standard YouTube connection already covers them. YouTube has no comment webhooks, so OmniSocials checks each channel for new comments once a day, and a new comment can take up to a day to arrive. - Reply as the channel (refused when YouTube does not allow replies on that comment). - Hide or unhide through YouTube's moderation status (`POST /inbox/messages/{id}/hide`). Deleting or liking comments is not supported. - YouTube has no DMs. ## Analytics | Level | Metrics | |-------|---------| | Per Short | `views`, `likes`, `comments`, `duration` | | Channel | `subscribers`, `total_views` (lifetime), `total_videos` | Watch time, average view duration, shares, traffic sources and subscribers gained or lost are not reported. ## Limitations - Only Shorts are supported. OmniSocials caps YouTube videos at 3 minutes and does not publish regular long-form videos. - YouTube's API enforces a daily quota, shared by all OmniSocials customers. When it runs out, the post fails with a message to retry later; retry it with `POST /posts/:id/retry`. - A custom thumbnail (`video_cover`) shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (rolling out since July 2026, YouTube Partner Program channels first). On other channels YouTube stores it but displays a frame from the video on the Shorts tab and in the Shorts feed. - `made_for_kids: true` disables most interaction features (comments, community tab, end screens) on the resulting Short. --- ## Document: SDKs: install and use the OmniSocials API in 8 languages Official OmniSocials SDKs for Node.js, Python, Go, Ruby, PHP, Java, .NET, and Rust. Install, authenticate, and publish your first post in a few lines. URL: https://docs.omnisocials.com/sdks # SDKs: install and use the OmniSocials API in 8 languages Official client libraries for the OmniSocials API in 8 languages. Every SDK has the same shape: resource-based clients (`client.posts.create(...)`), typed requests and responses, automatic retries with backoff, a shared error hierarchy, and a webhook signature verification helper. Pick your language and you are posting in five lines. These pages are the reference. For an overview of the SDKs and what you can build with them, see [omnisocials.com/sdk](https://omnisocials.com/sdk). ## Install Each language has its own page with full examples: install, auth, posting, media, analytics, the social inbox, Pinterest product tags, webhooks, and error handling. | Language | Package | Install | Docs | |----------|---------|---------|------| | Node.js / TypeScript | `@omnisocials/sdk` | `npm install @omnisocials/sdk` | [Node.js](https://docs.omnisocials.com/sdks/node.md) | | Python | `omnisocials` | `pip install omnisocials` | [Python](https://docs.omnisocials.com/sdks/python.md) | | Go | `github.com/OmniSocials/omnisocials-go` | `go get github.com/OmniSocials/omnisocials-go` | [Go](https://docs.omnisocials.com/sdks/go.md) | | Ruby | `omnisocials` | `gem install omnisocials` | [Ruby](https://docs.omnisocials.com/sdks/ruby.md) | | Java | `com.omnisocials:omnisocials-java` | Maven / Gradle dependency | [Java](https://docs.omnisocials.com/sdks/java.md) | | PHP | `omnisocials/omnisocials-php` | `composer require omnisocials/omnisocials-php` | [PHP](https://docs.omnisocials.com/sdks/php.md) | | .NET | `OmniSocials` | `dotnet add package OmniSocials` | [.NET](https://docs.omnisocials.com/sdks/dotnet.md) | | Rust | `omnisocials` | `cargo add omnisocials` | [Rust](https://docs.omnisocials.com/sdks/rust.md) | All 8 cover the full v1 surface: posts (including create-and-publish, retrying the failed platforms of a failed or partially failed post via `posts.retry`, approving or rejecting a post waiting in an approval workflow via `posts.approve` / `posts.reject`, reading the review of a post (steps, approvers, the rejection reason and the comment thread) via `posts.getApproval`, per-platform options, and thread chains on X, Bluesky, Mastodon, and Threads), media (URL, base64, multipart, PDF carousels, presigned large-file uploads), folders, hashtag sets (CRUD plus applying a set at post create via `hashtag_set` / `hashtag_placement`), approval workflows (`approvalWorkflows.list`), accounts, analytics (post, bulk, overview, accounts, best times), Instagram locations, Instagram Reel music search, Pinterest product Pins for product tags (`pinterest.listProducts`, `pinterest.validateProduct`), the social inbox, and webhooks (CRUD plus secret rotation and signature verification). ## Social Inbox Every SDK has an `inbox` resource for the DMs, comments and mentions of your connected accounts. It needs an API key with the opt-in `inbox:read` / `inbox:write` scopes. | Method (Node names) | What it does | |---------------------|--------------| | `inbox.listConversations()` | Lists conversations. Filters: `platform`, `type`, `unread`, `unanswered`. | | `inbox.getMessages(conversationId)` | Returns the messages of one conversation. | | `inbox.reply(conversationId, { text })` | Sends a reply. Pass `message_id` on a comment thread. | | `inbox.markRead(conversationId)` | Marks the conversation as read. | | `inbox.hide(messageId)` | Hides or unhides a comment on one of your posts. | | `inbox.deleteMessage(messageId)` | Deletes a comment on one of your posts. | | `inbox.next()` | Hands out the next conversation that still needs an answer. | ```ts let { data: item } = await client.inbox.next(); while (item) { const reply = await client.inbox.reply(item.message.conversation_id, { text: "Thanks for reaching out!", message_id: item.message.id, include_next: true, }); item = reply.next ?? null; } ``` Each language page has the same calls in its own idiom. See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Every SDK has a `pinterest` resource for product tagging: it lists the product Pins of the connected Pinterest account, so you can tag up to 24 of them on a Pin with `product_tags` in the `pinterest` options of a post. It uses the `posts:read` scope. | Method (Node names) | What it does | |---------------------|--------------| | `pinterest.listProducts()` | Lists product Pins. Filters: `source` (`catalog` or `pins`), `product_group_id`, `bookmark`, `page_size`. | | `pinterest.validateProduct(id)` | Checks one Pin id or Pin link before you use it as a product tag. | ```ts const list = await client.pinterest.listProducts(); if (!list.error) { await client.posts.create({ content: "Our summer picks", channels: ["pinterest"], media_urls: ["https://example.com/summer-look.jpg"], pinterest: { board_id: "1234567890", product_tags: (list.products ?? []).slice(0, 3).map((product) => product.pin_id), }, }); } ``` The list answers with HTTP 200 in both cases: `products` on success, or an `error` object (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. Only product Pins of your own account can be tagged; products of other merchants cannot. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest.product_tags_result` with the tagged and the skipped Pin ids. Each language page has the same calls in its own idiom. ## Authentication Every SDK reads the `OMNISOCIALS_API_KEY` environment variable, or takes the key in the constructor. Create a key in the OmniSocials app under **Settings → API**. Keys start with `omsk_live_`; there is no separate test mode. ```bash export OMNISOCIALS_API_KEY="omsk_live_" ``` ## Quickstart ### Node.js / TypeScript ```ts import { OmniSocials } from "@omnisocials/sdk"; const client = new OmniSocials(); // reads OMNISOCIALS_API_KEY from env const post = await client.posts.create({ content: "Hello from the SDK", channels: ["instagram", "linkedin"], scheduled_at: "2026-08-01T09:00:00Z", }); ``` ### Python ```python from omnisocials import OmniSocials client = OmniSocials() # reads OMNISOCIALS_API_KEY from the environment post = client.posts.create(content="Hello from Python", channels=["instagram", "linkedin"]) print(post["data"]["id"]) ``` ### SDK names vs REST names The SDKs send a few fields under other names than the REST examples in these docs. The API accepts both names, so either one works in a raw HTTP body too. If you move between an SDK and raw HTTP (or read both docs), map them like this: | SDK parameter | REST field | |---------------|-----------| | `channels: ["instagram"]` | `accounts: ["instagram"]` | | `scheduled_at` | `schedule_at` | | `content: "one string"` | `content: { "default": "one string" }` | Everything else has the same name in both. A synchronous and an async client (`AsyncOmniSocials`) ship in the Python package. The Go, Ruby, PHP, Java, .NET, and Rust clients expose the same resources and methods adapted to each language's idiom. Full runnable examples for every resource live in each package's README (linked in the table above). **X link posts use credits.** When a create targets X and the text contains a URL, the create response carries a top-level `warnings` array next to `data` (code `x_url_post_credits`): X's link-post fee passed through as prepaid credits. Every SDK returns it as part of the parsed response body (typed as `PostWarning` in the Node and Go SDKs). See [Creating Posts](https://docs.omnisocials.com/creating-posts.md#x-link-posts-use-credits) for the shape and the publish-time behavior. ## Configuration Every client accepts the same options: `apiKey`, `baseUrl`, `timeout` (default 30s), and `maxRetries` (default 2). Retries use exponential backoff with jitter on `429`, `5xx`, and network errors, and honor the `Retry-After` header. Other `4xx` responses are never retried. ## Errors Non-2xx responses raise a typed error from a shared hierarchy: a base error, an API error carrying `status` / `code` / `body`, and per-status subclasses (authentication, permission, not-found, validation, rate-limit with `retry_after`, server), plus a connection error and a webhook-verification error. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md) for the status codes. ## Webhook verification Each SDK ships a helper that verifies the `X-OmniSocials-Signature` header (HMAC-SHA256 over `"{timestamp}.{rawBody}"`, constant-time compare, replay-window check) and returns the parsed event. Node example: ```ts import { verifyWebhookSignature } from "@omnisocials/sdk"; app.post("/webhooks/omnisocials", express.raw({ type: "application/json" }), (req, res) => { const event = verifyWebhookSignature({ payload: req.body, // raw bytes, not parsed JSON signature: req.header("X-OmniSocials-Signature"), secret: process.env.OMNISOCIALS_WEBHOOK_SECRET, }); // handle event.type ... res.sendStatus(200); }); ``` The events are `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` and `reason`. See [Webhooks](https://docs.omnisocials.com/webhooks.md) for event types and payloads. ## Prefer a different integration? - [MCP Server](https://docs.omnisocials.com/mcp-server.md) for AI assistants (Claude, ChatGPT, Cursor, and more) - [Agent Skills](https://docs.omnisocials.com/integrations/agent-skills.md) for AI coding tools without MCP support - [API Reference](https://docs.omnisocials.com/api) for the raw HTTP endpoints --- ## Document: Node.js SDK reference: install, auth and methods The official Node.js SDK for the OmniSocials API: npm install, authenticate, publish, schedule, upload media, and read analytics with TypeScript types. URL: https://docs.omnisocials.com/sdks/node # Node.js SDK reference: install, auth and methods The official Node.js / TypeScript SDK. Zero runtime dependencies (native `fetch`), Node 18+, ships ESM and CommonJS, fully typed. This page is the reference. For an overview of the Node.js SDK, see [omnisocials.com/sdk/nodejs](https://omnisocials.com/sdk/nodejs). ## Install ```bash npm install @omnisocials/sdk ``` ## Authentication ```ts import { OmniSocials } from "@omnisocials/sdk"; const client = new OmniSocials({ apiKey: "omsk_live_..." }); // or set OMNISOCIALS_API_KEY and call new OmniSocials() ``` CommonJS: `const { OmniSocials, verifyWebhookSignature } = require("@omnisocials/sdk");` ## Create a post ```ts const { data: post } = await client.posts.create({ content: "New drop this Friday", channels: ["instagram", "facebook", "linkedin"], scheduled_at: "2026-08-01T09:00:00Z", media_urls: ["https://example.com/teaser.jpg"], }); console.log(post.id, post.status); ``` Any `media_urls` (or `media_ids`) entry can also be an object carrying per-media alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```ts media_urls: [{ url: "https://example.com/teaser.jpg", alt: "Red sneaker on a white background" }], ``` Publish immediately: ```ts await client.posts.createAndPublish({ content: "Going live right now", channels: ["x", "bluesky"], }); ``` ## Upload media ```ts const upload = await client.media.uploadFromUrl({ url: "https://example.com/launch-video.mp4", name: "launch-video-v2", folder: "Campaigns", }); console.log(upload.data.id, upload.compatibility); ``` Local files via multipart: ```ts // From a path const res = await client.media.upload({ file: "./photos/product.jpg", name: "product-hero" }); // Or from bytes / a Blob import { readFile } from "node:fs/promises"; const bytes = await readFile("./photos/product.jpg"); await client.media.upload({ file: bytes, filename: "product.jpg" }); ``` ## Analytics ```ts // One post's latest per-platform metrics const { data: stats } = await client.analytics.post("post_id"); console.log(stats.platforms.instagram?.metrics); // Batch: up to 100 posts in one call const batch = await client.analytics.posts(["id1", "id2", "id3"]); // Workspace-wide overview const { data: overview } = await client.analytics.overview({ period: "30d" }); console.log(overview.total_impressions, overview.total_engagements); // Account-level stats (followers etc) const accounts = await client.analytics.accounts({ platform: "instagram" }); ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`cursor` in, `pagination.next_cursor` out). ```ts const { data: conversations } = await client.inbox.listConversations({ platform: "instagram", unread: true, }); const conversationId = conversations[0].conversation_id; const { data: messages } = await client.inbox.getMessages(conversationId); await client.inbox.reply(conversationId, { text: "Thanks for reaching out!" }); await client.inbox.markRead(conversationId); // Comment moderation: hide (pass { hide: false } to unhide) or delete await client.inbox.hide(messages[0].id); await client.inbox.deleteMessage(messages[0].id); ``` `inbox.next()` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, pass `message_id` so the reply goes under the comment you answer and not under the newest comment on the post. ```ts let { data: item, remaining } = await client.inbox.next(); while (item) { if (!item.reply_window.open) { // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app await client.inbox.markRead(item.message.conversation_id); ({ data: item, remaining } = await client.inbox.next()); continue; } const reply = await client.inbox.reply(item.message.conversation_id, { text: "Thanks! DM sent.", message_id: item.message.id, include_next: true, }); item = reply.next ?? null; remaining = reply.remaining ?? 0; } ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `product_tags` in the `pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```ts const list = await client.pinterest.listProducts(); if (list.error) { console.warn(list.error.code, list.error.message); } else { await client.posts.create({ content: "Our summer picks", channels: ["pinterest"], media_urls: ["https://example.com/summer-look.jpg"], scheduled_at: "2026-08-01T09:00:00Z", pinterest: { board_id: "1234567890", product_tags: (list.products ?? []).slice(0, 3).map((product) => product.pin_id), }, }); } ``` The list answers with HTTP 200 in both cases: `products` on success, or an `error` object (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest.product_tags_result` with the tagged and the skipped Pin ids. `client.pinterest.validateProduct(id)` checks one Pin id or Pin link before you post. On `posts.update()` the `pinterest` object replaces the stored one, so leave `product_tags` out to remove the tags. ## Verify webhooks ```ts import express from "express"; import { verifyWebhookSignature, WebhookVerificationError } from "@omnisocials/sdk"; const app = express(); app.post( "/omnisocials/webhook", express.raw({ type: "application/json" }), // keep the raw body! (req, res) => { try { const event = verifyWebhookSignature({ payload: req.body, // Buffer of the raw body signature: req.get("X-OmniSocials-Signature") ?? "", secret: process.env.OMNISOCIALS_WEBHOOK_SECRET!, tolerance: 300, // seconds (default) }); switch (event.type) { case "post.published": console.log("Published:", event.data.post_id, event.data.targets); break; case "post.failed": console.error("Failed:", event.data.post_id); break; case "post.rejected": console.warn("Rejected:", event.data.post_id, event.data.approval?.reason); break; } res.sendStatus(200); } catch (err) { if (err instanceof WebhookVerificationError) { return res.sendStatus(400); } throw err; } } ); ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (`null` on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.posts.getApproval(id)`. ## Errors The base class is `OmniSocialsError`. API errors are `APIError` subclasses carrying `status`, `code`, `message`, and `body`: `ValidationError` (400/422), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `RateLimitError` (429, with `.retryAfter`), `ServerError` (5xx), plus `APIConnectionError` and `WebhookVerificationError`. ```ts import { APIError, RateLimitError, ValidationError, APIConnectionError } from "@omnisocials/sdk"; try { await client.posts.create({ content: "Hi", channels: ["instagram"] }); } catch (err) { if (err instanceof RateLimitError) { console.warn(`Rate limited, retry in ${err.retryAfter}s`); } else if (err instanceof ValidationError) { console.error(`Bad request (${err.code}): ${err.message}`, err.body); } else if (err instanceof APIConnectionError) { console.error("Network problem:", err.message); } else if (err instanceof APIError) { console.error(`API error ${err.status} (${err.code}): ${err.message}`); } else { throw err; } } ``` ## Configuration ```ts const client = new OmniSocials({ apiKey: "omsk_live_...", baseUrl: "https://api.omnisocials.com/v1", // default timeout: 30_000, // per-request timeout in ms (default 30s) maxRetries: 2, // automatic retries on 429 / 5xx / network errors (default 2) }); ``` Note that `timeout` is in milliseconds; the other SDKs use seconds or durations. ## Notes - Responses destructure as `{ data }`; list endpoints return `{ data, pagination }` - `204` deletes resolve to `null` - `client.health()` returns `{ status, version, timestamp }` - Source: [github.com/OmniSocials/omnisocials-node](https://github.com/OmniSocials/omnisocials-node) --- ## Document: Python SDK reference: install, auth and methods The official Python SDK for the OmniSocials API: pip install, authenticate, publish, schedule, upload media, and read analytics. URL: https://docs.omnisocials.com/sdks/python # Python SDK reference: install, auth and methods The official Python SDK. Python 3.9+, the only dependency is `httpx`, and both a synchronous and an async client ship in the same package. This page is the reference. For an overview of the Python SDK, see [omnisocials.com/sdk/python](https://omnisocials.com/sdk/python). ## Install ```bash pip install omnisocials ``` ## Authentication ```python from omnisocials import OmniSocials client = OmniSocials(api_key="omsk_live_...") # or: export OMNISOCIALS_API_KEY=omsk_live_... and just OmniSocials() ``` Context manager form: ```python with OmniSocials() as client: accounts = client.accounts.list() ``` ## Create a post ```python post = client.posts.create( content="Big announcement coming Friday", channels=["instagram", "facebook", "linkedin"], scheduled_at="2026-08-01T09:00:00Z", media_urls=["https://example.com/teaser.jpg"], ) ``` Any `media_urls` (or `media_ids`) entry can also be a dict carrying per-media alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```python media_urls=[{"url": "https://example.com/teaser.jpg", "alt": "Red sneaker on a white background"}], ``` Publish immediately: ```python post = client.posts.create_and_publish( content="We are live!", channels=["x", "bluesky"], ) ``` ## Upload media ```python media = client.media.upload_from_url( url="https://example.com/promo.mp4", name="promo-august", folder="Campaigns", ) media_id = media["data"]["id"] ``` Local files: `client.media.upload(file="/path/to/image.jpg", name="hero-shot")` (`file` accepts a path, bytes, or a binary file-like object). Base64: `client.media.upload_from_base64(data=b64_string, mime_type="image/png")`. ## Analytics ```python # One post stats = client.analytics.post("123") # Up to 100 posts in one request (avoids rate limit trouble when syncing) bulk = client.analytics.posts(["123", "124", "125"]) # Workspace overview overview = client.analytics.overview(period="30d") # Account-level stats (followers etc.) accounts = client.analytics.accounts(platform="instagram") ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`cursor` in, `pagination["next_cursor"]` out). ```python conversations = client.inbox.list_conversations(platform="instagram", unread=True) conversation_id = conversations["data"][0]["conversation_id"] thread = client.inbox.get_messages(conversation_id) client.inbox.reply(conversation_id, "Thanks for reaching out!") client.inbox.mark_read(conversation_id) # Comment moderation: hide (hide=False unhides) or delete client.inbox.hide(thread["data"][0]["id"]) client.inbox.delete_message(thread["data"][0]["id"]) ``` `inbox.next()` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, pass `message_id` so the reply goes under the comment you answer and not under the newest comment on the post. ```python item = client.inbox.next() while item["data"]: message = item["data"]["message"] if not item["data"]["reply_window"]["open"]: # An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app client.inbox.mark_read(message["conversation_id"]) item = client.inbox.next() continue reply = client.inbox.reply( message["conversation_id"], "Thanks! DM sent.", message_id=message["id"], include_next=True, ) item = {"data": reply.get("next"), "remaining": reply.get("remaining", 0)} ``` The same methods are on `AsyncOmniSocials` (`await client.inbox.next()`). See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `product_tags` in the `pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```python result = client.pinterest.list_products() if "error" in result: print(result["error"]["code"], result["error"]["message"]) else: client.posts.create( content="Our summer picks", channels=["pinterest"], media_urls=["https://example.com/summer-look.jpg"], scheduled_at="2026-08-01T09:00:00Z", pinterest={ "board_id": "1234567890", "product_tags": [product["pin_id"] for product in result["products"][:3]], }, ) ``` The list answers with HTTP 200 in both cases: `"products"` on success, or an `"error"` object (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest["product_tags_result"]` with the tagged and the skipped Pin ids. `client.pinterest.validate_product(id)` checks one Pin id or Pin link before you post. On `posts.update()` the `pinterest` object replaces the stored one, so leave `product_tags` out to remove the tags. The same methods are on `AsyncOmniSocials`. ## Verify webhooks ```python from fastapi import FastAPI, Header, HTTPException, Request from omnisocials import WebhookVerificationError, verify_webhook_signature app = FastAPI() WEBHOOK_SECRET = "whsec_from_webhook_creation" @app.post("/hooks/omnisocials") async def omnisocials_webhook( request: Request, x_omnisocials_signature: str = Header(None), ): payload = await request.body() # raw bytes, exactly as received try: event = verify_webhook_signature( payload, x_omnisocials_signature, WEBHOOK_SECRET, tolerance=300 ) except WebhookVerificationError: raise HTTPException(status_code=400, detail="Invalid signature") if event["type"] == "post.published": for target in event["data"]["targets"]: print(target["platform"], target["status"], target.get("native_post_id")) elif event["type"] == "post.rejected": print("Rejected:", event["data"]["post_id"], event["data"]["approval"]["reason"]) return {"ok": True} ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (`None` on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.posts.get_approval(post_id)`. ## Errors The base class is `OmniSocialsError`. `APIError` covers HTTP errors with subclasses `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429, with `.retry_after`), and `ServerError` (5xx), plus `APIConnectionError` and `WebhookVerificationError`. Each carries `status`, `code`, `message`, and `body`. ```python from omnisocials import ( OmniSocials, APIError, APIConnectionError, AuthenticationError, NotFoundError, RateLimitError, ValidationError, ) client = OmniSocials() try: client.posts.get("does-not-exist") except NotFoundError as exc: print("Gone:", exc.code, exc.message) except RateLimitError as exc: print(f"Slow down, retry in {exc.retry_after}s") except ValidationError as exc: print("Bad request:", exc.body) except AuthenticationError: print("Check your API key") except APIConnectionError: print("Network problem") except APIError as exc: print("API error:", exc.status, exc.message) ``` ## Configuration ```python client = OmniSocials( api_key="omsk_live_...", timeout=60.0, # seconds max_retries=5, # retries after the first attempt base_url="https://api.omnisocials.com/v1", # override for testing ) ``` ## Async client `AsyncOmniSocials` mirrors every resource and method with identical names: ```python import asyncio from omnisocials import AsyncOmniSocials async def main(): async with AsyncOmniSocials() as client: posts = await client.posts.list(status="scheduled") print(len(posts["data"])) asyncio.run(main()) ``` ## Notes - Responses are plain dicts; list endpoints include `data` and `pagination` - Top-level `None` values are dropped from request bodies; nested `None` is sent as JSON `null` - Source: [github.com/OmniSocials/omnisocials-python](https://github.com/OmniSocials/omnisocials-python) --- ## Document: .NET SDK reference: install, auth and methods The official .NET SDK for the OmniSocials API: NuGet install, authenticate, publish, schedule, upload media, and read analytics. URL: https://docs.omnisocials.com/sdks/dotnet # .NET SDK reference: install, auth and methods The official .NET SDK. Targets .NET 8, depends only on `System.Net.Http` and `System.Text.Json`, fully async with `CancellationToken` support on every method. This page is the reference. For an overview of the .NET SDK, see [omnisocials.com/sdk/dotnet](https://omnisocials.com/sdk/dotnet). ## Install ```bash dotnet add package OmniSocials ``` ## Authentication ```csharp var client = new OmniSocialsClient(new OmniSocialsOptions { ApiKey = "omsk_live_..." }); ``` Reads `OMNISOCIALS_API_KEY` if omitted. A missing key throws `AuthenticationException`. The client owns an internal `HttpClient` and implements `IDisposable`: create one and reuse it. ## Create a post ```csharp var response = await client.Posts.CreateAsync(new PostCreateParams { Content = "New drop this Friday", Channels = new[] { "instagram", "facebook", "linkedin" }, ScheduledAt = "2026-08-01T09:00:00Z", MediaUrls = new[] { "https://example.com/teaser.jpg" }, }); var post = response!.Value.GetProperty("data"); Console.WriteLine($"{post.GetProperty("id")} {post.GetProperty("status")}"); ``` `MediaUrls` / `MediaIds` entries can also be objects carrying per-media alt text (`new { url, alt }` / `new { id, alt }`, max 1500 chars), delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```csharp MediaUrls = new object[] { new { url = "https://example.com/teaser.jpg", alt = "Red sneaker on a white background" } }, ``` Publish immediately: ```csharp await client.Posts.CreateAndPublishAsync(new PostCreateParams { Content = "Going live right now", Channels = new[] { "x", "bluesky" }, }); ``` ## Upload media ```csharp var upload = await client.Media.UploadFromUrlAsync(new MediaUploadFromUrlParams { Url = "https://example.com/launch-video.mp4", Name = "launch-video-v2", Folder = "Campaigns", }); Console.WriteLine(upload!.Value.GetProperty("data").GetProperty("id")); ``` Local files via factory methods: ```csharp // From a path await client.Media.UploadAsync(MediaUploadParams.FromFile("./photos/product.jpg", name: "product-hero")); // Or from bytes / a stream var bytes = await File.ReadAllBytesAsync("./photos/product.jpg"); await client.Media.UploadAsync(MediaUploadParams.FromBytes(bytes, "product.jpg")); await using var stream = File.OpenRead("./photos/product.jpg"); await client.Media.UploadAsync(MediaUploadParams.FromStream(stream, "product.jpg")); ``` ## Analytics ```csharp // One post's latest per-platform metrics var stats = await client.Analytics.PostAsync("post_id"); Console.WriteLine(stats!.Value.GetProperty("data").GetProperty("platforms")); // Batch: up to 100 posts in one call var batch = await client.Analytics.PostsAsync(new[] { "id1", "id2", "id3" }); // Workspace-wide overview var overview = await client.Analytics.OverviewAsync(new AnalyticsOverviewParams { Period = "30d" }); var totals = overview!.Value.GetProperty("data"); Console.WriteLine($"{totals.GetProperty("total_impressions")} impressions"); // Account-level stats (followers etc) var accountStats = await client.Analytics.AccountsAsync(new AccountAnalyticsParams { Platform = "instagram" }); ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`Cursor` in, `pagination.next_cursor` out). Optional typed models are available (`InboxNextResponse`, `InboxReplyResponse`, `InboxDeleteMessageResponse`). ```csharp var page = await client.Inbox.ListConversationsAsync(new InboxConversationListParams { Platform = "instagram", Unread = true, }); var conversationId = page!.Value.GetProperty("data")[0].GetProperty("conversation_id").GetString()!; var messages = await client.Inbox.GetMessagesAsync(conversationId); await client.Inbox.ReplyAsync(conversationId, new InboxReplyParams { Text = "Thanks for reaching out!" }); await client.Inbox.MarkReadAsync(conversationId); // Comment moderation: hide (hide: false unhides) or delete var messageId = messages!.Value.GetProperty("data")[0].GetProperty("id").GetString()!; await client.Inbox.HideAsync(messageId); await client.Inbox.DeleteMessageAsync(messageId); ``` `NextAsync` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, set `MessageId` so the reply goes under the comment you answer and not under the newest comment on the post. ```csharp var next = await client.Inbox.NextAsync(); var item = next!.Value.GetProperty("data"); while (item.ValueKind != JsonValueKind.Null) { var message = item.GetProperty("message"); if (!item.GetProperty("reply_window").GetProperty("open").GetBoolean()) { // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app await client.Inbox.MarkReadAsync(message.GetProperty("conversation_id").GetString()!); next = await client.Inbox.NextAsync(); item = next!.Value.GetProperty("data"); continue; } var reply = await client.Inbox.ReplyAsync(message.GetProperty("conversation_id").GetString()!, new InboxReplyParams { Text = "Thanks! DM sent.", MessageId = message.GetProperty("id").GetString(), IncludeNext = true, }); item = reply!.Value.GetProperty("next"); } ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `["product_tags"]` in the `Pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```csharp var result = (await client.Pinterest.ListProductsAsync())!.Value; if (result.TryGetProperty("error", out var error)) { Console.Error.WriteLine(error.GetProperty("code").GetString()); } else { var productTags = result.GetProperty("products").EnumerateArray() .Take(3) .Select(product => product.GetProperty("pin_id").GetString()!) .ToArray(); await client.Posts.CreateAsync(new PostCreateParams { Content = "Our summer picks", Channels = new[] { "pinterest" }, MediaUrls = new[] { "https://example.com/summer-look.jpg" }, ScheduledAt = "2026-08-01T09:00:00Z", Pinterest = new Dictionary { ["board_id"] = "1234567890", ["product_tags"] = productTags, }, }); } ``` The list answers with HTTP 200 in both cases: `products` on success, or an `error` object (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest.product_tags_result` with the tagged and the skipped Pin ids. `client.Pinterest.ValidateProductAsync(id)` checks one Pin id or Pin link before you post. On `Posts.UpdateAsync` the `Pinterest` dictionary replaces the stored one, so leave `["product_tags"]` out to remove the tags. ## Verify webhooks ASP.NET Core minimal API: ```csharp using System.Text.Json; using OmniSocials; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapPost("/omnisocials/webhook", async (HttpRequest request) => { using var reader = new StreamReader(request.Body); var payload = await reader.ReadToEndAsync(); // raw body, do not re-serialize var signature = request.Headers["X-OmniSocials-Signature"].ToString(); var secret = Environment.GetEnvironmentVariable("OMNISOCIALS_WEBHOOK_SECRET")!; JsonElement webhookEvent; try { webhookEvent = WebhookSignature.Verify(payload, signature, secret, toleranceSeconds: 300); } catch (WebhookVerificationException) { return Results.BadRequest(); } switch (webhookEvent.GetProperty("type").GetString()) { case "post.published": var data = webhookEvent.GetProperty("data"); Console.WriteLine($"Published: {data.GetProperty("post_id")} {data.GetProperty("targets")}"); break; case "post.failed": Console.Error.WriteLine($"Failed: {webhookEvent.GetProperty("data").GetProperty("post_id")}"); break; case "post.rejected": var rejected = webhookEvent.GetProperty("data"); Console.Error.WriteLine($"Rejected: {rejected.GetProperty("post_id")} {rejected.GetProperty("approval").GetProperty("reason")}"); break; } return Results.Ok(); }); app.Run(); ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (null on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.Posts.GetApprovalAsync(id)`. ## Errors All exceptions extend `OmniSocialsException`. `ApiException` exposes `Status`, `Code`, `Message`, and `Body`. Types: `ValidationException` (400/422), `AuthenticationException` (401), `PermissionDeniedException` (403), `NotFoundException` (404), `RateLimitException` (429, `RetryAfter`), `ServerException` (5xx), `ApiConnectionException`, `WebhookVerificationException`. ```csharp try { await client.Posts.CreateAsync(new PostCreateParams { Content = "Hi", Channels = new[] { "instagram" }, }); } catch (RateLimitException ex) { Console.WriteLine($"Rate limited, retry in {ex.RetryAfter}s"); } catch (ValidationException ex) { Console.WriteLine($"Bad request ({ex.Code}): {ex.Message}"); } catch (ApiConnectionException ex) { Console.WriteLine($"Network problem: {ex.Message}"); } catch (ApiException ex) { Console.WriteLine($"API error {ex.Status} ({ex.Code}): {ex.Message}"); } ``` ## Configuration ```csharp var client = new OmniSocialsClient(new OmniSocialsOptions { ApiKey = "omsk_live_...", BaseUrl = "https://api.omnisocials.com/v1", // default Timeout = TimeSpan.FromSeconds(30), // per-request timeout (default 30s) MaxRetries = 2, // automatic retries on 429 / 5xx / network errors (default 2) }); ``` `Timeout` is a `TimeSpan` and applies per attempt. ## Notes - Every method ends in `...Async` and returns `Task` (raw JSON) - Update helpers: `MoveToRoot = true` (media) / `MoveToTopLevel = true` (folders) distinguish "leave unchanged" from "set to null" - Dictionary entries with explicit nulls are sent on the wire; null POCO properties are omitted - Source: [github.com/OmniSocials/omnisocials-dotnet](https://github.com/OmniSocials/omnisocials-dotnet) --- ## Document: Go SDK reference: install, auth and methods The official Go SDK for the OmniSocials API: go get, authenticate, publish, schedule, upload media, and read analytics. URL: https://docs.omnisocials.com/sdks/go # Go SDK reference: install, auth and methods The official Go SDK. Standard library only, Go 1.21+, context-first API with typed errors and generics for response envelopes. This page is the reference. For an overview of the Go SDK, see [omnisocials.com/sdk/go](https://omnisocials.com/sdk/go). ## Install ```bash go get github.com/OmniSocials/omnisocials-go ``` ## Authentication ```go client, err := omnisocials.NewClient(omnisocials.WithAPIKey("omsk_live_...")) ``` Reads `OMNISOCIALS_API_KEY` if no option is passed. A missing key returns `*omnisocials.AuthenticationError`. ## Create a post ```go res, err := client.Posts.Create(ctx, &omnisocials.PostCreateParams{ Content: "New drop this Friday", Channels: []string{"instagram", "facebook", "linkedin"}, ScheduledAt: "2026-08-01T09:00:00Z", MediaURLs: []string{"https://example.com/teaser.jpg"}, }) fmt.Println(res.Data.ID, res.Data.Status) ``` `MediaURLs` / `MediaIDs` entries can also carry per-media alt text (max 1500 chars) via `MediaURLEntry` / `MediaIDEntry`, delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```go MediaURLs: []omnisocials.MediaURLEntry{ {URL: "https://example.com/teaser.jpg", Alt: "Red sneaker on a white background"}, }, ``` Publish immediately: ```go res, err := client.Posts.CreateAndPublish(ctx, &omnisocials.PostCreateParams{ Content: "Going live right now", Channels: []string{"x", "bluesky"}, }) ``` ## Upload media ```go upload, err := client.Media.UploadFromURL(ctx, &omnisocials.MediaUploadFromURLParams{ URL: "https://example.com/launch-video.mp4", Name: "launch-video-v2", Folder: "Campaigns", }) fmt.Println(upload.Data.ID, upload.Compatibility) ``` Local files via multipart (any `io.Reader`): ```go file, err := os.Open("./photos/product.jpg") if err != nil { log.Fatal(err) } defer file.Close() res, err := client.Media.Upload(ctx, &omnisocials.MediaUploadParams{ File: file, Filename: "product.jpg", Name: "product-hero", }) ``` ## Analytics ```go // One post's latest per-platform metrics stats, err := client.Analytics.Post(ctx, "post_id") fmt.Println(stats.Data.Platforms["instagram"].Metrics) // Batch: up to 100 posts in one call batch, err := client.Analytics.Posts(ctx, []string{"id1", "id2", "id3"}) // Workspace-wide overview overview, err := client.Analytics.Overview(ctx, &omnisocials.AnalyticsOverviewParams{Period: "30d"}) fmt.Println(overview.Data.TotalImpressions, overview.Data.TotalEngagement) // Account-level stats (followers etc) accountStats, err := client.Analytics.Accounts(ctx, &omnisocials.AccountAnalyticsParams{Platform: "instagram"}) ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`Cursor` in, `Pagination.NextCursor` out). ```go conversations, err := client.Inbox.ListConversations(ctx, &omnisocials.InboxListParams{ Platform: "instagram", Unread: omnisocials.Bool(true), }) conversationID := conversations.Data[0].ConversationID thread, err := client.Inbox.GetMessages(ctx, conversationID, nil) _, err = client.Inbox.Reply(ctx, conversationID, &omnisocials.InboxReplyParams{ Text: "Thanks for reaching out!", }) _, err = client.Inbox.MarkRead(ctx, conversationID) // Comment moderation: hide (false unhides) or delete _, err = client.Inbox.Hide(ctx, thread.Data[0].ID, true) _, err = client.Inbox.DeleteMessage(ctx, thread.Data[0].ID) ``` `Inbox.Next` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, set `MessageID` so the reply goes under the comment you answer and not under the newest comment on the post. ```go next, err := client.Inbox.Next(ctx, &omnisocials.InboxNextParams{}) for err == nil && next.Data != nil { if !next.Data.ReplyWindow.Open { // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app if _, err = client.Inbox.MarkRead(ctx, next.Data.Message.ConversationID); err == nil { next, err = client.Inbox.Next(ctx, &omnisocials.InboxNextParams{}) } continue } var reply *omnisocials.InboxReplyResponse reply, err = client.Inbox.Reply(ctx, next.Data.Message.ConversationID, &omnisocials.InboxReplyParams{ Text: "Thanks! DM sent.", MessageID: next.Data.Message.ID, IncludeNext: true, }) if err == nil { next = &omnisocials.InboxNextResponse{Data: reply.Next} } } ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `"product_tags"` in the `Pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```go list, err := client.Pinterest.ListProducts(ctx, nil) if err != nil { log.Fatal(err) } if list.Error != nil { log.Println(list.Error.Code, list.Error.Message) } else { productTags := []string{} for _, product := range list.Products { if len(productTags) < 3 { productTags = append(productTags, product.PinID) } } _, err = client.Posts.Create(ctx, &omnisocials.PostCreateParams{ Content: "Our summer picks", Channels: []string{"pinterest"}, MediaURLs: []string{"https://example.com/summer-look.jpg"}, ScheduledAt: "2026-08-01T09:00:00Z", Pinterest: map[string]any{"board_id": "1234567890", "product_tags": productTags}, }) } ``` The list answers with HTTP 200 in both cases: `Products` on success, or an `Error` with a `Code` (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `Pinterest["product_tags_result"]` with the tagged and the skipped Pin ids. `client.Pinterest.ValidateProduct(ctx, id)` checks one Pin id or Pin link before you post. On `Posts.Update` the `Pinterest` map replaces the stored one, so leave `product_tags` out to remove the tags. ## Verify webhooks ```go package main import ( "errors" "io" "log" "net/http" "os" "time" omnisocials "github.com/OmniSocials/omnisocials-go" ) func main() { secret := os.Getenv("OMNISOCIALS_WEBHOOK_SECRET") http.HandleFunc("/omnisocials/webhook", func(w http.ResponseWriter, r *http.Request) { payload, err := io.ReadAll(r.Body) // keep the raw body! if err != nil { http.Error(w, "failed to read body", http.StatusBadRequest) return } event, err := omnisocials.VerifyWebhookSignature( payload, r.Header.Get("X-OmniSocials-Signature"), secret, 5*time.Minute, // tolerance; pass 0 for the default (5 minutes) ) if err != nil { var verr *omnisocials.WebhookVerificationError if errors.As(err, &verr) { http.Error(w, "invalid signature", http.StatusBadRequest) return } http.Error(w, "server error", http.StatusInternalServerError) return } data, _ := event["data"].(map[string]any) switch event["type"] { case "post.published": log.Println("Published:", data["post_id"], data["targets"]) case "post.failed": log.Println("Failed:", data["post_id"]) case "post.rejected": approval, _ := data["approval"].(map[string]any) log.Println("Rejected:", data["post_id"], approval["reason"]) } w.WriteHeader(http.StatusOK) }) log.Fatal(http.ListenAndServe(":8080", nil)) } ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (null on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.Posts.GetApproval(ctx, id)`. ## Errors Typed errors matched with `errors.As`. The `*APIError` wrapper has `Status`, `Code`, `Message`, and `Body`. Types: `*ValidationError` (400/422), `*AuthenticationError` (401), `*PermissionDeniedError` (403), `*NotFoundError` (404), `*RateLimitError` (429, `RetryAfter` is a `time.Duration`), `*ServerError` (5xx), `*ConnectionError`, and `*WebhookVerificationError`. ```go _, err := client.Posts.Create(ctx, &omnisocials.PostCreateParams{ Content: "Hi", Channels: []string{"instagram"}, }) if err != nil { var rateLimited *omnisocials.RateLimitError var validation *omnisocials.ValidationError var connection *omnisocials.ConnectionError var apiErr *omnisocials.APIError switch { case errors.As(err, &rateLimited): log.Printf("rate limited, retry in %s", rateLimited.RetryAfter) case errors.As(err, &validation): log.Printf("bad request (%s): %s", validation.Code, validation.Message) case errors.As(err, &connection): log.Printf("network problem: %v", connection) case errors.As(err, &apiErr): log.Printf("API error %d (%s): %s", apiErr.Status, apiErr.Code, apiErr.Message) default: log.Fatal(err) } } ``` ## Configuration ```go client, err := omnisocials.NewClient( omnisocials.WithAPIKey("omsk_live_..."), omnisocials.WithBaseURL("https://api.omnisocials.com/v1"), // default omnisocials.WithTimeout(30*time.Second), // per-request timeout (default 30s) omnisocials.WithMaxRetries(2), // retries on 429 / 5xx / network errors (default 2) omnisocials.WithHTTPClient(customHTTPClient), // optional custom *http.Client ) ``` ## Notes - Every method takes a `context.Context` as its first argument for deadlines and cancellation - Generic response envelopes: `ItemResponse[T]` and `ListResponse[T]` - Nullable helpers: `omnisocials.Bool(false)`, `omnisocials.String(...)`, and the `omnisocials.Null` sentinel for clearing fields - Source: [github.com/OmniSocials/omnisocials-go](https://github.com/OmniSocials/omnisocials-go) --- ## Document: Java SDK reference: install, auth and methods The official Java SDK for the OmniSocials API: Maven or Gradle install, authenticate, publish, schedule, and read analytics. URL: https://docs.omnisocials.com/sdks/java # Java SDK reference: install, auth and methods The official Java SDK. Java 11+, built on `java.net.http.HttpClient`, single dependency (Jackson), Jackson `JsonNode` responses with a fluent params builder. This page is the reference. For an overview of the Java SDK, see [omnisocials.com/sdk/java](https://omnisocials.com/sdk/java). ## Install Maven: ```xml com.omnisocials omnisocials-java 0.11.0 ``` Gradle: ```groovy implementation "com.omnisocials:omnisocials-java:0.11.0" ``` ## Authentication ```java OmniSocials client = OmniSocials.builder() .apiKey("omsk_live_...") .build(); ``` Or `OmniSocials.fromEnv()` (reads `OMNISOCIALS_API_KEY`). A missing key throws `AuthenticationException`. ## Create a post ```java JsonNode res = client.posts().create(Params.builder() .put("content", "New drop this Friday") .put("channels", List.of("instagram", "facebook", "linkedin")) .put("scheduled_at", "2026-08-01T09:00:00Z") .put("media_urls", List.of("https://example.com/teaser.jpg")) .build()); JsonNode post = res.get("data"); System.out.println(post.get("id").asText() + " " + post.get("status").asText()); ``` Any `media_urls` (or `media_ids`) entry can also be a map carrying per-media alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```java .put("media_urls", List.of(Params.of( "url", "https://example.com/teaser.jpg", "alt", "Red sneaker on a white background"))) ``` Publish immediately: ```java client.posts().createAndPublish(Params.builder() .put("content", "Going live right now") .put("channels", List.of("x", "bluesky")) .build()); ``` ## Upload media ```java JsonNode upload = client.media().uploadFromUrl(Params.builder() .put("url", "https://example.com/launch-video.mp4") .put("name", "launch-video-v2") .put("folder", "Campaigns") .build()); System.out.println(upload.get("data").get("id").asText()); System.out.println(upload.get("compatibility")); ``` Local files via multipart: ```java import java.nio.file.Path; // From a path (filename and content type detected from the file) JsonNode res = client.media().upload(Path.of("photos/product.jpg"), Params.of("name", "product-hero")); // Or from bytes + filename byte[] bytes = java.nio.file.Files.readAllBytes(Path.of("photos/product.jpg")); client.media().upload(bytes, "product.jpg"); ``` ## Analytics ```java // One post's latest per-platform metrics JsonNode stats = client.analytics().post("post_id"); System.out.println(stats.get("data").get("platforms").path("instagram").path("metrics")); // Batch: up to 100 posts in one call JsonNode batch = client.analytics().posts(List.of("id1", "id2", "id3")); // or: client.analytics().posts("id1", "id2", "id3"); // Workspace-wide overview JsonNode overview = client.analytics().overview(Params.of("period", "30d")); System.out.println(overview.get("data").get("total_impressions").asLong()); // Account-level stats (followers etc) JsonNode accountStats = client.analytics().accounts(Params.of("platform", "instagram")); ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`cursor` in, `pagination.next_cursor` out). ```java JsonNode conversations = client.inbox().listConversations( Params.of("platform", "instagram", "unread", true)); String conversationId = conversations.get("data").get(0).get("conversation_id").asText(); JsonNode messages = client.inbox().getMessages(conversationId); client.inbox().reply(conversationId, Params.of("text", "Thanks for reaching out!")); client.inbox().markRead(conversationId); // Comment moderation: hide (hide(messageId, false) unhides) or delete String messageId = messages.get("data").get(0).get("id").asText(); client.inbox().hide(messageId); client.inbox().deleteMessage(messageId); ``` `inbox().next()` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, pass `message_id` so the reply goes under the comment you answer and not under the newest comment on the post. ```java JsonNode next = client.inbox().next().get("data"); while (next != null && !next.isNull()) { JsonNode message = next.get("message"); if (!next.get("reply_window").get("open").asBoolean()) { // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app client.inbox().markRead(message.get("conversation_id").asText()); next = client.inbox().next().get("data"); continue; } JsonNode reply = client.inbox().reply( message.get("conversation_id").asText(), Params.of( "text", "Thanks! DM sent.", "message_id", message.get("id").asText(), "include_next", true)); next = reply.get("next"); } ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `product_tags` in the `pinterest` map of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```java JsonNode result = client.pinterest().listProducts(); if (result.has("error")) { System.err.println(result.get("error").get("code").asText()); } else { List productTags = new ArrayList<>(); for (JsonNode product : result.get("products")) { if (productTags.size() < 3) { productTags.add(product.get("pin_id").asText()); } } client.posts().create(Params.builder() .put("content", "Our summer picks") .put("channels", List.of("pinterest")) .put("media_urls", List.of("https://example.com/summer-look.jpg")) .put("scheduled_at", "2026-08-01T09:00:00Z") .put("pinterest", Params.of("board_id", "1234567890", "product_tags", productTags)) .build()); } ``` The list answers with HTTP 200 in both cases: `products` on success, or an `error` object (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest.product_tags_result` with the tagged and the skipped Pin ids. `client.pinterest().validateProduct(id)` checks one Pin id or Pin link before you post. On `posts().update()` the `pinterest` map replaces the stored one, so leave `product_tags` out to remove the tags. ## Verify webhooks Spring Boot: ```java import com.fasterxml.jackson.databind.JsonNode; import com.omnisocials.Webhooks; import com.omnisocials.errors.WebhookVerificationException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @RestController public class OmniSocialsWebhookController { private final String secret = System.getenv("OMNISOCIALS_WEBHOOK_SECRET"); @PostMapping("/omnisocials/webhook") public ResponseEntity handle( @RequestBody String rawBody, // Spring gives you the raw body as a String @RequestHeader("X-OmniSocials-Signature") String signature) { JsonNode event; try { event = Webhooks.verifySignature(rawBody, signature, secret, 300); } catch (WebhookVerificationException e) { return ResponseEntity.badRequest().build(); } switch (event.get("type").asText()) { case "post.published": System.out.println("Published: " + event.get("data").get("post_id").asText()); break; case "post.failed": System.err.println("Failed: " + event.get("data").get("post_id").asText()); break; case "post.rejected": System.err.println("Rejected: " + event.get("data").get("post_id").asText() + " " + event.get("data").get("approval").get("reason").asText()); break; default: break; } return ResponseEntity.ok().build(); } } ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (null on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.posts().getApproval(id)`. ## Errors All exceptions extend `OmniSocialsException` (unchecked) and live in `com.omnisocials.errors`. `ApiException` exposes `getStatus()`, `getCode()`, `getMessage()`, and `getBody()`. Types: `ValidationException` (400/422), `AuthenticationException` (401), `PermissionDeniedException` (403), `NotFoundException` (404), `RateLimitException` (429, `getRetryAfter()`), `ServerException` (5xx), `ApiConnectionException`, `WebhookVerificationException`. ```java import com.omnisocials.errors.*; try { client.posts().create(Params.of("content", "Hi", "channels", List.of("instagram"))); } catch (RateLimitException e) { System.out.println("Rate limited, retry in " + e.getRetryAfter() + "s"); } catch (ValidationException e) { System.err.println("Bad request (" + e.getCode() + "): " + e.getMessage()); System.err.println(e.getBody()); } catch (ApiConnectionException e) { System.err.println("Network problem: " + e.getMessage()); } catch (ApiException e) { System.err.println("API error " + e.getStatus() + " (" + e.getCode() + "): " + e.getMessage()); } ``` ## Configuration ```java import java.time.Duration; OmniSocials client = OmniSocials.builder() .apiKey("omsk_live_...") .baseUrl("https://api.omnisocials.com/v1") // default .timeout(Duration.ofSeconds(30)) // per-request timeout (default 30s) .maxRetries(2) // retries on 429 / 5xx / network errors (default 2) .build(); ``` ## Notes - Params via `Params.builder().put(...).build()` or `Params.of(k, v, ...)`; unlike `Map.of`, null values are allowed and serialize to JSON `null` - Responses are Jackson `JsonNode` - Webhook verification uses `MessageDigest.isEqual` (constant time) - Source: [github.com/OmniSocials/omnisocials-java](https://github.com/OmniSocials/omnisocials-java) --- ## Document: PHP SDK reference: install, auth and methods The official PHP SDK for the OmniSocials API: Composer install, authenticate, publish, schedule, upload media, and read analytics. URL: https://docs.omnisocials.com/sdks/php # PHP SDK reference: install, auth and methods The official PHP SDK. PHP 8.1+, no Composer dependencies (ext-curl, ext-json), Stripe-style resource objects with associative-array params. This page is the reference. For an overview of the PHP SDK, see [omnisocials.com/sdk/php](https://omnisocials.com/sdk/php). ## Install ```bash composer require omnisocials/omnisocials-php ``` ## Authentication ```php use OmniSocials\Client; $client = new Client(apiKey: 'omsk_live_...'); ``` Reads `OMNISOCIALS_API_KEY` if omitted. A missing key throws `OmniSocials\Exception\AuthenticationException`. ## Create a post ```php $response = $client->posts->create([ 'content' => 'New drop this Friday', 'channels' => ['instagram', 'facebook', 'linkedin'], 'scheduled_at' => '2026-08-01T09:00:00Z', 'media_urls' => ['https://example.com/teaser.jpg'], ]); echo $response['data']['id'] . ' ' . $response['data']['status']; ``` Any `media_urls` (or `media_ids`) entry can also be an array carrying per-media alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```php 'media_urls' => [['url' => 'https://example.com/teaser.jpg', 'alt' => 'Red sneaker on a white background']], ``` Publish immediately: ```php $client->posts->createAndPublish([ 'content' => 'Going live right now', 'channels' => ['x', 'bluesky'], ]); ``` ## Upload media ```php $upload = $client->media->uploadFromUrl([ 'url' => 'https://example.com/launch-video.mp4', 'name' => 'launch-video-v2', 'folder' => 'Campaigns', ]); echo $upload['data']['id']; print_r($upload['compatibility']); ``` Local files via multipart: ```php // From a path $client->media->upload(['file' => './photos/product.jpg', 'name' => 'product-hero']); // Or from raw bytes (pass a filename so the API can detect the type) $bytes = file_get_contents('./photos/product.jpg'); $client->media->upload(['file' => $bytes, 'filename' => 'product.jpg']); ``` ## Analytics ```php // One post's latest per-platform metrics $stats = $client->analytics->post('post_id'); print_r($stats['data']['platforms']['instagram']['metrics'] ?? null); // Batch: up to 100 posts in one call $batch = $client->analytics->posts(['id1', 'id2', 'id3']); // Workspace-wide overview $overview = $client->analytics->overview(['period' => '30d']); echo $overview['data']['total_impressions'] . ' ' . $overview['data']['total_engagements']; // Account-level stats (followers etc) $accountStats = $client->analytics->accounts(['platform' => 'instagram']); ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`cursor` in, `pagination.next_cursor` out). ```php $conversations = $client->inbox->listConversations(['platform' => 'instagram', 'unread' => true]); $conversationId = $conversations['data'][0]['conversation_id']; $messages = $client->inbox->getMessages($conversationId); $client->inbox->reply($conversationId, ['text' => 'Thanks for reaching out!']); $client->inbox->markRead($conversationId); // Comment moderation: hide (['hide' => false] unhides) or delete $client->inbox->hide($messages['data'][0]['id']); $client->inbox->deleteMessage($messages['data'][0]['id']); ``` `next()` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, pass `message_id` so the reply goes under the comment you answer and not under the newest comment on the post. ```php $next = $client->inbox->next(); while ($next['data'] !== null) { $message = $next['data']['message']; if (!$next['data']['reply_window']['open']) { // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app $client->inbox->markRead($message['conversation_id']); $next = $client->inbox->next(); continue; } $reply = $client->inbox->reply($message['conversation_id'], [ 'text' => 'Thanks! DM sent.', 'message_id' => $message['id'], 'include_next' => true, ]); $next = ['data' => $reply['next'] ?? null, 'remaining' => $reply['remaining'] ?? 0]; } ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `product_tags` in the `pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```php $result = $client->pinterest->listProducts(); if (isset($result['error'])) { error_log($result['error']['code'] . ': ' . $result['error']['message']); } else { $client->posts->create([ 'content' => 'Our summer picks', 'channels' => ['pinterest'], 'media_urls' => ['https://example.com/summer-look.jpg'], 'scheduled_at' => '2026-08-01T09:00:00Z', 'pinterest' => [ 'board_id' => '1234567890', 'product_tags' => array_column(array_slice($result['products'], 0, 3), 'pin_id'), ], ]); } ``` The list answers with HTTP 200 in both cases: `products` on success, or an `error` array (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest.product_tags_result` with the tagged and the skipped Pin ids. `$client->pinterest->validateProduct($id)` checks one Pin id or Pin link before you post. On `posts->update()` the `pinterest` array replaces the stored one, so leave `product_tags` out to remove the tags. ## Verify webhooks ```php posts->getApproval($id)`. ## Errors The base class is `OmniSocials\Exception\OmniSocialsException`. `ApiException` exposes `getStatus()`, `getErrorCode()`, `getMessage()`, and `getBody()`. Types: `ValidationException` (400/422), `AuthenticationException` (401), `PermissionDeniedException` (403), `NotFoundException` (404), `RateLimitException` (429, `getRetryAfter()`), `ServerException` (5xx), `ApiConnectionException`, and `WebhookVerificationException`. ```php use OmniSocials\Exception\ApiConnectionException; use OmniSocials\Exception\ApiException; use OmniSocials\Exception\RateLimitException; use OmniSocials\Exception\ValidationException; try { $client->posts->create(['content' => 'Hi', 'channels' => ['instagram']]); } catch (RateLimitException $e) { echo 'Rate limited, retry in ' . $e->getRetryAfter() . "s\n"; } catch (ValidationException $e) { echo "Bad request ({$e->getErrorCode()}): {$e->getMessage()}\n"; print_r($e->getBody()); } catch (ApiConnectionException $e) { echo 'Network problem: ' . $e->getMessage() . "\n"; } catch (ApiException $e) { echo "API error {$e->getStatus()} ({$e->getErrorCode()}): {$e->getMessage()}\n"; } ``` Note the error code accessor is `getErrorCode()`, not `getCode()`. ## Configuration ```php $client = new Client( apiKey: 'omsk_live_...', baseUrl: 'https://api.omnisocials.com/v1', // default timeout: 30.0, // per-request timeout in seconds (default 30) maxRetries: 2, // automatic retries on 429 / 5xx / network errors (default 2) ); ``` ## Notes - Responses are decoded to associative arrays - Webhook verification is the static `Webhooks::verifySignature()` using `hash_equals` - Source: [github.com/OmniSocials/omnisocials-php](https://github.com/OmniSocials/omnisocials-php) --- ## Document: Ruby SDK reference: install, auth and methods The official Ruby SDK for the OmniSocials API: gem install, authenticate, publish, schedule, upload media, and read analytics. URL: https://docs.omnisocials.com/sdks/ruby # Ruby SDK reference: install, auth and methods The official Ruby SDK. Ruby 3.0+, zero runtime dependencies (Net::HTTP, OpenSSL, JSON), plain-Hash responses. This page is the reference. For an overview of the Ruby SDK, see [omnisocials.com/sdk/ruby](https://omnisocials.com/sdk/ruby). ## Install ```bash gem install omnisocials ``` Or in your Gemfile: ```ruby gem "omnisocials" ``` ## Authentication ```ruby require "omnisocials" client = OmniSocials::Client.new(api_key: "omsk_live_...") # or: export OMNISOCIALS_API_KEY=omsk_live_... and just OmniSocials::Client.new ``` ## Create a post ```ruby post = client.posts.create( content: "Big announcement coming Friday", channels: ["instagram", "facebook", "linkedin"], scheduled_at: "2026-08-01T09:00:00Z", media_urls: ["https://example.com/teaser.jpg"] ) ``` Any `media_urls` (or `media_ids`) entry can also be a Hash carrying per-media alt text (max 1500 chars), delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```ruby media_urls: [{ "url" => "https://example.com/teaser.jpg", "alt" => "Red sneaker on a white background" }] ``` Publish immediately: ```ruby post = client.posts.create_and_publish( content: "We are live!", channels: ["x", "bluesky"] ) ``` ## Upload media ```ruby media = client.media.upload_from_url( url: "https://example.com/promo.mp4", name: "promo-august", folder: "Campaigns" ) media_id = media["data"]["id"] ``` Local files: `client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")` (`file` accepts a path String/Pathname, an IO, or a raw binary String). Base64: `client.media.upload_from_base64(data: b64_string, mime_type: "image/png")`. ## Analytics ```ruby # One post stats = client.analytics.post("123") # Up to 100 posts in one request (avoids rate limit trouble when syncing) bulk = client.analytics.posts(["123", "124", "125"]) # Workspace overview overview = client.analytics.overview(period: "30d") # Account-level stats (followers etc.) accounts = client.analytics.accounts(platform: "instagram") ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`cursor:` in, `pagination["next_cursor"]` out). ```ruby conversations = client.inbox.list_conversations(platform: "instagram", unread: true) conversation_id = conversations["data"][0]["conversation_id"] messages = client.inbox.get_messages(conversation_id) client.inbox.reply(conversation_id, text: "Thanks for reaching out!") client.inbox.mark_read(conversation_id) # Comment moderation: hide (hide: false unhides) or delete client.inbox.hide(messages["data"][0]["id"]) client.inbox.delete_message(messages["data"][0]["id"]) ``` `inbox.next` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, pass `message_id:` so the reply goes under the comment you answer and not under the newest comment on the post. ```ruby item = client.inbox.next while item["data"] message = item["data"]["message"] unless item["data"]["reply_window"]["open"] # An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app client.inbox.mark_read(message["conversation_id"]) item = client.inbox.next next end reply = client.inbox.reply( message["conversation_id"], text: "Thanks! DM sent.", message_id: message["id"], include_next: true ) item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 } end ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `"product_tags"` in the `pinterest:` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```ruby result = client.pinterest.list_products if result["error"] warn "#{result["error"]["code"]}: #{result["error"]["message"]}" else client.posts.create( content: "Our summer picks", channels: ["pinterest"], media_urls: ["https://example.com/summer-look.jpg"], scheduled_at: "2026-08-01T09:00:00Z", pinterest: { "board_id" => "1234567890", "product_tags" => result["products"].first(3).map { |product| product["pin_id"] } } ) end ``` The list answers with HTTP 200 in both cases: `"products"` on success, or an `"error"` hash (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest["product_tags_result"]` with the tagged and the skipped Pin ids. `client.pinterest.validate_product(id)` checks one Pin id or Pin link before you post. On `posts.update` the `pinterest:` hash replaces the stored one, so leave `"product_tags"` out to remove the tags. ## Verify webhooks Rails controller: ```ruby class OmnisocialsWebhooksController < ApplicationController skip_before_action :verify_authenticity_token def create payload = request.body.read # raw body, exactly as received signature = request.headers["X-OmniSocials-Signature"] begin event = OmniSocials::Webhooks.verify( payload: payload, signature: signature, secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET"), tolerance: 300 ) rescue OmniSocials::WebhookVerificationError return head :bad_request end if event["type"] == "post.published" event["data"]["targets"].each do |target| Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}" end elsif event["type"] == "post.rejected" Rails.logger.info "Rejected: #{event["data"]["post_id"]} #{event["data"]["approval"]["reason"]}" end head :ok end end ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (`nil` on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.posts.get_approval(post_id)`. ## Errors The base class is `OmniSocials::Error`. `OmniSocials::APIError` covers HTTP errors with subclasses `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429, with `#retry_after`), and `ServerError` (5xx), plus `APIConnectionError` and `WebhookVerificationError`. Each carries `status`, `code`, `message`, and `body`. ```ruby begin client.posts.get("does-not-exist") rescue OmniSocials::NotFoundError => e puts "Gone: #{e.code} #{e.message}" rescue OmniSocials::RateLimitError => e puts "Slow down, retry in #{e.retry_after}s" rescue OmniSocials::ValidationError => e puts "Bad request: #{e.body}" rescue OmniSocials::AuthenticationError puts "Check your API key" rescue OmniSocials::APIConnectionError puts "Network problem" rescue OmniSocials::APIError => e puts "API error: #{e.status} #{e.message}" end ``` ## Configuration ```ruby client = OmniSocials::Client.new( api_key: "omsk_live_...", timeout: 60, # seconds max_retries: 5, # retries after the first attempt base_url: "https://api.omnisocials.com/v1" # override for testing ) ``` ## Notes - A fresh connection is used per request; there is nothing to close - All responses are plain Hashes with string keys - Omitting `folder_id`/`parent_id` leaves a value unchanged; passing `nil` explicitly moves to root/top level - Source: [github.com/OmniSocials/omnisocials-ruby](https://github.com/OmniSocials/omnisocials-ruby) --- ## Document: Rust SDK reference: install, auth and methods The official Rust SDK for the OmniSocials API: cargo add, authenticate, publish, schedule, upload media, and read analytics. URL: https://docs.omnisocials.com/sdks/rust # Rust SDK reference: install, auth and methods The official Rust SDK. Async-first on `reqwest` with rustls (no OpenSSL), typed request structs with serde, `serde_json::Value` responses. This page is the reference. For an overview of the Rust SDK, see [omnisocials.com/sdk/rust](https://omnisocials.com/sdk/rust). ## Install ```bash cargo add omnisocials cargo add tokio --features rt-multi-thread,macros ``` ## Authentication ```rust let client = omnisocials::Client::new("omsk_live_...")?; ``` Or `Client::from_env()?` (reads `OMNISOCIALS_API_KEY`). A missing key returns `Error::Auth` with code `missing_api_key`. ## Create a post ```rust use omnisocials::CreatePostParams; let post = client.posts().create(CreatePostParams { content: "New drop this Friday".into(), channels: Some(vec!["instagram".into(), "facebook".into(), "linkedin".into()]), scheduled_at: Some("2026-08-01T09:00:00Z".into()), media_urls: Some(vec!["https://example.com/teaser.jpg"].into()), ..Default::default() }).await?; println!("{} {}", post["data"]["id"], post["data"]["status"]); ``` `media_urls` / `media_ids` entries can also carry per-media alt text (max 1500 chars) via `MediaEntry::Url` / `MediaEntry::Id`, delivered to Mastodon, Bluesky, X (photos), Pinterest, Instagram (images), and LinkedIn (images): ```rust use omnisocials::MediaEntry; media_urls: Some(vec![MediaEntry::Url { url: "https://example.com/teaser.jpg".into(), alt: Some("Red sneaker on a white background".into()), }].into()), ``` Publish immediately: ```rust client.posts().create_and_publish(CreatePostParams { content: "Going live right now".into(), channels: Some(vec!["x".into(), "bluesky".into()]), ..Default::default() }).await?; ``` ## Upload media ```rust use omnisocials::UploadMediaFromUrlParams; let upload = client.media().upload_from_url(UploadMediaFromUrlParams { url: "https://example.com/launch-video.mp4".into(), name: Some("launch-video-v2".into()), folder: Some("Campaigns".into()), ..Default::default() }).await?; println!("{} {:?}", upload["data"]["id"], upload["compatibility"]); ``` Local files (multipart from bytes): ```rust use omnisocials::UploadMediaParams; let bytes = std::fs::read("./photos/product.jpg")?; let uploaded = client.media().upload(UploadMediaParams { file: bytes, filename: "product.jpg".into(), name: Some("product-hero".into()), ..Default::default() }).await?; ``` ## Analytics ```rust use omnisocials::{AccountAnalyticsParams, AnalyticsOverviewParams}; // One post's latest per-platform metrics let stats = client.analytics().post("post_id").await?; println!("{:?}", stats["data"]["platforms"]["instagram"]["metrics"]); // Batch: up to 100 posts in one call let batch = client.analytics().posts(&["id1", "id2", "id3"]).await?; // Workspace-wide overview let overview = client.analytics().overview(AnalyticsOverviewParams { period: Some("30d".into()), ..Default::default() }).await?; println!( "{} impressions, {} engagements", overview["data"]["total_impressions"], overview["data"]["total_engagements"] ); // Account-level stats (followers etc) let account_stats = client.analytics().accounts(AccountAnalyticsParams { platform: Some("instagram".into()), ..Default::default() }).await?; ``` ## Social Inbox Read and answer DMs, comments and mentions from your connected accounts. The API key needs the opt-in `inbox:read` / `inbox:write` scopes. The lists use cursor pagination (`cursor` in, `pagination.next_cursor` out). ```rust use omnisocials::{ListConversationsParams, ReplyParams}; let conversations = client.inbox().list_conversations(ListConversationsParams { platform: Some("instagram".into()), unread: Some(true), ..Default::default() }).await?; let conversation_id = conversations["data"][0]["conversation_id"].as_str().unwrap(); let messages = client.inbox().get_messages(conversation_id, Default::default()).await?; client.inbox().reply(conversation_id, ReplyParams { text: "Thanks for reaching out!".into(), ..Default::default() }).await?; client.inbox().mark_read(conversation_id).await?; // Comment moderation: hide (false unhides) or delete let message_id = messages["data"][0]["id"].as_str().unwrap(); client.inbox().hide(message_id, true).await?; client.inbox().delete_message(message_id).await?; ``` `inbox().next()` hands out the next conversation that still needs an answer, with the thread and the post it belongs to. On a comment thread, set `message_id` so the reply goes under the comment you answer and not under the newest comment on the post. ```rust use omnisocials::NextUnansweredParams; let mut next = client.inbox().next(NextUnansweredParams::default()).await?; while !next["data"].is_null() { let message = &next["data"]["message"]; if next["data"]["reply_window"]["open"] == false { // An Instagram/Facebook DM past Meta's 24-hour window: answer it in the app client.inbox().mark_read(message["conversation_id"].as_str().unwrap()).await?; next = client.inbox().next(NextUnansweredParams::default()).await?; continue; } let reply = client.inbox().reply(message["conversation_id"].as_str().unwrap(), ReplyParams { text: "Thanks! DM sent.".into(), message_id: message["id"].as_str().map(str::to_owned), include_next: Some(true), ..Default::default() }).await?; next = serde_json::json!({ "data": reply["next"], "remaining": reply["remaining"] }); } ``` See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for the platforms, the reply rules and the error codes. ## Pinterest product tags Tag products on a Pin so people can shop the items in the image. List the product Pins of the connected Pinterest account, then pass up to 24 of their Pin ids as `product_tags` in the `pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. ```rust use omnisocials::ListPinterestProductsParams; let result = client.pinterest().list_products(ListPinterestProductsParams::default()).await?; if let Some(error) = result.get("error") { eprintln!("{}: {}", error["code"], error["message"]); } else { let product_tags: Vec<&str> = result["products"] .as_array() .map(|products| products.iter().filter_map(|product| product["pin_id"].as_str()).take(3).collect()) .unwrap_or_default(); client.posts().create(CreatePostParams { content: "Our summer picks".into(), channels: Some(vec!["pinterest".into()]), media_urls: Some(vec!["https://example.com/summer-look.jpg"].into()), scheduled_at: Some("2026-08-01T09:00:00Z".into()), pinterest: Some(json!({ "board_id": "1234567890", "product_tags": product_tags })), ..Default::default() }).await?; } ``` The list answers with HTTP 200 in both cases: `products` on success, or an `error` object (`pinterest_not_connected`, `pinterest_catalog_access_required`, `platform_error`) when it could not be read. It reads the Pinterest catalog when the connection has catalog access (given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog), else the account's own Pins. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the post then carries `pinterest.product_tags_result` with the tagged and the skipped Pin ids. `client.pinterest().validate_product(id)` checks one Pin id or Pin link before you post. On `posts().update` the `pinterest` value replaces the stored one, so leave `product_tags` out to remove the tags. ## Verify webhooks axum handler: ```rust use axum::body::Bytes; use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; use axum::Router; async fn omnisocials_webhook(headers: HeaderMap, body: Bytes) -> StatusCode { let signature = headers .get("x-omnisocials-signature") .and_then(|value| value.to_str().ok()) .unwrap_or(""); let secret = std::env::var("OMNISOCIALS_WEBHOOK_SECRET").expect("secret not set"); // `body` is the raw request bytes: exactly what the signature covers. match omnisocials::webhooks::verify_signature(&body, signature, &secret, 300) { Ok(event) => { match event["type"].as_str() { Some("post.published") => { println!("Published: {} {:?}", event["data"]["post_id"], event["data"]["targets"]); } Some("post.failed") => { eprintln!("Failed: {}", event["data"]["post_id"]); } Some("post.rejected") => { eprintln!("Rejected: {} {}", event["data"]["post_id"], event["data"]["approval"]["reason"]); } _ => {} } StatusCode::OK } Err(_) => StatusCode::BAD_REQUEST, } } let app: Router = Router::new().route("/omnisocials/webhook", post(omnisocials_webhook)); ``` Events: `post.scheduled`, `post.published`, `post.failed`, `post.approved` and `post.rejected`. The two approval events carry `data.approval` with `status`, `decided_by` (the approver's user id) and `reason` (`null` on `post.approved`). To read the full review of a post (steps, approvers, the rejection reason and the comment thread), call `client.posts().get_approval(id)`. ## Errors Everything returns `Result`. The `Error` enum has variants `Validation` (400/422), `Auth` (401), `PermissionDenied` (403), `NotFound` (404), `RateLimit` (429, with `retry_after`), `Server` (5xx), `Api` (other non-2xx, e.g. 409 `media_in_use`), `Connection`, and `WebhookVerification`. Accessors `err.status()`, `err.code()`, and `err.retry_after()` work across all API variants. ```rust use omnisocials::{CreatePostParams, Error}; match client.posts().create(CreatePostParams { content: "Hi".into(), channels: Some(vec!["instagram".into()]), ..Default::default() }).await { Ok(post) => println!("created {}", post["data"]["id"]), Err(Error::RateLimit { retry_after, .. }) => { eprintln!("rate limited, retry in {:?}s", retry_after); } Err(Error::Validation { code, message, .. }) => { eprintln!("bad request ({:?}): {}", code, message); } Err(Error::Connection(err)) => { eprintln!("network problem: {err}"); } Err(err) => { eprintln!("API error {:?} ({:?}): {}", err.status(), err.code(), err); } } ``` ## Configuration ```rust use std::time::Duration; let client = omnisocials::Client::builder() .api_key("omsk_live_...") // wins over the env var .base_url("https://api.omnisocials.com/v1") // default .timeout(Duration::from_secs(30)) // per-request timeout (default 30s) .max_retries(2) // retries on 429 / 5xx / network errors (default 2) .build()?; ``` ## Notes - Typed request structs with the `..Default::default()` idiom throughout - `content` is a `Content` enum with a `Content::PerPlatform(HashMap<...>)` variant for per-platform text - Clearing nullable fields uses nested options: `folder_id: Some(Some("12".into()))` sets a value, `Some(None)` sends JSON `null` (move to root) - Responses are `serde_json::Value`; deserialize with `serde_json::from_value` if you want types - Source: [github.com/OmniSocials/omnisocials-rust](https://github.com/OmniSocials/omnisocials-rust) --- ## Document: CLI: post to social media from your terminal Post to 12 social channels from your terminal with the OmniSocials CLI: publish, schedule, upload media, read analytics, and manage your inbox. URL: https://docs.omnisocials.com/cli # CLI: post to social media from your terminal The OmniSocials CLI is a single-file, zero-dependency Node.js command line tool that covers the full v1 API: posting, media, analytics, the social inbox, accounts, and webhooks. It is designed for both humans and AI agents (it ships as part of [OmniSocials Agent Skills](https://docs.omnisocials.com/integrations/agent-skills.md)), and every command supports `--json` for machine-readable output. ## Install The CLI is a single executable script; there is no npm package. Clone the repository and put the script on your PATH under the name `omnisocials`: ```bash git clone https://github.com/OmniSocials/omnisocials-agent-skills sudo ln -s "$PWD/omnisocials-agent-skills/skills/omnisocials/scripts/omnisocials.js" /usr/local/bin/omnisocials omnisocials --help ``` If you prefer not to touch `/usr/local/bin`, an alias works the same: `alias omnisocials="$HOME/omnisocials-agent-skills/skills/omnisocials/scripts/omnisocials.js"`. Every example below assumes the `omnisocials` name; without the symlink or alias, invoke the script by its path instead. For AI agents, install it as a skill instead; the skill ships the same CLI and instructs the agent how to call it: ```bash npx skills add OmniSocials/omnisocials-agent-skills ``` Requires Node.js 18 or newer. No dependencies are installed; the CLI uses the built-in `fetch`. ## Authentication Run the interactive setup once. It prompts for your API key, validates it against the API, writes a config file, and adds `.omnisocials/` to your `.gitignore`: ```bash omnisocials setup # project config (./.omnisocials/config.json) omnisocials setup --global # global config (~/.config/omnisocials/config.json) ``` Create an API key in the OmniSocials app under **Settings → API**. Keys start with `omsk_live_`; there is no separate test mode. See [Authentication](https://docs.omnisocials.com/authentication.md) for scopes. The CLI resolves credentials in this order (first match wins): 1. `--api-key ` flag on the command 2. `OMNISOCIALS_API_KEY` environment variable 3. Project config: `./.omnisocials/config.json` 4. Global config: `~/.config/omnisocials/config.json` The config file is plain JSON: ```json { "api_key": "omsk_live_...", "base_url": "https://api.omnisocials.com/v1" } ``` `base_url` is optional and defaults to `https://api.omnisocials.com/v1`. Check what the CLI resolved with `omnisocials config:show`. ## Global flags Every command accepts: | Flag | Description | |------|-------------| | `--json` | Output the raw JSON response instead of formatted text | | `--api-key ` | Override the API key for this invocation | | `--base-url ` | Override the API base URL | | `--help` | Show the built-in help | ## Channels Commands that target platforms take `--channels` with comma-separated channel ids: `instagram`, `facebook`, `linkedin` (personal profile), `linkedin_page` (company page), `youtube`, `tiktok`, `x`, `pinterest`, `bluesky`, `threads`, `mastodon`, `google_business`, `telegram` (a channel or group) ## Command groups | Group | Commands | Page | |-------|----------|------| | Posting | `posts:list`, `posts:get`, `posts:create`, `posts:create-and-publish`, `posts:update`, `posts:publish`, `posts:retry`, `posts:approve`, `posts:reject`, `posts:approval`, `posts:delete`, `posts:recent-platform` | [Posting commands](https://docs.omnisocials.com/cli/posting.md) | | Inbox | `inbox:list`, `inbox:messages`, `inbox:next`, `inbox:read`, `inbox:reply`, `inbox:hide`, `inbox:delete` | [Inbox commands](https://docs.omnisocials.com/cli/inbox.md) | | Analytics | `analytics:post`, `analytics:posts`, `analytics:overview`, `analytics:accounts`, `analytics:best-times` | [Analytics commands](https://docs.omnisocials.com/cli/analytics.md) | | Platforms | `accounts:list`, `accounts:get`, `locations:search`, `audio:search`, `pinterest:products`, `pinterest:validate` | [Platform commands](https://docs.omnisocials.com/cli/platforms.md) | | Management | `setup`, `config:show`, `media:*`, `folders:*`, `hashtag-sets:*`, `automations:*`, `webhooks:*` | [Management commands](https://docs.omnisocials.com/cli/management.md) | ## Quick example ```bash # Connect once omnisocials setup # See what is connected omnisocials accounts:list # Schedule a post to two platforms omnisocials posts:create \ --text "Big announcement coming Friday" \ --channels instagram,linkedin \ --media-urls https://example.com/teaser.jpg \ --schedule 2026-08-01T09:00:00Z ``` Rate limit: 100 requests per minute per API key. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). --- ## Document: CLI analytics commands OmniSocials CLI analytics commands: post metrics, bulk stats, workspace overviews, account snapshots, and best posting times. URL: https://docs.omnisocials.com/cli/analytics # CLI analytics commands Pull post-level and account-level analytics, workspace overviews, and recommended posting times. All analytics commands require the `analytics:read` scope on your API key. ## Commands | Command | Description | |---------|-------------| | `analytics:post ` | Per-post analytics aggregated across platforms: impressions, engagements, likes, comments, shares, plus per-platform breakdowns | | `analytics:posts ` | Bulk analytics for up to 100 posts in one call (comma- or space-separated ids) | | `analytics:overview` | Workspace-level overview: totals, average engagement rate, top platform. Flags: `--period <7d\|30d\|90d>`, `--start-date `, `--end-date ` | | `analytics:accounts` | Account-level analytics such as followers and subscribers. Flags: `--platform`, `--date ` | | `analytics:best-times` | Recommended posting slots (top 3 plus per-day scores) computed from your own posting history. Flags: `--platform` (required), `--timezone ` | `analytics:best-times` needs history to work with: below 15 analyzed posts it falls back to labeled industry defaults instead of your own data. ## Metric semantics Account-level metrics from `analytics:accounts` are daily snapshots, and a metric's scope varies by platform; read the `note` field on each row. The big one: **LinkedIn profile rows keep a lifetime total** in `impressions_lifetime`, across all of the account's content, including posts published outside OmniSocials. When LinkedIn serves a daily breakdown, `impressions` is that day's value and the row says `period: "daily"`; otherwise the row says `period: "lifetime"`. LinkedIn page rows always hold day values. Never compare a lifetime total to a windowed export like LinkedIn's native 90-day analytics: a multi-year account's lifetime total can easily be 10x a 90-day window. To measure a window, diff two snapshots (`--date 2026-08-01` vs `--date 2026-07-01`). Some platforms (e.g. Instagram) report no account-level impressions at all; a missing field means "not collected", not zero. Engagement is normalized the same way on every OmniSocials surface: the sum of the platform's real interaction fields: for LinkedIn that includes link clicks (matching LinkedIn's own definition), for X it includes quotes and bookmarks. Engagement rate is engagement / impressions. ## Examples How did last week go? ```bash omnisocials analytics:overview --period 7d ``` Check a specific post across every platform it went to: ```bash omnisocials analytics:post 58105759 ``` Bulk-check a campaign: ```bash omnisocials analytics:posts 58105759,58105760,58105761 ``` When should we post on TikTok? ```bash omnisocials analytics:best-times --platform tiktok --timezone Europe/Amsterdam ``` Combine with `--json` and `jq` for reporting pipelines: ```bash omnisocials analytics:overview --period 30d --json | jq '.data.totals' ``` --- ## Document: CLI inbox commands OmniSocials CLI inbox commands: list conversations, work through what needs an answer, read message history, reply, and hide or delete comments. URL: https://docs.omnisocials.com/cli/inbox # CLI inbox commands Read and reply to DMs, comments, and mentions from Instagram, Facebook, and LinkedIn company pages, TikTok and YouTube video comments, X (Twitter) DMs for workspaces that have enabled them, and Threads replies and mentions. See the [Social Inbox guide](https://docs.omnisocials.com/inbox.md) for concepts, platform coverage, and reply limits. Inbox access is opt-in: your API key needs the `inbox:read` scope for reading and `inbox:write` for replying, marking read, and hiding or deleting comments. Enable them when creating the key under **Settings -> API**. ## Commands | Command | Description | |---------|-------------| | `inbox:list` | List conversations (latest message each) with participant, unread count, and related post. Flags: `--platform `, `--type `, `--unread`, `--unanswered` (only conversations that still need a reply), `--limit`, `--cursor`. Requires `inbox:read` | | `inbox:next` | The next conversation that needs an answer, with its thread (oldest first) and the post it is on, plus how many more are waiting. Flags: `--platform`, `--type `, `--order ` (default oldest), `--include-read`, `--exclude `. Requires `inbox:read` | | `inbox:messages ` | Full message history for one conversation, oldest to newest, each with its message id. Comment threads print the post (caption, url, thumbnail) first. Flags: `--limit`, `--cursor`. Requires `inbox:read` | | `inbox:read ` | Mark a conversation's messages as read. Also the durable skip for `inbox:next`. Requires `inbox:write` | | `inbox:reply ` | Send a reply. Flags: `--text` (required), `--attachment-url`, `--attachment-type`, `--next` (also returns the next conversation that needs an answer). Requires `inbox:write` | | `inbox:hide ` | Hide a comment someone left on one of your posts, as the post owner; `--unhide` reverses it. Facebook, Instagram, TikTok, YouTube, and Threads (top-level replies only). Takes the message id from `inbox:messages` or `inbox:next`, not the conversation id. Requires `inbox:write` | | `inbox:delete ` | Delete a comment from the platform. Facebook, Instagram, and TikTok comments only (YouTube: use `inbox:hide`). Cannot be undone; replies under the comment go with it. Takes the message id, not the conversation id. Requires `inbox:write` | Conversation ids can contain `:` and `()` (LinkedIn URNs); the CLI URL-encodes them for you. Lists are cursor-paginated: pass the `cursor` from the previous response to fetch the next page. Two ids show up in the output. The conversation id is what `inbox:messages`, `inbox:read`, and `inbox:reply` take. The message id, printed on every message, is what `inbox:hide` and `inbox:delete` take. ## What counts as unanswered `inbox:list --unanswered` and `inbox:next` use the same rule. A conversation still needs an answer when the customer's latest DM has no reply after it (Instagram and Facebook DMs only within Meta's 24-hour messaging window, since replies outside it are refused), or when a comment or mention has not been replied to and is not hidden. Replies typed in the native apps (the Instagram app, Messenger) are mirrored into the inbox as outgoing messages and count as answers, so a thread a colleague answered on their phone is not served again. Instagram mentions are skipped because there is no reply path for them. ## Examples Triage unread Instagram DMs: ```bash omnisocials inbox:list --platform instagram --type dm --unread ``` Only the conversations still waiting for a reply: ```bash omnisocials inbox:list --unanswered ``` Work through everything that needs an answer: ```bash omnisocials inbox:next # Next up: instagram comment from Jane Doe (@janedoe) # Conversation: ig_comment_18001 # Message to answer: 42 # On post: 17912345678901234 (IMAGE) # Caption: Summer drop is live # URL: https://www.instagram.com/p/abc123/ # Thumbnail: https://cdn.example.com/p1.jpg # # Thread (oldest first) # ... # 3 more waiting. omnisocials inbox:reply ig_comment_18001 --text "Link is in our bio!" --next # Reply sent! ... followed by the next item. Repeat until the CLI prints # "Nothing is waiting for an answer." ``` `inbox:next` serves unread items only, so marking a conversation read skips it for good. To leave some out of a single call instead, pass `--exclude`: ```bash omnisocials inbox:read ig_comment_18001 omnisocials inbox:next --exclude ig_comment_18001,fb_dm_555 omnisocials inbox:next --platform instagram --type comment --order newest omnisocials inbox:next --include-read ``` Read a conversation and reply: ```bash omnisocials inbox:messages 17845123456789 omnisocials inbox:reply 17845123456789 --text "Thanks for reaching out! Sending you the details now." omnisocials inbox:read 17845123456789 ``` Answer a TikTok video comment (replies are text only, 150 characters max): ```bash omnisocials inbox:list --platform tiktok --unread omnisocials inbox:reply tiktok_7301234567890_7449912345678 --text "Love this! Thanks for watching." ``` Answer a YouTube video comment (conversations group per video): ```bash omnisocials inbox:list --platform youtube --unread omnisocials inbox:reply youtube_comment_aBcDeFgHiJk --text "Glad it helped! More on this coming next week." ``` Hide a comment on your own post (Facebook, Instagram, TikTok, YouTube, Threads): ```bash omnisocials inbox:messages threads_comment_17912345678901234 # each message prints its id; use it for inbox:hide and inbox:delete omnisocials inbox:hide 20457105 omnisocials inbox:hide 20457105 --unhide ``` Delete a comment for good (Facebook, Instagram, TikTok): ```bash omnisocials inbox:delete 20457105 # Comment 20457105 deleted from the platform. # Conversation: fb_comment_1 # Also removed 2 replies under it: 20457110, 20457112 ``` On YouTube, hiding sets the comment's moderation status to rejected, which removes it and its replies from public view; there is no delete. Hiding needs the account to have been connected with the moderation permission: a `reconnect_required` error means reconnecting the account in the dashboard. YouTube's daily API quota can return `quota_exceeded`; retry after midnight Pacific. Replies are limited to existing conversations (no cold outreach), the platform's messaging window applies, and each workspace can send up to 1,000 replies per rolling 24 hours. TikTok conversations are video comments only; see [TikTok comments](https://docs.omnisocials.com/inbox.md#tiktok-comments). YouTube conversations are video comments only too; channels are checked for new comments once per day, so a new comment can take up to a day to show up; see [Platform notes](https://docs.omnisocials.com/inbox.md#platform-notes). X conversations are DMs only, require the workspace to opt in, and each reply uses 2 credits; see [X direct messages](https://docs.omnisocials.com/inbox.md#x-direct-messages). Threads conversations are replies on your posts and mentions, replies publish as native Threads replies, and only incoming top-level replies on your own posts can be hidden. Threads connections made before the inbox permissions were added need a one-time reconnect in the dashboard; see [Threads replies and mentions](https://docs.omnisocials.com/inbox.md#threads-replies-and-mentions). --- ## Document: CLI account management commands OmniSocials CLI management commands: workspaces, media library, folders, hashtag sets, and webhooks. URL: https://docs.omnisocials.com/cli/management # CLI account management commands Configuration, media library, folders, and webhook management. ## Setup and configuration | Command | Description | |---------|-------------| | `setup` | Interactive setup: prompts for an API key, validates it, writes the config file, and adds `.omnisocials/` to `.gitignore`. Flags: `--api-key ` (skip the prompt), `--global` (write the global config), `--base-url ` | | `config:show` | Show the resolved config: masked API key, base URL, and which source it came from | These two commands are the only ones that run without authentication. See [CLI authentication](https://docs.omnisocials.com/cli.md#authentication) for the full resolution order. ## Media | Command | Description | |---------|-------------| | `media:list` | List uploaded media files. Flags: `--limit`, `--offset` | | `media:upload` | Upload from a URL (image, video, or PDF). Flags: `--url` (required), `--filename`, `--name`, `--folder`, `--folder-id`, `--pdf-mode`. PDFs are split into one image slide per page for carousels; `--pdf-mode document` keeps the PDF as one media item whose single id expands into every page | | `media:upload-base64` | Upload a local file: `--file ` (auto-encodes and infers the MIME type) or `--data ` plus `--mime-type `; also `--filename`, `--name`, `--folder`, `--folder-id`, `--pdf-mode` | | `media:check` | Check media compatibility against target platforms before posting. Pass `--url`, `--media-id`, or `--size-bytes` plus `--mime`. For a library image (`--media-id`) it also prints how each channel shows it as a feed post (ratio, whether it fits, the crop target and the default action), so you can decide on `--media-fit` before you post | | `media:delete ` | Delete a media file | ```bash # Upload a local video and verify it works everywhere you want to post omnisocials media:upload-base64 --file ./reel.mp4 omnisocials media:check --media-id 12345 ``` ## Folders | Command | Description | |---------|-------------| | `folders:list` | List media folders with id, name, parent, and item count | | `folders:create` | Create a folder. Flags: `--name` (required), `--parent-id` (nested folders) | ## Hashtag sets Save a group of hashtags once, then apply it to any post with `posts:create --hashtag-set ""`. Tags merge into the captions at create time (editing a set later never changes existing posts), duplicates already in a caption are skipped, and Instagram's 5-hashtag cap fails fast. Add `--hashtag-placement first_comment` to post the tags as the automatic first comment where supported, and `--hashtag-platforms instagram,tiktok` to target a subset of channels. | Command | Description | |---------|-------------| | `hashtag-sets:list` | List saved sets with id, name, tag count, and a preview | | `hashtag-sets:create` | Save a set. Flags: `--name` (required, unique per workspace), `--tags` (required, e.g. `"#fitness #gym"`; the `#` is optional, max 100 tags) | | `hashtag-sets:update ` | Rename or replace tags. Flags: `--name`, `--tags` (replaces the full list) | | `hashtag-sets:delete ` | Delete a set. Posts that already used it keep their hashtags | ```bash omnisocials hashtag-sets:create --name "Fitness Brand" --tags "#fitness #gym #workout" omnisocials posts:create --text "New PB today" \ --channels instagram,tiktok \ --hashtag-set "Fitness Brand" --hashtag-placement first_comment ``` ## Automations (comment to DM) An automation DMs a person once when they comment a keyword on an Instagram post (or send it in a DM or a story reply), and can post a public reply under the comment. The API key needs the opt-in `automations:read` and `automations:write` scopes (Settings, API, "Automations" on the key). | Command | Description | |---------|-------------| | `automations:list` | List automations with status, trigger, keywords, post and counts. Flags: `--status `, `--post-id` | | `automations:get ` | One automation in full | | `automations:create` | Flags: `--dm ""` (required), `--keywords "guide,link"`, `--match `, `--reply "Sent you a DM!"` (public reply, comma list for variations), `--post-id ` or `--instagram-media-id `, `--scope `, `--triggers comment,dm,story_reply`, `--max-sends `, `--stop-at `, `--name`, `--paused` | | `automations:update ` | Same flags, all optional, plus `--pause` / `--resume` | | `automations:delete ` | Delete it and its activity. DMs already sent stay sent | | `automations:events ` | The activity: who commented or wrote, what was sent, skips with their reason. Flags: `--outcome `, `--limit`, `--offset` | A new post can carry its automation: `posts:create --dm-keywords "guide" --dm-message "Here is the guide" --dm-reply "Sent you a DM!"` (Instagram must be a channel); `posts:update --no-dm` removes it. ```bash omnisocials automations:create --post-id 83778183 \ --keywords "guide,link" --dm "Here is the guide: https://example.com/guide" \ --reply "Sent you a DM!,Check your inbox" --max-sends 500 omnisocials automations:events 100000004 --outcome sent ``` ## Webhooks | Command | Description | |---------|-------------| | `webhooks:list` | List all configured webhooks | | `webhooks:create` | Create a webhook. Flags: `--url` (required), `--events` (required; comma-separated: `post.scheduled`, `post.published`, `post.failed`, `post.approved`, `post.rejected`). The signing secret is shown once | | `webhooks:get ` | Webhook details | | `webhooks:update ` | Update URL, events, or active state. Flags: `--url`, `--events`, `--active ` | | `webhooks:delete ` | Delete a webhook | | `webhooks:rotate-secret ` | Rotate the signing secret; the new secret is shown once | ```bash omnisocials webhooks:create \ --url https://example.com/hooks/omnisocials \ --events post.published,post.failed ``` `post.approved` and `post.rejected` fire when the review of a post in an approval workflow ends. To be told about both: ```bash omnisocials webhooks:create \ --url https://example.com/hooks/omnisocials \ --events post.approved,post.rejected ``` Their payload names the decision, the approver and the reason; read the full review with [`posts:approval `](https://docs.omnisocials.com/cli/posting.md). See the [Webhooks guide](https://docs.omnisocials.com/webhooks.md) for event payloads and signature verification. --- ## Document: CLI platform commands OmniSocials CLI platform commands: list connected accounts and inspect per-platform capabilities. URL: https://docs.omnisocials.com/cli/platforms # CLI platform commands Inspect connected accounts and discover platform-specific ids (Instagram locations, licensed audio, Pinterest boards, Pinterest product Pins) that other commands take as input. ## Accounts | Command | Description | |---------|-------------| | `accounts:list` | List connected accounts with channel ids, platforms, supported content types, and Pinterest boards | | `accounts:get ` | Full details for one account, including platform-specific data such as Pinterest board ids | ```bash omnisocials accounts:list ``` The channel ids in the output are what `posts:create --channels` expects. ## Location search | Command | Description | |---------|-------------| | `locations:search ""` | Search taggable locations (minimum 2 characters, `--q` or positional). Flags: `--platform ` (default `instagram`), and for Threads `--latitude` + `--longitude` instead of a name | With the default `--platform instagram` it searches Facebook Places and returns `location_id` values for `posts:create --location-id` (Instagram place tags). Only places with a street address can be tagged. ```bash omnisocials locations:search "Amsterdam Central" omnisocials posts:create \ --text "Live from Amsterdam" \ --channels instagram \ --media-urls https://example.com/photo.jpg \ --location-id 106039436102339 ``` With `--platform threads` it searches Threads locations and returns ids for `posts:create --threads-location-id`. The two id namespaces are different: a Facebook Place ID is not a Threads location id, so never reuse one as the other. Threads also accepts coordinates instead of a name. Threads location tagging is currently rolling out; until Meta approves the permission the search answers `not_available`, and Threads connections made before the permission existed answer `threads_reauth_required` until reconnected. ```bash omnisocials locations:search "Griffith Observatory" --platform threads omnisocials locations:search --platform threads --latitude 34.1184 --longitude -118.3004 omnisocials posts:create \ --text "Best view in the city" \ --channels threads \ --threads-location-id 17841400000000000 ``` ## Audio search | Command | Description | |---------|-------------| | `audio:search [""]` | Search Meta's licensed music catalog for Instagram Reels. Empty query returns trending audio. Flags: `--type ` | Returns `audio_id` values for `posts:create --instagram-audio-id` (reels only). ```bash # Trending right now omnisocials audio:search # Find a specific track omnisocials audio:search "daft punk" --type music ``` ## Pinterest products | Command | Description | |---------|-------------| | `pinterest:products` | List the product Pins of the connected Pinterest account. Flags: `--source `, `--product-group-id `, `--bookmark `, `--page-size <1-100>` (default 25, catalog only) | | `pinterest:validate ` | Check that a Pin ID or Pin link (`https://www.pinterest.com/pin//`) is a product Pin of the connected account | `pinterest:products` returns `pin_id` values for `posts:create --pinterest-product-tags` (max 24 products per Pin). Pinterest accepts a product only when its Pin is public, belongs to the same Pinterest account and links to a website that account claimed. Products of other merchants cannot be tagged. Both commands need the `posts:read` scope. ```bash omnisocials pinterest:products omnisocials posts:create \ --text "Autumn outfit" \ --channels pinterest \ --media-urls https://example.com/outfit.jpg \ --pinterest-board-id 1234567890 \ --pinterest-product-tags 813744226420795884,813744226420795885 ``` ```text Pinterest products (2, source: catalog) ──────────────────────────────────────── pin_id: 813744226420795884 Blue ribbed top (24.99 EUR, IN_STOCK, item TOP-BLUE-M) https://shop.example.com/products/blue-ribbed-top pin_id: 813744226420795885 Grey wool scarf (39.95 EUR, IN_STOCK, item SCARF-GREY) https://shop.example.com/products/grey-wool-scarf Pass ids to posts:create/posts:update with --pinterest-product-tags (max 24 per Pin). ``` There are two sources: - `--source catalog` reads the Pinterest catalog. Each product carries its price, currency, availability and item id, and the output lists the product groups of the account so you can pick another one with `--product-group-id`. It needs catalog access on the Pinterest connection. Give it one time in the OmniSocials composer: open the Pinterest options, select **Add products**, then **Connect catalog**. Without catalog access the command fails with `pinterest_catalog_access_required`. - `--source pins` reads the account's own Pins and returns the ones Pinterest marks as product Pins. It works on every connection. One call scans up to 250 Pins, so a call can find no product Pin and still print a `--bookmark` value: run the command again with that bookmark. Without `--source` the API uses `catalog` when the connection has catalog access, else `pins`. When more results exist, the output ends with the `--bookmark` value for the next page. A problem is printed as an error (`Error [code]: message`, exit code 1), not as an empty list: `pinterest_not_connected` (no Pinterest account on the workspace), `pinterest_catalog_access_required`, or `platform_error` (Pinterest did not answer or refused). `pinterest:validate` checks one Pin before you tag it: ```bash omnisocials pinterest:validate 813744226420795884 omnisocials pinterest:validate https://www.pinterest.com/pin/813744226420795884/ ``` It prints `Valid product Pin: `, or `Not valid (): ` for a Pin that is not a product Pin, belongs to another account or does not exist. `Not checked (): ` means the check could not run; the publish step is then the final check. `pin.it` short links do not work. The check does not look at the claimed-website rule; Pinterest applies that one when the post is published. --- ## Document: CLI posting commands OmniSocials CLI posting commands: create, schedule, publish, retry, and delete posts from the terminal. URL: https://docs.omnisocials.com/cli/posting # CLI posting commands Create, schedule, publish, update, and delete posts from the command line. All commands honor the [global flags](https://docs.omnisocials.com/cli.md#global-flags) and print JSON with `--json`. ## Commands | Command | Description | |---------|-------------| | `posts:list` | List posts. Flags: `--status `, `--limit`, `--offset`. A post that waits for approval or was rejected prints an `Approval:` line that names `posts:approval ` | | `posts:get ` | Full details for one post | | `posts:create` | Create a draft or scheduled post. `--text` is required | | `posts:create-and-publish` | Create a post and publish it immediately (no `--schedule`) | | `posts:update ` | Update a draft or scheduled post; takes the same flags as `posts:create` | | `posts:publish ` | Publish an existing draft or scheduled post now | | `posts:retry ` | Retry only the failed platforms of a `failed` or `warning` post; succeeded channels are never re-posted (max 3 retries per platform) | | `posts:approve ` | Approve the current step of a post's approval workflow (`approval_status: "pending"`). You must be a listed approver for that step; steps approve in order | | `posts:reject ` | Reject a post's approval workflow, stopping it immediately (not just the current step). Flag: `--comment "..."` (optional, shown to the requester and other approvers) | | `posts:approval ` | Read the review of a post that went through an approval workflow: the step it waits on and who still has to decide, who rejected it and why, every step with each approver's decision, and the comment thread. Read-only; `--json` returns the full [review object](https://docs.omnisocials.com/creating-posts.md#reading-the-review) | | `posts:delete ` | Delete a post (irreversible) | | `posts:recent-platform` | Fetch recent posts live from the connected platforms, including content published outside OmniSocials. Flags: `--limit` (1-50, default 25; X defaults to 10 unless set explicitly), `--platforms `. X results may be served from a snapshot up to 24h old (X bills per returned post); it refreshes right after you publish to X through OmniSocials. Requires the `analytics:read` scope | ## Post options For `posts:create`, `posts:create-and-publish`, and `posts:update`: | Flag | Description | |------|-------------| | `--text ` | Caption / post content (required on create) | | `--channels ` | Comma-separated [channel ids](https://docs.omnisocials.com/cli.md#channels) | | `--type ` | Content type (default `post`) | | `--schedule ` | Schedule time, e.g. `2026-08-01T09:00:00Z` | | `--draft` | With `--schedule`: store a planned draft that keeps the time but is not scheduled (a person confirms it in the dashboard, or `posts:update --no-draft`). On `posts:update` alone, `--draft` unschedules a scheduled post and keeps its time | | `--no-draft` | `posts:update` only: schedule a planned draft at the time it already carries | | `--media-urls ` | Attach media by URL | | `--media-ids ` | Attach media from the library | | `--media-fit ` | What happens to a feed image outside what a channel shows uncropped, per channel: `instagram=keep,pinterest=crop`. Actions `auto` (default), `crop`, `keep`. Without it Instagram crops a 9:16 image to 4:5 around the subject and Pinterest keeps a Pin taller than 2:3; every decision is printed as a warning after the post is created. On `posts:update` alone, re-decides the stored images (`keep` gives the original back). See [Image fit per channel](https://docs.omnisocials.com/creating-posts.md#image-fit-per-channel) | | `--link-url ` | Link-preview card (LinkedIn / Facebook) | | `--link-title ` | Override the link-preview title | | `--link-description ` | Override the link-preview description | | `--link-thumbnail-url ` | Override the link-preview thumbnail | | `--location-id ` | Instagram place tag (find ids with [`locations:search`](https://docs.omnisocials.com/cli/platforms.md#location-search)) | | `--collaborators ` | Instagram co-author usernames (max 3) | | `--user-tags ''` | Instagram photo tags: JSON array of `{"username","x","y","image_index?"}` | | `--x-thread "a \|\| b \|\| c"` | X thread; parts split on `\|\|` | | `--bluesky-thread "a \|\| b \|\| c"` | Bluesky thread | | `--mastodon-thread "a \|\| b \|\| c"` | Mastodon thread | | `--threads-thread "a \|\| b \|\| c"` | Threads thread (Meta Threads) | | `--video-cover-json ''` | Thumbnail for a post whose media is one video: `{"type":"frame","thumb_offset":3000}` (milliseconds into the video) or `{"type":"custom","cover_url":"https://..."}`, plus optional `"overrides"` keyed by platform. Applied on Instagram, Facebook, LinkedIn, TikTok (frame only), Pinterest and YouTube Shorts (channel-dependent, see below). The literal value `null` removes it on update. See [Creating reels](https://docs.omnisocials.com/creating-reels.md) | | `--threads-location-id ` | Threads place tag, rolling out (find ids with [`locations:search --platform threads`](https://docs.omnisocials.com/cli/platforms.md#location-search); the literal value `null` clears the tag on update) | ## Platform-specific flags ### Instagram | Flag | Description | |------|-------------| | `--instagram-share-to-feed` | Also share the reel to the feed | | `--instagram-cover-url` | Custom reel thumbnail URL | | `--instagram-thumbnail-type` | `from-video` or `from-library` | | `--instagram-thumb-offset` | Video timestamp in ms (for `from-video`) | | `--instagram-audio-id` | Licensed music for reels (find ids with [`audio:search`](https://docs.omnisocials.com/cli/platforms.md#audio-search)) | | `--instagram-audio-volume` | Music volume 0-100 | | `--instagram-video-volume` | Original audio volume 0-100 (0 = music only) | | `--instagram-first-comment` | First comment (not for stories) | | `--instagram-trial-reel` | Publish as a trial reel (roughly 1,000+ followers required) | | `--instagram-trial-graduation-strategy` | `MANUAL` or `SS_PERFORMANCE` | ### YouTube | Flag | Description | |------|-------------| | `--youtube-title` | Video title | | `--youtube-privacy` | `public`, `private`, or `unlisted` | | `--youtube-tags` | Comma-separated tags | | `--youtube-category-id` | Category ID | | `--youtube-made-for-kids` | Mark as made for kids | | `--youtube-notify-subscribers` | Notify subscribers on publish | | `--youtube-contains-synthetic-media` | Mark as AI-generated | | `--youtube-first-comment` | First comment (video must allow comments) | A custom thumbnail (`--video-cover-json`) shows on a Short only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first). Other channels show a frame from the video. ### TikTok | Flag | Description | |------|-------------| | `--tiktok-privacy` | `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY` | | `--tiktok-disable-comment` | Disable comments | | `--tiktok-disable-duet` | Disable duets | | `--tiktok-disable-stitch` | Disable stitches | | `--tiktok-video-cover-timestamp-ms` | Cover frame timestamp in ms | | `--tiktok-is-aigc` | Mark as AI-generated content | | `--tiktok-brand-content-toggle` | Paid partnership disclosure | | `--tiktok-brand-organic-toggle` | Own-brand disclosure | | `--tiktok-auto-add-music` | Auto-add music to photo carousels | | `--tiktok-title` | Title of a photo carousel (max 90 characters; ignored on video) | | `--tiktok-first-comment` | First comment on the video (max 150 characters; needs comments enabled on the TikTok channel, and the video must be public) | ### Pinterest | Flag | Description | |------|-------------| | `--pinterest-board-id` | Board ID (required for Pinterest; find it with `accounts:get`) | | `--pinterest-title` | Pin title | | `--pinterest-link` | Destination URL | | `--pinterest-video-cover` | Cover image URL for video pins | | `--pinterest-alt-text` | Accessibility alt text | | `--pinterest-product-tags` | Products to tag on the Pin: comma-separated Pin IDs or Pin links of your own product Pins, max 24 (find ids with [`pinterest:products`](https://docs.omnisocials.com/cli/platforms.md#pinterest-products)) | Product tags are added right after the Pin is published. Pinterest accepts a product only when its Pin is public, belongs to the same Pinterest account and links to a website that account claimed. A product that Pinterest refuses never fails the post. After publishing, `posts:list` prints the outcome as `Pinterest products: 1 of 2 tagged (skipped: PIN_IS_PRIVATE)`, and `posts:get` returns it as `pinterest.product_tags_result`. On `posts:update` the Pinterest flags replace the stored Pinterest options as one object, so pass them all again; leaving `--pinterest-product-tags` out removes the tags. ### Other platforms | Flag | Description | |------|-------------| | `--facebook-first-comment` | First comment on the page post | | `--linkedin-first-comment` | First comment on a personal profile post | | `--linkedin-page-first-comment` | First comment on a company page post | | `--linkedin-carousel-as-images` | `true` = publish 2+ images as a plain gallery instead of the default document carousel (profile post) | | `--linkedin-page-carousel-as-images` | Same for the company page post | | `--linkedin-document-source` | `original_pdf` (default) or `slides`. When the images are the unchanged pages of one uploaded PDF, the default sends that original file to LinkedIn; `slides` rebuilds the document from the slide images (profile post) | | `--linkedin-page-document-source` | Same for the company page post | | `--x-reply-settings` | `following` or `mentionedUsers` (empty = everyone) | | `--google-business-cta-action` | Google Business button: `LEARN_MORE`, `BOOK`, `ORDER`, `SHOP`, `SIGN_UP`, or `CALL` | | `--google-business-cta-url` | Button link, `http(s)://` (required for every action except `CALL`) | | `--google-business-topic-type` | `STANDARD` (default), `EVENT`, or `OFFER` | | `--google-business-json` | The full `google_business` object, for `EVENT` and `OFFER` posts (an offer needs `event.title` and `event.schedule.startDate` too, see [Google Business](https://docs.omnisocials.com/platforms/google-business.md#offer-posts)) | ### Telegram | Flag | Description | |------|-------------| | `--telegram-silent` | Send without a notification sound | | `--telegram-link-preview false` | Hide the link preview of a text-only post (shown by default) | | `--telegram-pin` | Pin the post in the chat (needs the bot's Edit messages right in a channel, Pin messages in a group) | | `--telegram-protect` | Stop forwarding and saving of the post | | `--telegram-spoiler` | Blur the photos and videos until someone taps them | | `--telegram-caption-above` | Show the text above the media | | `--telegram-buttons-json ''` | Up to 3 link buttons: `[{"text":"Shop now","url":"https://..."}]`. Not on an album or a poll | | `--telegram-first-comment` | A silent reply under the post (max 4,096 characters) | | `--telegram-json ''` | The full `telegram` object, merged over the flags | See [Telegram](https://docs.omnisocials.com/platforms/telegram.md) for the rules. ### Polls Each flag takes one JSON object, and the post text is the question (LinkedIn polls carry their own question). A poll and media do not go together: X, Threads, Mastodon and Telegram refuse the request, and on LinkedIn the poll wins over the media. On a thread the poll goes on the first part. On `posts:update`, the literal value `null` removes the poll. | Flag | Description | |------|-------------| | `--linkedin-poll-json ''` | LinkedIn polls, independent per channel: `{"linkedin":{"question":"...","options":["...","..."],"duration":"SEVEN_DAYS"},"linkedin_page":{...}}`. Question max 140 characters, 2 to 4 options of up to 30, duration `ONE_DAY`, `THREE_DAYS`, `SEVEN_DAYS` or `FOURTEEN_DAYS` | | `--x-poll-json ''` | `{"options":["A","B"],"duration_minutes":1440}`: 2 to 4 options of up to 25 characters, 5 to 10080 minutes (default 1440) | | `--threads-poll-json ''` | `{"options":["A","B"]}`: 2 to 4 options of 1 to 25 characters; Threads closes the poll after 24 hours. Text-only posts | | `--mastodon-poll-json ''` | `{"options":["A","B"],"expires_in":86400,"multiple":false,"hide_totals":false}`, within the instance's limits (`expires_in` defaults to 86400) | | `--telegram-poll-json ''` | `{"options":["A","B"],"open_period":86400,"multiple":false}`: 2 to 12 options of up to 100 characters, 5 to 2628000 seconds (leave it out to keep the poll open); the question is max 300 characters | See [Polls](https://docs.omnisocials.com/creating-posts.md#polls) for the rules. ### Comment to DM (Instagram) These flags attach a comment-to-DM automation to the post: whoever comments one of the keywords gets a DM, once per person. Instagram must be in `--channels`, and the key needs the `automations:write` scope. | Flag | Description | |------|-------------| | `--dm-keywords ` | The keywords | | `--dm-message ""` | The DM to send (max 1000 characters) | | `--dm-reply ""` | Public reply under the comment; repeat the flag (or pass a comma list) for up to 10 variations | | `--dm-match ` | `word` (default): the keyword on its own; `contains`: anywhere; `any`: every comment | | `--dm-max-sends ` | Stop after this many DMs | | `--dm-stop-at ` | Stop at this time | ## Examples Schedule an image post to Instagram and LinkedIn: ```bash omnisocials posts:create \ --text "Launch day! Here is what we built." \ --channels instagram,linkedin \ --media-urls https://example.com/launch.jpg \ --schedule 2026-08-01T09:00:00Z ``` Publish an X thread right away: ```bash omnisocials posts:create-and-publish \ --channels x \ --text "How we cut our posting time by 90%" \ --x-thread "How we cut our posting time by 90% || Step 1: stop copy-pasting between tabs || Step 2: automate the boring parts" ``` Publish a reel with licensed music: ```bash omnisocials audio:search "summer vibes" omnisocials posts:create-and-publish \ --type reel \ --channels instagram \ --text "POV: your content calendar fills itself" \ --media-urls https://example.com/reel.mp4 \ --instagram-audio-id 123456789 \ --instagram-video-volume 0 ``` Tag products on a Pinterest Pin: ```bash omnisocials pinterest:products omnisocials posts:create \ --text "Autumn outfit" \ --channels pinterest \ --media-urls https://example.com/outfit.jpg \ --pinterest-board-id 1234567890 \ --pinterest-product-tags 813744226420795884,813744226420795885 ``` Find out why a post was rejected, or who it waits for: ```bash omnisocials posts:list --status rejected omnisocials posts:approval 123456 ``` ```text Approval review for post 123456 ──────────────────────────────────────── Status: rejected Workflow: Content approval (ID: 42) Requested by: Alex at 2026-10-01T09:00:00.000Z Rejected by: Jordan on step 2 at 2026-10-01T14:30:00.000Z Reason: The image does not match the caption Steps Step 1: Team review [any one approves]: approved Sam: approved at 2026-10-01T10:15:00.000Z Step 2: Client approval [all must approve]: rejected Jordan: rejected at 2026-10-01T14:30:00.000Z ("The image does not match the caption") Comments (1, oldest first) 2026-10-01T14:28:00.000Z Jordan [instagram]: Please use the new logo ``` While a post is still in review, the same command prints a `Waiting on:` line with the current step and the approvers who still have to decide. Ask your X and Threads followers a question: ```bash omnisocials posts:create \ --text "Which feature should we build next?" \ --channels x,threads \ --x-poll-json '{"options":["Polls","Reports","Ads"],"duration_minutes":1440}' \ --threads-poll-json '{"options":["Polls","Reports","Ads"]}' ``` Check what went out recently, straight from the platforms: ```bash omnisocials posts:recent-platform --platforms instagram,tiktok --limit 10 ``` --- ## Document: MCP server for social media posting Connect AI assistants to OmniSocials over MCP: a hosted remote endpoint (OAuth or API key) plus an npm package for stdio clients like Claude Desktop and Cursor. URL: https://docs.omnisocials.com/mcp-server # MCP server for social media posting OmniSocials provides an MCP (Model Context Protocol) server so AI assistants and tools can manage your social media directly. The server is available in two forms: 1. **Remote MCP endpoint** at `https://mcp.omnisocials.com`, for clients that speak MCP over HTTP (claude.ai, ChatGPT, Notion, OpenClaw) 2. **npm package** [`@omnisocials/mcp-server`](https://www.npmjs.com/package/@omnisocials/mcp-server), for clients that spawn local MCP servers over stdio (Claude Desktop, Cursor, Windsurf, Claude Code, OpenClaw) Both expose the same tools and use your OmniSocials API key. Pick whichever your client supports. ## Dedicated client guides These clients have their own walkthroughs with screenshots and exact config paths: - [Claude (claude.ai, Desktop, Code)](https://docs.omnisocials.com/integrations/claude.md) - [ChatGPT](https://docs.omnisocials.com/integrations/chatgpt.md) - [Grok](https://docs.omnisocials.com/integrations/grok.md) - [Muse](https://docs.omnisocials.com/integrations/muse.md) - [Notion Agents](https://docs.omnisocials.com/integrations/notion.md) - [OpenClaw](https://docs.omnisocials.com/integrations/openclaw.md) For every other MCP-compatible client, use one of the setups below. ## Remote MCP URL (hosted) The remote endpoint supports two authentication modes: **OAuth (preferred).** Clients with a connector/app flow (ChatGPT, claude.ai, grok.com) add `https://mcp.omnisocials.com` as the server URL and sign in when prompted; no key handling needed. See the [ChatGPT guide](https://docs.omnisocials.com/integrations/chatgpt.md) for the walkthrough. **API key in the URL.** For clients without an OAuth flow: ```text https://mcp.omnisocials.com?API_KEY=omsk_live_ ``` Or, for clients that support Bearer token authentication, use the URL without the query parameter and pass the key as `Authorization: Bearer omsk_live_`. ## npm package (local stdio) For clients that spawn MCP servers as local subprocesses: ```bash npx -y @omnisocials/mcp-server ``` Pass the API key via environment variable: ```bash export OMNISOCIALS_API_KEY=omsk_live_ ``` The generic MCP config shape most clients accept: ```json { "mcpServers": { "omnisocials": { "command": "npx", "args": ["-y", "@omnisocials/mcp-server"], "env": { "OMNISOCIALS_API_KEY": "omsk_live_" } } } } ``` Config file location varies by client: | Client | Config path | |--------|-------------| | Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` | | Claude Desktop (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` | | Cursor | `.cursor/mcp.json` (in the project) or `~/.cursor/mcp.json` (global) | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | | Claude Code | Managed via `claude mcp add` command | ## Available tools The MCP server exposes the public API as structured tools: | Category | Tools | |----------|-------| | Posts | `list_posts`, `get_post`, `create_post`, `create_and_publish_post`, `update_post`, `delete_post`, `publish_post`, `retry_post`, `list_approval_workflows`, `approve_post`, `reject_post`, `get_post_approval`, `get_recent_platform_posts`, `search_locations`, `list_pinterest_products`, `search_instagram_audio` | | Media | `list_media`, `upload_media`, `update_media`, `check_media_compatibility` | | Folders | `list_folders`, `create_folder` | | Hashtag Sets | `list_hashtag_sets`, `create_hashtag_set`, `update_hashtag_set`, `delete_hashtag_set` | | Automations | `list_automations`, `get_automation`, `create_automation`, `update_automation`, `delete_automation`, `get_automation_activity` (comment-to-DM on Instagram; `create_post` also takes `comment_dm_automation`) | | Accounts | `list_accounts`, `get_account` | | Inbox | `list_inbox_conversations`, `get_inbox_conversation`, `get_next_unanswered`, `mark_inbox_read`, `reply_to_inbox`, `hide_inbox_comment`, `delete_inbox_comment` | | Analytics | `get_post_analytics`, `get_posts_analytics`, `get_analytics_overview`, `get_account_analytics`, `get_best_times` | | Webhooks | `list_webhooks`, `get_webhook`, `create_webhook`, `update_webhook`, `delete_webhook`, `rotate_webhook_secret` | | Workspaces | `list_workspaces`, `switch_workspace` | Each tool accepts the same parameters as the corresponding REST endpoint. `create_post` and `update_post` support the full post feature set, including per-platform automatic first comments and PDF-to-carousel uploads. The MCP server handles validation, retries, and response shaping so the assistant gets clean structured output. For the Social Inbox, `get_next_unanswered` serves the next DM or comment that needs an answer together with the whole thread and the post it is on, and `reply_to_inbox` takes `include_next: true` to return the following item in the same call, so an assistant can work through the inbox in a loop. `list_inbox_conversations` accepts an `unanswered` filter, `get_inbox_conversation` shows the post (caption, link, image) and each message's `#id`, `hide_inbox_comment` hides or unhides Instagram, Facebook, TikTok, YouTube, and Threads comments, and `delete_inbox_comment` deletes Instagram, Facebook, and TikTok comments together with their replies (irreversible, the assistant confirms first). See [Using it from an AI assistant](https://docs.omnisocials.com/inbox.md#using-it-from-an-ai-assistant). ## Scopes The MCP server respects your API key's scopes. A key without `posts:write` will see `create_post` in the tool list but calling it will return `403 Forbidden`. Give the key exactly the scopes it needs for the task, and no more. ## Alternative: Agent Skills If your AI tool does not support MCP (e.g. GitHub Copilot, Codex, Gemini CLI), use [Agent Skills](https://docs.omnisocials.com/integrations/agent-skills.md) instead. Agent Skills work via a CLI + markdown instruction file and support any tool that can run shell commands. --- ## Document: AI and automation integrations Connect OmniSocials to Claude, ChatGPT, Grok, n8n, Make, Zapier, and agent frameworks like LangChain and CrewAI via MCP, agent skills, or the REST API. URL: https://docs.omnisocials.com/integrations # AI and automation integrations OmniSocials connects to AI assistants and coding tools through two protocols: MCP (Model Context Protocol) and Agent Skills. Both use your OmniSocials API key and expose the same capabilities: create posts, upload media, list accounts, read analytics, and manage webhooks. Automation platforms (n8n, Make, Zapier) call the REST API with the same key, and agent frameworks connect to the hosted MCP server from your own code. Pick the integration that matches the tool you use: ## MCP vs Agent Skills Both approaches give an AI agent the same capabilities. They differ in how they connect. | | MCP Server | Agent Skills | |---|---|---| | **Works with** | MCP-compatible clients (Claude, ChatGPT, Cursor, Notion, etc.) | Any AI tool that can run shell commands | | **Transport** | Structured protocol (stdio or HTTP) | CLI + SKILL.md instruction file | | **Install** | `npx -y @omnisocials/mcp-server` or remote URL | `npx skills add OmniSocials/omnisocials-agent-skills` | | **Best for** | Tools with native MCP support | Codex, Copilot, Gemini CLI, and other non-MCP tools | Both use the same API key and expose the same set of tools. You can run both in parallel with no conflict. ## Coming soon
Raycast
Raycast
Draft and schedule posts from Raycast
Slack
Slack
Real-time notifications and updates
--- ## Document: Connect AI agent frameworks to the OmniSocials MCP server Give agents built with the OpenAI Agents SDK, Vercel AI SDK, LangChain, Mastra, or CrewAI the hosted OmniSocials MCP server. Code samples and API key auth. URL: https://docs.omnisocials.com/integrations/agent-frameworks # Connect AI agent frameworks to the OmniSocials MCP server Agents you build in code can use the hosted OmniSocials MCP server, the same server that claude.ai and ChatGPT connect to. The agent gets tools like `list_accounts`, `create_post`, `upload_media`, `get_best_times`, and the inbox and analytics tools. See [MCP server](https://docs.omnisocials.com/mcp-server.md) for the full list. This page has a minimal working sample for each framework: - [OpenAI Agents SDK](#openai-agents-sdk-python) (Python) - [Vercel AI SDK](#vercel-ai-sdk-typescript) (TypeScript) - [LangChain and LangGraph](#langchain-and-langgraph-python) (Python) - [Mastra](#mastra-typescript) (TypeScript) - [CrewAI](#crewai-python) (Python) ## Connection details | Setting | Value | |---------|-------| | URL | `https://mcp.omnisocials.com` | | Transport | Streamable HTTP. There is no SSE endpoint. | | Authentication | `Authorization: Bearer omsk_live_` | Use the URL as it is. Do not add `/mcp` or `/sse` to it. **How the key works.** The server reads any bearer token that starts with `omsk_` as an OmniSocials API key. That makes the `Authorization` header the right choice for server-side code. The `?API_KEY=` query form on the [MCP server](https://docs.omnisocials.com/mcp-server.md) page also works, but a header keeps the key out of URLs and request logs. OAuth is meant for apps where a person signs in, see [OAuth for your users](#oauth-for-your-users). **Protocol version.** The server uses the MCP initialize handshake. Clients built for the newer `2026-07-28` revision, such as current FastMCP, Mastra, and the AI SDK, probe for it first and fall back to the handshake on their own. Leave their protocol setting on its default. A client pinned to `2026-07-28` only will not connect. ## Before you start 1. Create an API key in **Settings → API**. For an agent that drafts and schedules posts, give it `posts:write`, `media:write`, and `accounts:read`. Add `analytics:read` if it should read results. See [Authentication](https://docs.omnisocials.com/authentication.md). 2. Store the key in an environment variable or your secrets manager. Never commit it. ```bash export OMNISOCIALS_API_KEY="omsk_live_" ``` The samples below read `OMNISOCIALS_API_KEY` and also need your model provider's key, as each framework documents. ## OpenAI Agents SDK (Python) The Agents SDK connects to remote servers with `MCPServerStreamableHttp`. ```bash pip install openai-agents ``` ```python import asyncio import os from agents import Agent, Runner from agents.mcp import MCPServerStreamableHttp async def main() -> None: async with MCPServerStreamableHttp( name="OmniSocials", params={ "url": "https://mcp.omnisocials.com", "headers": {"Authorization": f"Bearer {os.environ['OMNISOCIALS_API_KEY']}"}, "timeout": 30, }, cache_tools_list=True, client_session_timeout_seconds=60, ) as server: agent = Agent( name="Social media assistant", instructions=( "Use the OmniSocials tools. Call list_accounts before you create a post. " "Create drafts unless the user asks you to schedule or publish." ), mcp_servers=[server], ) result = await Runner.run(agent, "Draft a LinkedIn post about our new pricing page.") print(result.final_output) asyncio.run(main()) ``` `client_session_timeout_seconds` defaults to 5 seconds. Raise it as above, because uploading media from a URL can take longer. ## Vercel AI SDK (TypeScript) The AI SDK's MCP client is `createMCPClient` from `@ai-sdk/mcp`. ```bash npm install ai @ai-sdk/mcp ``` ```typescript import { createMCPClient, type MCPClient } from '@ai-sdk/mcp'; import { generateText, isStepCount } from 'ai'; let mcpClient: MCPClient | undefined; try { mcpClient = await createMCPClient({ transport: { type: 'http', url: 'https://mcp.omnisocials.com', headers: { Authorization: `Bearer ${process.env.OMNISOCIALS_API_KEY}` }, }, }); const tools = await mcpClient.tools(); const { text } = await generateText({ model: 'anthropic/claude-sonnet-5.5', tools, stopWhen: isStepCount(5), prompt: 'List my connected social accounts, then draft a LinkedIn post about our new pricing page.', }); console.log(text); } finally { await mcpClient?.close(); } ``` `generateText` runs one step by default. `stopWhen: isStepCount(5)` lets the model call a tool, read the result, and continue. With `streamText`, close the client in `onEnd` instead. ## LangChain and LangGraph (Python) LangChain 1.4 ships MCP support in `langchain.mcp` (beta). It replaces the standalone `langchain-mcp-adapters` package, which is no longer actively maintained. Both work with OmniSocials. Use the first tab for new projects. ```python title="langchain.mcp" # pip install "langchain[mcp]" import asyncio import os from fastmcp.client import Client from langchain.agents import create_agent from langchain.mcp import MCPAdapter async def main(): client = Client("https://mcp.omnisocials.com", auth=os.environ["OMNISOCIALS_API_KEY"]) async with MCPAdapter(client) as adapter: tools = await adapter.list_tools() agent = create_agent("claude-sonnet-5", tools) result = await agent.ainvoke( {"messages": [{"role": "user", "content": "List my connected social accounts."}]} ) print(result["messages"][-1].content) asyncio.run(main()) ``` ```python title="langchain-mcp-adapters" # pip install langchain-mcp-adapters import asyncio import os from langchain.agents import create_agent from langchain_mcp_adapters.client import MultiServerMCPClient async def main(): client = MultiServerMCPClient( { "omnisocials": { "transport": "http", "url": "https://mcp.omnisocials.com", "headers": {"Authorization": f"Bearer {os.environ['OMNISOCIALS_API_KEY']}"}, } } ) tools = await client.get_tools() agent = create_agent("claude-sonnet-5", tools) result = await agent.ainvoke({"messages": "List my connected social accounts."}) print(result["messages"][-1].content) asyncio.run(main()) ``` In `langchain.mcp`, the key goes on the FastMCP `Client` as `auth`. A plain string there is sent as a bearer token. `create_agent` returns a LangGraph graph. In a graph you build yourself, give the same `tools` list to your tool node. ## Mastra (TypeScript) Mastra connects with `MCPClient` from `@mastra/mcp`. Version 2 of `@mastra/mcp` needs `@mastra/core` 1.76 or a later 1.x release. ```bash npm install @mastra/mcp@latest @mastra/core ``` ```typescript import { Agent } from '@mastra/core/agent'; import { MCPClient } from '@mastra/mcp'; export const omnisocials = new MCPClient({ id: 'omnisocials', servers: { omnisocials: { url: new URL('https://mcp.omnisocials.com'), requestInit: { headers: { Authorization: `Bearer ${process.env.OMNISOCIALS_API_KEY}` }, }, }, }, }); export const socialAgent = new Agent({ id: 'social-agent', name: 'Social media assistant', instructions: 'Use the OmniSocials tools. List the accounts before you create a post.', model: 'openai/gpt-5.6-sol', tools: await omnisocials.listTools(), }); const response = await socialAgent.generate('List my connected social accounts.'); console.log(response.text); ``` `listTools()` prefixes every tool with the server key, so the agent sees `omnisocials_list_accounts` and `omnisocials_create_post`. If each of your users has their own OmniSocials key, build an `MCPClient` per request and pass `await client.listToolsets()` as `toolsets` to `generate()` instead of setting `tools` on the agent. Call `disconnect()` on that client when the request is done. ## CrewAI (Python) CrewAI agents take MCP servers in their `mcps` field. `MCPServerHTTP` from `crewai.mcp` speaks Streamable HTTP. ```bash uv add crewai mcp ``` ```python import os from crewai import Agent, Crew, Task from crewai.mcp import MCPServerHTTP social_agent = Agent( role="Social media manager", goal="Plan and schedule posts on the company's social accounts", backstory="You run the company's social media through OmniSocials.", mcps=[ MCPServerHTTP( url="https://mcp.omnisocials.com", headers={"Authorization": f"Bearer {os.environ['OMNISOCIALS_API_KEY']}"}, streamable=True, ), ], ) task = Task( description="List the connected social accounts, then draft a LinkedIn post about our new pricing page.", expected_output="The connected accounts and the ID of the draft post.", agent=social_agent, ) crew = Crew(agents=[social_agent], tasks=[task]) print(crew.kickoff()) ``` ## Keep agents in bounds An agent with `posts:write` can publish to your real accounts. There is no test mode. - Give each agent its own key with only the scopes it needs. A missing scope makes the tool return `403`. - Ask the agent to create drafts, which a person reviews and schedules in the dashboard. Or have it pass an `approval_workflow_id` to `create_post`, so the post waits for an approver. `list_approval_workflows` lists the workflows. - Gate the tools that publish or delete, such as `publish_post`, `create_and_publish_post`, and `delete_post`. The frameworks have hooks for this: `tool_filter` and `require_approval` in the OpenAI Agents SDK, `toolApproval` in the AI SDK, `requireToolApproval` in Mastra, and `@wrap_tool_call` middleware in LangChain. ## OAuth for your users If people sign in to your app with their own OmniSocials account, use OAuth instead of a shared key. The server supports OAuth with dynamic client registration, and the user picks which workspaces your app may use. Each framework has its own OAuth hook: `auth="oauth"` on the FastMCP `Client`, `authProvider` on the AI SDK transport, and `authProvider` with `MCPOAuthClientProvider` in Mastra. Follow the framework's OAuth guide with `https://mcp.omnisocials.com` as the server URL. ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | `401` on connect | The key is wrong or revoked, or the header lacks `Bearer `. | Check the `Authorization` header value. | | `402` on connect | The organisation has no active plan. | Check billing in **Settings → Organisation**. | | `404` with "Session expired" | The server dropped the session, for example after a restart. | Reconnect. Most clients send a new initialize on their own. | | A tool call returns `403` | The key misses that tool's scope. | Create a new key with the scope. Scopes cannot be edited. | | The client cannot connect at all | It uses the old SSE transport, or `/mcp` or `/sse` was added to the URL. | Use the Streamable HTTP class shown above and the bare URL. | --- ## Document: Post to social media with Agent Skills Install the OmniSocials agent skill and CLI for Claude Code, Codex, and other coding agents. URL: https://docs.omnisocials.com/integrations/agent-skills # Post to social media with Agent Skills Agent Skills is a CLI + markdown approach to giving AI coding tools the ability to manage your OmniSocials workspace. Unlike MCP, Agent Skills work with **any** AI tool that can read a markdown file and run shell commands. That includes Cursor, Codex, GitHub Copilot, Gemini CLI, Windsurf, and Claude Code. ## Install ```bash npx skills add OmniSocials/omnisocials-agent-skills ``` This installs the skill into your agent's skills directory: a `SKILL.md` instruction file plus the CLI (`/scripts/omnisocials.js`). Your AI agent reads the SKILL.md to learn how to use the CLI. ## Set your API key ```bash ./scripts/omnisocials.js setup ``` The setup command prompts for your API key and stores it locally. Alternatively, export the env var directly: ```bash export OMNISOCIALS_API_KEY=omsk_live_ ``` ## What your agent can do Once installed, you can ask any compatible agent: - "Show my connected social accounts" - "Create a LinkedIn post announcing our new feature" - "Schedule an Instagram reel for tomorrow at 9am" - "How are my posts performing this week?" - "Upload this image and create a Pinterest pin on the Spring Inspiration board" The agent reads SKILL.md, generates the appropriate CLI command, and executes it. The CLI wraps the OmniSocials REST API with more than 40 commands covering posts, media, folders, hashtag sets, locations, audio search, accounts, analytics, the social inbox, and webhooks. ## How it differs from the MCP server | | Agent Skills | MCP Server | |---|---|---| | **Works with** | Any AI coding tool that can read markdown and run commands | MCP-compatible clients only | | **Install** | `npx skills add OmniSocials/omnisocials-agent-skills` | `npx -y @omnisocials/mcp-server` | | **Protocol** | CLI commands via bash | MCP structured tool calls | | **Best for** | Broad compatibility across non-MCP tools | Deep native integration with MCP clients | Both use the same API and the same API key. You can use both simultaneously in the same tool if you want. ## Supported tools (via Agent Skills) - Claude Code - Cursor - Windsurf - GitHub Copilot - OpenAI Codex - Google Gemini CLI - JetBrains AI Assistant - Any other AI tool that can read a markdown file and execute shell commands ## Source and documentation Full documentation, examples, and the CLI source live on GitHub: [OmniSocials/omnisocials-agent-skills](https://github.com/OmniSocials/omnisocials-agent-skills) The repo includes a generated SKILL.md for every supported client and examples of common tasks. --- ## Document: Post to social media from ChatGPT Connect ChatGPT to OmniSocials as a custom MCP plugin with OAuth and manage your social media from a chat. No Developer mode needed. URL: https://docs.omnisocials.com/integrations/chatgpt # Post to social media from ChatGPT OmniSocials works in ChatGPT as a custom MCP plugin. ChatGPT talks to the hosted OmniSocials MCP server directly, so there is nothing to install locally. You don't need Developer mode: since July 2026, ChatGPT adds custom MCP servers from Plugins. - **ChatGPT Plus and Pro:** add it yourself. - **Business, Enterprise and Edu:** an admin or owner adds it for the workspace. ## Setup 1. **Add a custom MCP server.** On ChatGPT on the web, open [Plugins](https://chatgpt.com/plugins), click **+** and choose **Add custom MCP server**. Use these values: - **Name:** `OmniSocials` - **Connection, Server URL:** `https://mcp.omnisocials.com` - **Authentication:** `OAuth` - Tick **I understand and want to continue**, then click **Create as a plugin**. 2. **Sign in and authorize.** OmniSocials opens: pick which workspaces ChatGPT can see, then click **Allow access**. 3. **Use it in a chat.** Type **@**, pick **OmniSocials** and ask: > Can you list my connected accounts on OmniSocials? If ChatGPT responds with your accounts, the connection is live. ## Working with images and videos **Use ChatGPT on web or desktop for the best experience.** You can drag-and-drop images or videos directly into the chat and they'll flow straight into your OmniSocials posts. No extra steps, no separate hosting. Other ways to add media: - **Already-hosted media.** Paste any public image or video URL into the chat. - **AI-generated images.** Ask ChatGPT to create the image with DALL·E and it'll be used automatically. ### ChatGPT mobile Attached files on ChatGPT mobile don't pass through correctly yet (known [OpenAI bug](https://github.com/openai/openai-apps-sdk-examples/issues/185)). Switch to web or desktop if you can. For images, you can also upload to [imgbb.com](https://imgbb.com) (no account needed) and paste the returned link into the chat. ## What you can ask ChatGPT can do everything the OmniSocials API supports: - "Create a LinkedIn post about our new feature and schedule it for tomorrow at 9am" - "Schedule this video as a Pinterest pin with a matching title and caption" (with a video attached) - "Show me the top performing post from last week" - "What webhooks do I have set up?" - "Work through my unanswered Instagram comments one at a time and draft a reply for each" (ChatGPT pulls the next unanswered comment with the post it is on, drafts, sends, and moves to the next one) - "Hide that spam comment on yesterday's Facebook post" If you have multiple workspaces, ChatGPT will ask which one to use the first time, and remember the pick for the rest of the conversation. ## See your post as a card When ChatGPT makes, changes or publishes a post, the post shows up in the chat as a card: the post the way each platform will show it, the state of every channel, and a button that opens it in OmniSocials. **Show all channels** opens the card full screen with every channel side by side. Added OmniSocials before October 2026? ChatGPT keeps the tool list from the day you added the app, so open [chatgpt.com/plugins](https://chatgpt.com/plugins), open OmniSocials and click **Refresh** once, then start a new chat. Every answer about a post also ends with an "Open in OmniSocials" link. ## Limitations - ChatGPT mobile can't yet receive attached files via the Apps SDK. Web and desktop work fine. - Plugins you add stay installed across sessions. - ChatGPT saves the app's tool list when you add it and does not pick up new tools on its own. After an OmniSocials update, open [chatgpt.com/plugins](https://chatgpt.com/plugins), open the OmniSocials app and click **Refresh** (if there is no Refresh button, delete and re-create the app). Then start a new chat. The mobile apps use the same connection, so they pick up the change too. --- ## Document: Post to social media from Claude Connect Claude to OmniSocials: the claude.ai connector, Claude Desktop, and Claude Code via MCP. Post, schedule, and read analytics from a chat. URL: https://docs.omnisocials.com/integrations/claude # Post to social media from Claude OmniSocials integrates with every flavor of Claude: claude.ai (web), Claude Desktop, and Claude Code. All three use the same underlying MCP server. The only difference is how you point Claude at it. ## claude.ai, Claude Desktop and the phone app (connector) Easiest option. Claude talks to the hosted OmniSocials endpoint and signs in with OAuth, so there is no key to copy and nothing to install. Claude, Claude Desktop and the Claude phone apps share one connector list; adding a connector works in a browser. 1. Open [Add OmniSocials to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=OmniSocials&connectorUrl=https%3A%2F%2Fmcp.omnisocials.com). The connector form opens with the name and address filled in. If it is empty, go to **Customize**, then **Connectors**, click **Add custom connector** and paste this address: ```text https://mcp.omnisocials.com ``` 2. Click **Add**, then **Connect**. Claude sends you to OmniSocials to sign in and pick which workspaces Claude can see. Click **Allow**. In any new Claude chat, OmniSocials tools are now available. Ask Claude to list your connected accounts to verify. On a Team or Enterprise plan an Owner adds the connector under Organization settings, then Connectors; members then click Connect on it. ## Claude Code (remote connector) One command, then a sign-in from the session: ```bash claude mcp add --transport http --scope user omnisocials https://mcp.omnisocials.com ``` Start Claude Code, type `/mcp`, pick **omnisocials** and finish the sign-in in your browser. `--scope user` makes the connector available in every project. ## API key instead of OAuth If you prefer an API key (for example on a shared machine, or in a client that has no OAuth flow), create one in OmniSocials under **Settings, then Organisation, then Integrations** and use the address with the key in it: ```text https://mcp.omnisocials.com?API_KEY=omsk_live_ ``` ## Claude Desktop (local npm package, API key) Runs the MCP server locally as a subprocess. Use this if you prefer to keep your API key on-device rather than sending it with every request. Open `claude_desktop_config.json` and add: ```json { "mcpServers": { "omnisocials": { "command": "npx", "args": ["-y", "@omnisocials/mcp-server"], "env": { "OMNISOCIALS_API_KEY": "omsk_live_" } } } } ``` The config file lives at: - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` Restart Claude Desktop to pick up the new config. ## Claude Code (local npm package, API key) If you want the server to run locally instead of the remote connector: ```bash claude mcp add omnisocials -- npx -y @omnisocials/mcp-server ``` Then export your API key so the MCP subprocess can read it: ```bash export OMNISOCIALS_API_KEY=omsk_live_ ``` Put the `export` line in your `~/.zshrc` or `~/.bashrc` so it persists across shell sessions. ## Verifying the connection In any Claude interface, ask: > Show my connected social accounts on OmniSocials Claude should call `list_accounts` and return the accounts in your workspace. If it responds with an authentication or connection error, double-check the API key and restart the client. ## Capabilities With OmniSocials connected, you can ask Claude to: - List connected accounts, media library items, or scheduled posts - Create drafts or scheduled posts with per-platform content - Upload media from a URL and attach to a post - Query analytics for a post, account, or time period - Work through the Social Inbox: `get_next_unanswered` hands Claude the next DM or comment that needs an answer, with the thread and the post it is on, and `reply_to_inbox` with `include_next: true` chains to the following one until nothing is waiting - Moderate comments with `hide_inbox_comment` (Instagram, Facebook, TikTok, YouTube, Threads) and `delete_inbox_comment` (Instagram, Facebook, TikTok; Claude confirms before deleting) - Manage webhooks The full set of tools matches the public API. Your API key's scopes determine what Claude can actually do. A read-only key lets Claude list things but not post. ## The post card On claude.ai, Claude Desktop and the phone apps, the connector shows a post as a card in the chat when Claude creates, changes, publishes or reads one. The card shows the post the way each platform will show it, a channel list with each channel's state (the planned time, Posting, Posted with a link to the live post, or Failed with the reason), and a button that opens the post in OmniSocials. "Show all channels" opens every channel side by side. While a post publishes, the card follows it until every channel is done. The first time, Claude asks whether it may show the card. Claude Code and other terminal clients show no card. Their answer ends with the same "Open in OmniSocials" link, and once a post is live, `get_post` lists the link to each live post. --- ## Document: Post to social media from Grok Connect Grok to OmniSocials via the remote MCP connector. Post, schedule, and read analytics from a chat on grok.com. URL: https://docs.omnisocials.com/integrations/grok # Post to social media from Grok Grok (grok.com) supports custom MCP connectors. Add OmniSocials once and every new Grok chat can draft posts, schedule content, and read your analytics. ## Connect via grok.com No install needed. Grok connects to OmniSocials' hosted MCP endpoint with OAuth. Grok has no link that prefills its connector form, so the steps are manual: 1. Open [grok.com/connectors](https://grok.com/connectors). Sign in to Grok if it asks. 2. Click **New Connector**, then **Custom**. 3. Paste this URL as the **MCP server URL** and save: ``` https://mcp.omnisocials.com ``` 4. Click **Connect**. Grok redirects you to OmniSocials to sign in. 5. Tick the workspaces Grok may use, then click **Allow**. You land back in Grok. That's it. All approved workspaces are available in every new Grok chat. Grok registers itself as an OAuth client, so there is no Client ID or secret to enter. ## Verifying the connection In a new Grok chat, ask: > Show my connected social accounts on OmniSocials Grok should call `list_accounts` and return the accounts in your workspace. If it responds with an authentication or connection error, remove the connector and add it again. ## Capabilities With OmniSocials connected, you can ask Grok to: - List connected accounts, media library items, or scheduled posts - Create drafts or scheduled posts with per-platform content - Upload media from a URL and attach it to a post - Query analytics for a post, account, or time period - Manage webhooks The available tools match the public API. The workspaces you approved during OAuth determine what Grok can see and do. --- ## Document: Post to social media from Make Schedule and publish social media posts from a Make scenario with the HTTP app's Make a request module and the OmniSocials REST API. URL: https://docs.omnisocials.com/integrations/make # Post to social media from Make Make (make.com) can create OmniSocials posts with the **HTTP** app and its **Make a request** module. The module calls the REST API, so any trigger in Make can schedule or publish a post: a new row in a sheet, a new item in a feed, a form submission. This guide uses version 4 of the HTTP app, the current one. It keeps the API key in a keychain, outside the module. ## Before you start - An OmniSocials account with at least one social account connected (**Settings → Organisation → Workspaces**). Accounts cannot be connected through the API. - An API key from **Settings → API** with the `posts:write` and `accounts:read` scopes. See [Authentication](https://docs.omnisocials.com/authentication.md). - A Make scenario with a trigger. ## 1. Add the module and the key 1. Add the **HTTP** app to your scenario and choose **Make a request**. 2. In **Authentication type**, select **API key**. 3. In **Credentials**, create a keychain and fill in: | Field | Value | |-------|-------| | **Name** | `OmniSocials API` | | **Key** | `Bearer omsk_live_` | | **API key placement** | In the header | | **API key parameter name** | `Authorization` | 4. Click **Create**. The **Key** must include the `Bearer ` prefix, followed by a space. Make stores the keychain under **Keys** in the left sidebar. Change the key there later and every module that uses it picks up the change. :::caution Do not put the key in the **Headers** or **Query parameters** fields. Make's own docs say to keep credentials in the **Credentials** field, where they live in the keychain instead of the module settings. ::: ## 2. Configure the request | Field | Value | |-------|-------| | **URL** | `https://api.omnisocials.com/v1/posts/create` | | **Method** | `POST` | | **Body content type** | **application/JSON** | | **Body input method** | **JSON string** to start, **Data structure** once you map values (see below) | | **Parse response** | **Yes** | With **JSON string**, paste this into the body: ```json { "content": { "default": "New on the blog: how we plan a month of posts in one hour." }, "accounts": ["linkedin", "x"], "schedule_at": "2026-11-02T09:00:00Z", "media_urls": ["https://example.com/cover.jpg"], "source": "make" } ``` Under **Advanced settings**, set **Return error if HTTP request fails** to **Yes**. A rejected post then stops the scenario instead of passing on an error response as if it worked. Run the module once. A `201` response returns the new post under `data`, with its `id` and a `status` of `scheduled`. Because **Parse response** is on, later modules can map `data.id`. ## 3. Map values from earlier modules **JSON string** sends your text as it is. A mapped caption with a double quote or a line break makes the JSON invalid, and the API answers `400`. Switch **Body input method** to **Data structure** for mapped values. Make escapes the values for you. Create the data structure with Make's generator and give it the sample above, then map your fields into it: - `content` → `default`: the caption from your trigger. - `accounts`: one item per account, such as `linkedin` and `x`. - `schedule_at`: a date in the future. Make turns a date into ISO 8601 text, so `{{addHours(now; 1)}}` works. - `media_urls`: public image or video URLs. ## Body fields The fields you will use most. The full list, including per-platform options, is in the [API reference](https://docs.omnisocials.com/api) and [Creating posts](https://docs.omnisocials.com/creating-posts.md). | Field | Type | What it does | |-------|------|--------------| | `content` | object | Required. `default` is the text for every account. Add a platform key such as `instagram` or `linkedin` to give that platform its own text. | | `accounts` | array of strings | Required. Platform names such as `"instagram"`, or account IDs from `GET /v1/accounts` such as `"844008_instagram"`. | | `schedule_at` | string | ISO 8601 date and time, in the future. The post publishes then. | | `publish_now` | boolean | `true` publishes right away. It wins over `schedule_at`. | | `media_urls` | array of URLs | Public image or video URLs. OmniSocials downloads them. Instagram and TikTok posts need at least one. | | `source` | string | Optional label for where the post came from. | Leave out both `schedule_at` and `publish_now` to save the post as a draft. Send `publish_now` as a boolean, never as the text `"false"`. :::tip To see the account IDs in your workspace, add a second **Make a request** module with the same keychain, method `GET`, and URL `https://api.omnisocials.com/v1/accounts`. ::: ## Troubleshooting | Response | Cause | Fix | |----------|-------|-----| | `401` | The key is wrong or revoked, or the `Bearer ` prefix is missing. | Edit the keychain under **Keys**. | | `403` | The key misses a scope. | Create a new key with the scope. Scopes cannot be edited. | | `400` with `validation_error` | A field is missing or invalid, for example an unknown account, a past `schedule_at`, or invalid JSON. | Read `error.message`, it names the problem. | | `429` | Rate limit. | Wait for `Retry-After` seconds, then retry. | See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md) for every status code. ## Next steps - [Creating posts](https://docs.omnisocials.com/creating-posts.md) covers per-platform text, post types, and updates. - [Media](https://docs.omnisocials.com/media.md) covers uploads, formats, and size limits. - [Webhooks](https://docs.omnisocials.com/webhooks.md) can start a scenario when a post publishes or fails. --- ## Document: Post to social media from Muse Connect Muse, Meta's personal AI agent, to OmniSocials with a custom connector and your API key. Post, schedule, and read analytics from a Muse chat. URL: https://docs.omnisocials.com/integrations/muse # Post to social media from Muse Muse is Meta's personal AI agent (muse.ai on the web, and the Muse apps for iPhone, Android and Mac). Muse builds custom connectors from a chat message: give it the OmniSocials MCP address once, and every Muse chat can draft posts, schedule content, and read your analytics. ## Before you start - Muse signs in to OmniSocials with an API key. Create one under **Settings → Organisation → Integrations → Muse** and copy the key itself (it shows once). You do not need the MCP URL with the key in it. - Custom connectors are available where Muse is: the United States, and Canada on iOS and the web (September 2026). Meta does not review custom connectors. ## Connect 1. Open Muse and start a new chat. 2. Send Muse this message: ```text Create a custom connector for OmniSocials. It is an MCP server over streamable HTTP at https://mcp.omnisocials.com. It signs in with a bearer token: ask me for the API key in a secure prompt. Then list my social accounts and save the connector so I can use it in every chat. ``` 3. Muse asks how the server signs in. Choose the bearer token. Muse opens a secure prompt for the credential: paste your API key there, never in the chat. 4. Muse connects, lists the OmniSocials tools and runs the first call. Ask it to save the connector so you do not repeat the setup in the next chat. Muse sends the key as `Authorization: Bearer omsk_live_` on every call. To give Muse several workspaces, join their keys with a comma, the same as in the MCP URL. ## Verifying the connection In a new Muse chat, ask: > Show my connected social accounts on OmniSocials Muse should call `list_accounts` and return the accounts in your workspace. If it reports an authentication error, create a new key in OmniSocials and hand it to Muse again through the secure prompt. ## Approvals Muse's settings decide what a custom connector may do on its own: read only, ask before every action, or allow. Set OmniSocials to ask first if you want to confirm each post before Muse creates or schedules it. ## Capabilities With OmniSocials connected, you can ask Muse to: - List connected accounts, media library items, or scheduled posts - Create drafts or scheduled posts with per-platform content - Upload media from a URL and attach it to a post - Query analytics for a post, account, or time period - Manage webhooks The available tools match the public API. The key's workspace determines what Muse can see and do. --- ## Document: Post to social media from n8n Schedule and publish social posts from n8n with the HTTP Request node, or give an n8n AI Agent the OmniSocials MCP server as a tool. URL: https://docs.omnisocials.com/integrations/n8n # Post to social media from n8n There are two ways to use OmniSocials from n8n: 1. **HTTP Request node.** Your workflow calls the REST API with fixed fields. Use it when every run does the same thing, for example "new RSS item, schedule a LinkedIn post". 2. **AI Agent with the MCP Client Tool node.** An agent gets the OmniSocials MCP tools and decides what to call. Use it when a model should write the caption, pick the accounts, or choose a time. Both use the same API key and the same n8n credential. ## Before you start - An OmniSocials account with at least one social account connected (**Settings → Organisation → Workspaces**). Accounts cannot be connected through the API. - An API key from **Settings → API**. Give it `posts:write` and `accounts:read`, plus `media:write` if the workflow uploads files. See [Authentication](https://docs.omnisocials.com/authentication.md). - n8n Cloud or a self-hosted n8n instance. ## Store the API key in n8n Create the credential once. Both nodes on this page use it. 1. In n8n, create a new credential and pick the **Bearer Auth** type. 2. In **Bearer Token**, paste your key: `omsk_live_`. 3. Name it something like "OmniSocials API" and save. n8n sends it as `Authorization: Bearer omsk_live_` on every request. Keep the key in this credential, not in a node's headers or body, so it never shows up in exported workflows. ## Option 1: create a post with the HTTP Request node Add an **HTTP Request** node after your trigger and set: | Parameter | Value | |-----------|-------| | **Method** | `POST` | | **URL** | `https://api.omnisocials.com/v1/posts/create` | | **Authentication** | **Generic Credential Type** | | **Generic Auth Type** | **Bearer Auth**, then pick the "OmniSocials API" credential | | **Send Body** | On | | **Body Content Type** | **JSON** | | **Specify Body** | **Using JSON** | Paste this into the **JSON** field: ```json { "content": { "default": "New on the blog: how we plan a month of posts in one hour." }, "accounts": ["linkedin", "x"], "schedule_at": "2026-11-02T09:00:00Z", "media_urls": ["https://example.com/cover.jpg"], "source": "n8n" } ``` Run the node. A `201` response returns the new post in `data`, with its `id` and a `status` of `scheduled`. ### Map data from earlier nodes Switch the **JSON** field to an expression to fill it from the previous node. Wrap text values in `JSON.stringify()`, so quotes and line breaks in a caption cannot break the JSON: ```text { "content": { "default": {{ JSON.stringify($json.caption) }} }, "accounts": ["linkedin", "x"], "schedule_at": "{{ $now.plus({ hours: 1 }).toISO() }}", "media_urls": [{{ JSON.stringify($json.image_url) }}], "source": "n8n" } ``` ### Body fields The fields you will use most. The full list, including per-platform options, is in the [API reference](https://docs.omnisocials.com/api) and [Creating posts](https://docs.omnisocials.com/creating-posts.md). | Field | Type | What it does | |-------|------|--------------| | `content` | object | Required. `default` is the text for every account. Add a platform key such as `instagram` or `linkedin` to give that platform its own text. | | `accounts` | array of strings | Required. Platform names such as `"instagram"`, or account IDs from `GET /v1/accounts` such as `"844008_instagram"`. | | `schedule_at` | string | ISO 8601 date and time, in the future. The post publishes then. | | `publish_now` | boolean | `true` publishes right away. It wins over `schedule_at`. | | `media_urls` | array of URLs | Public image or video URLs. OmniSocials downloads them. Instagram and TikTok posts need at least one. | | `source` | string | Optional label for where the post came from. | Leave out both `schedule_at` and `publish_now` to save the post as a draft. Send `publish_now` as a real boolean (`true`), never as the string `"false"`. :::tip Run a `GET` on `https://api.omnisocials.com/v1/accounts` with the same credential to see the exact account IDs in your workspace. ::: ## Option 2: let an AI Agent post through MCP The **MCP Client Tool** node connects an n8n **AI Agent** to the hosted OmniSocials MCP server at `https://mcp.omnisocials.com`. The agent then sees tools like `list_accounts`, `create_post`, `upload_media`, and `get_best_times`. See [MCP server](https://docs.omnisocials.com/mcp-server.md) for the full tool list. Build the workflow: 1. Add a **Chat Trigger** node. It shows as "When chat message received" on the canvas. 2. Add an **AI Agent** node after it. Leave **Source for Prompt (User Message)** on **Connected Chat Trigger Node**. 3. Connect a chat model sub-node to the agent, for example OpenAI or Anthropic. 4. On the agent's tool connector, add an **MCP Client Tool** node and set: | Parameter | Value | |-----------|-------| | **Endpoint** | `https://mcp.omnisocials.com` | | **Server Transport** | **HTTP Streamable** | | **Authentication** | **Bearer Auth**, then pick the "OmniSocials API" credential | | **Tools to Include** | **Selected**, then pick the tools the agent needs. Or **All**. | 5. Optional: under the agent's **Options**, add a **System Message**, for example: ```text You manage social media through OmniSocials. Call list_accounts before you create a post. Create posts as drafts unless the user asks you to schedule or publish them. ``` 6. Open the chat and ask: "List my connected social accounts on OmniSocials". The agent should call `list_accounts` and answer with your accounts. :::caution OmniSocials only speaks Streamable HTTP. If your MCP Client Tool node shows a field called **SSE Endpoint** and no **Server Transport**, it is the first version of the node. Add a fresh MCP Client Tool node on a current n8n release. ::: The tools respect the key's scopes. A key without `posts:write` still lists `create_post`, but calling it returns `403`. Pick **Selected** in **Tools to Include** to keep the agent to the tools it needs, for example `list_accounts`, `create_post`, and `list_posts`. ## Troubleshooting | Response | Cause | Fix | |----------|-------|-----| | `401` | The key is wrong or revoked. | Check the Bearer Token in the credential. | | `402` on the MCP server | The organisation has no active plan. | Check billing in **Settings → Organisation**. | | `403` | The key misses a scope. | Create a new key with the scope. Scopes cannot be edited. | | `400` with `validation_error` | A field is missing or invalid, for example an unknown account or a past `schedule_at`. | Read `error.message`, it names the field. | | `429` | Rate limit. | Wait for `Retry-After` seconds, then retry. | See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md) for every status code. ## Next steps - [Creating posts](https://docs.omnisocials.com/creating-posts.md) covers per-platform text, post types, and updates. - [Media](https://docs.omnisocials.com/media.md) covers uploads, formats, and size limits. - [Webhooks](https://docs.omnisocials.com/webhooks.md) can start an n8n workflow when a post publishes or fails. --- ## Document: Post to social media from Notion Connect a Notion Custom Agent to OmniSocials via MCP and publish social posts straight from your Notion workspace. URL: https://docs.omnisocials.com/integrations/notion # Post to social media from Notion A Notion Custom Agent can manage your OmniSocials content through a custom MCP connection in the agent's settings. The connection is remote: Notion talks directly to the hosted OmniSocials MCP endpoint. Custom MCP servers are available on Notion's **Business** and **Enterprise** plans. ## Before you start A Notion workspace admin must allow custom MCP servers once: 1. In Notion, open **Settings → Connections** 2. On the **Manage** tab, turn on **Enable custom MCP servers** If your workspace only allows approved connections, the admin also adds the OmniSocials URL under **Approved connections → Add approved connection → Custom MCP server**. ## Setup 1. Create an API key in OmniSocials (**Settings → API**) 2. In Notion, open your Custom Agent's **Settings** 3. Go to **Tools & Access** 4. Click **Add connection → Custom MCP server** 5. Enter the MCP base URL: ```text https://mcp.omnisocials.com ``` 6. For authentication, choose the header-based option (bearer token) and paste your OmniSocials API key as the token 7. Click **Connect** ## Verifying Mention your Custom Agent in any page and ask: > List my connected social accounts on OmniSocials A successful response with your accounts confirms the integration. A `401` means the Bearer token is wrong. Double-check you copied the full key including the `omsk_live_` prefix. ## What you can do A Notion Custom Agent can: - Create, schedule, update, and publish posts - Upload media from URLs - List connected accounts and media library items - Query analytics for posts and accounts The agent respects your API key's scopes. For example, a key without `posts:write` can't create posts. ## Tips for working with Notion Custom Agents - Custom Agents work well with structured databases. Consider building a content calendar database in Notion and wiring an agent to push new rows to OmniSocials. - Give the agent a trigger (e.g. "when status changes to Ready") so it publishes the post without manual intervention. - Store platform-specific copy in separate database columns and let the agent map those to per-platform `content` on the post body. ## Limitations - Notion's custom MCP connections are new and may change - Rate limits on the Notion side can limit how often an agent makes calls. OmniSocials' own 100 req/min limit applies independently. - The Bearer Token method sends the key on every request, so it's secure. The key is not exposed in the connector URL. --- ## Document: Post to social media from OpenClaw Connect OpenClaw to OmniSocials: the remote MCP server (Streamable HTTP) with an API key or OAuth, or the npm MCP package. URL: https://docs.omnisocials.com/integrations/openclaw # Post to social media from OpenClaw OpenClaw is a personal AI assistant with native MCP support. Connect OmniSocials to the remote MCP server with an API key or with OAuth, or run the npm package locally. OmniSocials' remote server speaks **Streamable HTTP only**. OpenClaw uses SSE when a server has no `transport`, so always set `"transport": "streamable-http"`. ## Option 1: Remote server with an API key (recommended) 1. Create an API key in OmniSocials (**Settings → API**). 2. Put the key in OpenClaw's env file, so it never sits in the config: ```bash echo 'OMNISOCIALS_API_KEY=omsk_live_' >> ~/.openclaw/.env ``` 3. Stop the Gateway (the CLI edits saved servers only while it is stopped), add the server, probe it and start again: ```bash openclaw gateway stop openclaw mcp set omnisocials '{"url":"https://mcp.omnisocials.com","transport":"streamable-http","headers":{"Authorization":"Bearer ${OMNISOCIALS_API_KEY}"}}' openclaw mcp doctor omnisocials --probe openclaw gateway start ``` The same server in `~/.openclaw/openclaw.json`, under `mcp.servers`: ```json5 { mcp: { servers: { omnisocials: { url: "https://mcp.omnisocials.com", transport: "streamable-http", headers: { Authorization: "Bearer ${OMNISOCIALS_API_KEY}" }, }, }, }, } ``` You can also add it in the Control UI: **Settings → MCP → Add server**, Streamable HTTP, the URL above. ## Option 2: Remote server with OAuth ```bash openclaw gateway stop openclaw mcp add omnisocials --url https://mcp.omnisocials.com --transport streamable-http --auth oauth openclaw mcp login omnisocials openclaw gateway start ``` The login opens OmniSocials in your browser: pick the workspaces OpenClaw may use and click **Allow access**. ## Option 3: npm package (local) Runs the MCP server as a subprocess. The npm package has fewer tools than the remote server. ```json5 { mcp: { servers: { omnisocials: { command: "npx", args: ["-y", "@omnisocials/mcp-server"], env: { OMNISOCIALS_API_KEY: "omsk_live_" }, }, }, }, } ``` ## Verifying Ask OpenClaw: > List my connected social accounts on OmniSocials If OpenClaw responds with your accounts, the integration is working. If it errors, check the OpenClaw gateway logs. The error usually points at a missing config field or an invalid API key. --- ## Document: OmniSocials on Smithery OmniSocials is listed on Smithery, an MCP server registry and gateway. Connect through Smithery or directly via our own remote MCP endpoint. URL: https://docs.omnisocials.com/integrations/smithery # OmniSocials on Smithery OmniSocials is listed on [Smithery](https://smithery.ai/servers/admin-tbgz/omnisocials), a directory and gateway for MCP servers. [![smithery badge](https://smithery.ai/badge/admin-tbgz/omnisocials)](https://smithery.ai/servers/admin-tbgz/omnisocials) ## Connect via Smithery If you already use Smithery as your MCP gateway, add OmniSocials from its [server page](https://smithery.ai/servers/admin-tbgz/omnisocials) and supply your OmniSocials API key (Settings → API in the app) when prompted. ## Connect directly You don't need Smithery to use OmniSocials from an MCP client. Most clients can connect straight to the hosted endpoint: ``` https://mcp.omnisocials.com/ ``` See the [MCP Server](https://docs.omnisocials.com/mcp-server.md) page for local install, OAuth, and Claude/ChatGPT/Grok-specific setup. --- ## Document: Post to social media from Zapier Schedule and publish social posts from a Zap with Webhooks by Zapier and the OmniSocials REST API. Covers JSON escaping and the steps that need a paid plan. URL: https://docs.omnisocials.com/integrations/zapier # Post to social media from Zapier Zapier can create OmniSocials posts with **Webhooks by Zapier**. The Zap sends a `POST` request to the REST API, so any trigger in Zapier can schedule or publish a post: a new row in a sheet, a new blog post, a form submission. :::caution Webhooks by Zapier needs a paid Zapier plan. Zapier's pricing page lists webhooks with the paid plans (Professional and up), and the Free plan only runs two-step Zaps. The Zap on this page has three steps, so it needs a paid plan for that reason too. ::: ## Which webhook event to use Webhooks by Zapier has a **POST** event and a **Custom Request** event. Use **Custom Request** with the method set to `POST`. The **POST** event builds the body from key and value pairs and turns a list into comma-separated text. OmniSocials needs `accounts` as a real JSON array, so a body from the **POST** event is rejected. **Custom Request** sends the JSON you write, exactly as written. ## Before you start - An OmniSocials account with at least one social account connected (**Settings → Organisation → Workspaces**). Accounts cannot be connected through the API. - An API key from **Settings → API** with the `posts:write` scope. Name it "Zapier" so you can find it later. See [Authentication](https://docs.omnisocials.com/authentication.md). - A Zap with a trigger that provides the post text. ## 1. Prepare the text with Code by Zapier **Custom Request** does not escape mapped values. A caption with a double quote or a line break would break the JSON. A short code step turns the caption into a safe JSON string first, and works out the publish time. 1. Add a **Code by Zapier** step and choose **Run Javascript**. 2. Under **Input Data**, add a field named `caption` and map the text from your trigger. 3. Paste this code: ```javascript output = { caption_json: JSON.stringify(inputData.caption || ""), // Publish one hour from now. Replace with your own time if you have one. schedule_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), }; ``` 4. Test the step. `caption_json` comes back as the caption wrapped in quotes, with quotes and line breaks escaped. If your text never changes, skip this step and type the caption straight into the JSON in the next step. ## 2. Send the request with Webhooks by Zapier Add a **Webhooks by Zapier** step, choose the **Custom Request** event, and fill in: | Field | Value | |-------|-------| | **Method** | `POST` | | **URL** | `https://api.omnisocials.com/v1/posts/create` | | **Data Pass-Through?** | No | | **Data** | The JSON below | | **Headers** | `Content-Type`: `application/json`, and `Authorization`: `Bearer omsk_live_` | Leave **Basic Auth** empty. Paste this into **Data**, then replace the two placeholders with the fields from the code step: ```text { "content": { "default": CAPTION_JSON }, "accounts": ["linkedin", "x"], "schedule_at": "SCHEDULE_AT", "media_urls": ["https://example.com/cover.jpg"], "source": "zapier" } ``` - Replace `CAPTION_JSON` with the `caption_json` field from the code step. Do not add quotes around it: `JSON.stringify` already added them. - Replace `SCHEDULE_AT` with the `schedule_at` field from the code step, inside the quotes. Test the step. A `201` response returns the new post under `data`, with its `id` and a `status` of `scheduled`. ### Where the key lives Webhooks by Zapier has no credential store. The key is saved in the step's **Headers**, and anyone who can edit the Zap can read it. Keep it narrow: - Give the key only the scopes this Zap needs, usually just `posts:write`. - Use a separate key for each Zap, so you can revoke one without breaking the others. - Revoke the key in **Settings → API** when you delete or hand over the Zap. :::tip Zapier also has **API by Zapier**, a beta app with an API Request action that keeps credentials in a connection instead of in the Zap. It also needs a paid plan. See [Send API requests in Zaps](https://help.zapier.com/hc/en-us/articles/44391650357005). ::: ## Body fields The fields you will use most. The full list, including per-platform options, is in the [API reference](https://docs.omnisocials.com/api) and [Creating posts](https://docs.omnisocials.com/creating-posts.md). | Field | Type | What it does | |-------|------|--------------| | `content` | object | Required. `default` is the text for every account. Add a platform key such as `instagram` or `linkedin` to give that platform its own text. | | `accounts` | array of strings | Required. Platform names such as `"instagram"`, or account IDs from `GET /v1/accounts` such as `"844008_instagram"`. | | `schedule_at` | string | ISO 8601 date and time, in the future. The post publishes then. | | `publish_now` | boolean | `true` publishes right away. It wins over `schedule_at`. | | `media_urls` | array of URLs | Public image or video URLs. OmniSocials downloads them. Instagram and TikTok posts need at least one. | | `source` | string | Optional label for where the post came from. | Leave out both `schedule_at` and `publish_now` to save the post as a draft. Write `publish_now` as `true` without quotes. ## Troubleshooting | Response | Cause | Fix | |----------|-------|-----| | `401` | The key is wrong or revoked, or the `Bearer ` prefix is missing. | Check the `Authorization` header. | | `403` | The key misses a scope. | Create a new key with the scope. Scopes cannot be edited. | | `400` with `validation_error` | A field is missing or invalid, for example an unknown account, a past `schedule_at`, or invalid JSON. | Read `error.message`, it names the problem. Invalid JSON usually means a mapped value without the code step. | | `429` | Rate limit. | Wait for `Retry-After` seconds, then retry. | See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md) for every status code. ## Next steps - [Creating posts](https://docs.omnisocials.com/creating-posts.md) covers per-platform text, post types, and updates. - [Media](https://docs.omnisocials.com/media.md) covers uploads, formats, and size limits. - [Webhooks](https://docs.omnisocials.com/webhooks.md) can start a Zap when a post publishes or fails. --- ## Document: Migrate to the OmniSocials API Guides for moving a social posting integration from Ayrshare, Zernio (Late), Upload-Post, or the Postiz public API to the OmniSocials API. URL: https://docs.omnisocials.com/migrate # Migrate to the OmniSocials API These guides help you move an existing social posting integration to the OmniSocials API. Each one maps the other API's concepts, endpoints, fields, and webhooks to OmniSocials, shows the same post in both APIs, and lists what OmniSocials does not offer. Competitor facts were checked against their public docs on October 21, 2026. | Coming from | Guide | Biggest change | |-------------|-------|----------------| | Ayrshare | [From Ayrshare](https://docs.omnisocials.com/migrate/ayrshare.md) | A `Profile-Key` becomes a workspace with its own API key. A post without a time is a draft. | | Zernio (formerly Late) | [From Zernio](https://docs.omnisocials.com/migrate/zernio.md) | `platforms[]` with account IDs becomes a flat `accounts` array. Times go in as UTC. | | Upload-Post | [From Upload-Post](https://docs.omnisocials.com/migrate/upload-post.md) | Four multipart upload endpoints become one JSON endpoint. The `user` field becomes the key's workspace. | | Postiz public API | [From Postiz](https://docs.omnisocials.com/migrate/postiz.md) | One entry per channel becomes one post with per-channel objects. `type` becomes `schedule_at` or `publish_now`. | ## The OmniSocials side in brief - **Base URL:** `https://api.omnisocials.com/v1` - **Auth:** `Authorization: Bearer omsk_live_...`. Each key belongs to one workspace and has scopes. See [Authentication](https://docs.omnisocials.com/authentication.md). - **Create a post:** `POST /posts/create` with `content` (a string or a per-channel object), `accounts` (alias `channels`), `media_ids` or `media_urls`, and `schedule_at` (alias `scheduled_at`) or `publish_now`. Without either, the post is a draft. See [Creating posts](https://docs.omnisocials.com/creating-posts.md). - **Accounts:** connected only in the dashboard, never through the API. - **Channels:** 12 platforms and 13 channels, with LinkedIn as a profile and a page. See [Platforms](https://docs.omnisocials.com/platforms.md). - **Webhooks:** `post.scheduled`, `post.published`, `post.failed`, `post.approved`, and `post.rejected`, signed with HMAC-SHA256. See [Webhooks](https://docs.omnisocials.com/webhooks.md). - **Rate limit:** 100 requests per minute per key. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). - **No test mode.** Every key works on real accounts. Test with drafts, which never publish on their own. --- ## Document: Migrate from Ayrshare to the OmniSocials API Move an Ayrshare integration to the OmniSocials API: profile keys to workspaces, endpoint and field mapping, media, scheduling, webhooks, and platforms. URL: https://docs.omnisocials.com/migrate/ayrshare # Migrate from Ayrshare to the OmniSocials API This guide is for developers who post through the Ayrshare API and want to move to the OmniSocials API. The request shape is close. Ayrshare's `post`, `platforms`, `mediaUrls`, and `scheduleDate` become `content`, `accounts`, `media_urls`, and `schedule_at`. The main change is the account model. Ayrshare picks a user profile with a `Profile-Key` header and links accounts through a link session URL. OmniSocials gives each workspace its own API key, and accounts are connected in the dashboard. Ayrshare facts on this page come from ayrshare.com/docs and were checked on October 21, 2026. ## Concepts | Ayrshare | OmniSocials | Notes | |----------|-------------|-------| | API key (`Authorization: Bearer`) | API key (`Authorization: Bearer omsk_live_...`) | An OmniSocials key belongs to one workspace. | | User profile and its `Profile-Key` | Workspace and its own API key | There is no `Profile-Key` header. Use the workspace's key instead. | | `POST /profiles` | Add a workspace in the dashboard | There is no API to create workspaces. | | Link session or `generateJWT` URL | Dashboard connection | Accounts are connected only in the dashboard, never through the API. | | One connection per network per profile | One account per channel per workspace | The same model. LinkedIn is the exception, see below. | | `platforms` values (`twitter`, `gmb`, `linkedin`) | Channel IDs (`x`, `google_business`, `linkedin`, `linkedin_page`) | A workspace can hold a LinkedIn profile and a LinkedIn page at once. | | Ayrshare post `id` | Post `id` | | | `postIds[]` with `postUrl` per network | `published_urls` on `GET /posts/{id}` | A map from channel ID to the live URL. | | Auto Schedule | None | `GET /analytics/best-times` recommends slots. You pick the time. | | `requiresApproval` | Approval workflows | Pass `approval_workflow_id` with `schedule_at`. See [Creating posts](https://docs.omnisocials.com/creating-posts.md#approval-workflows). | | Webhook actions | Webhook events | See [Webhooks](#webhooks). | ## Authentication ```text Ayrshare: Authorization: Bearer API_KEY Profile-Key: PROFILE_KEY OmniSocials: Authorization: Bearer omsk_live_... ``` With Ayrshare, one API key serves every user profile, and the `Profile-Key` header picks the profile. An OmniSocials key is bound to one workspace, which you choose when you create the key under **Settings → API**. Store one OmniSocials key where you stored each profile key. `GET /accounts` returns `workspace_id` and `workspace_name`, so you can check which workspace a key belongs to. Ayrshare requires `X-Twitter-OAuth1-Api-Key` and `X-Twitter-OAuth1-Api-Secret` headers on X requests since March 31, 2026. OmniSocials needs no X developer app and no extra headers. Drop them. Keys have scopes, such as `posts:write` and `media:write`. A key's scopes cannot be edited later. See [Authentication](https://docs.omnisocials.com/authentication.md). ## Endpoint mapping OmniSocials paths are relative to `https://api.omnisocials.com/v1`. Ayrshare paths are relative to `https://api.ayrshare.com/api`. | Ayrshare | OmniSocials | Notes | |----------|-------------|-------| | `POST /post` | `POST /posts/create` | `POST /posts/create-and-publish` publishes at once. | | `GET /post/:id` | `GET /posts/{id}` | | | `GET /history` | `GET /posts` | Filter with `status`. Page with `limit` (max 100) and `offset`. There are no date or platform filters. | | `PATCH /post` | `PATCH /posts/{id}` | Drafts and scheduled posts only. Any create field can change, including text and media. | | `DELETE /post` | `DELETE /posts/{id}` | One post per call. Cancels a scheduled publish. A published post stays live on the platforms, see [What is different](#what-is-different). | | `PUT /post/retry` | `POST /posts/{id}/retry` | Retries only the failed channels, up to 3 times per channel. | | `POST /media/upload` | `POST /media/upload` | Multipart, up to 100 MB. `POST /media/upload-from-base64` takes base64. | | `GET /media/uploadUrl` | `POST /media/create-upload-url` | Returns a single-use `upload_url` for 10 minutes. POST a multipart `file` to it, not a `PUT`. | | `POST /profiles`, `GET`, `PATCH`, `DELETE /profiles` | None | Manage workspaces in the dashboard. | | `POST /profiles/link-sessions`, `POST /profiles/generateJWT` | None | Connect accounts in the dashboard. | | `DELETE /profiles/social` | None | Disconnect accounts in the dashboard. | | `GET /user` | `GET /accounts` | Returns the workspace and its connected accounts, with `status` and `needs_reconnect`. | | `POST /analytics/post` | `GET /analytics/posts/{id}` | `GET /analytics/posts?ids=` takes up to 100 post IDs. | | `POST /analytics/social` | `GET /analytics/accounts` | `GET /analytics/overview` gives workspace totals. | | `GET /comments/:id` | `GET /inbox/conversations?type=comment` | Then `GET /inbox/conversations/{conversationId}/messages`. Needs the opt-in `inbox:read` scope. | | `POST /comments/reply/:commentId` | `POST /inbox/conversations/{conversationId}/reply` | Pass `message_id` to answer one comment. Needs `inbox:write`. | | `DELETE /comments/:id` | `DELETE /inbox/messages/{id}` | Instagram, Facebook, and TikTok comments. | | `POST /comments` | None | `first_comment` posts one comment when the post publishes. | | `POST /hook/webhook` | `POST /webhooks` | One webhook can subscribe to several events. | | `GET /hook/webhook` | `GET /webhooks` | | | `DELETE /hook/webhook` | `DELETE /webhooks/{id}` | | | `POST /hook/webhook/secret` | `POST /webhooks/{id}/rotate-secret` | The old secret stops working at once. | | `GET /hook/history` | `GET /webhooks/{id}` | Returns `failure_count` and `last_failure`. There is no full delivery log. | | `POST /auto-schedule/set` | None | | | `POST /validate/post` | `POST /media/check` | Checks media against your connected channels. There is no full post validation call. | ## Create a post The same post in both APIs: one image to Facebook, Instagram, and LinkedIn, with a first comment, scheduled for 08:00 UTC on November 2, 2026. Ayrshare: ```bash curl -X POST https://api.ayrshare.com/api/post \ -H "Authorization: Bearer $AYRSHARE_API_KEY" \ -H "Profile-Key: $AYRSHARE_PROFILE_KEY" \ -H "Content-Type: application/json" \ -d '{ "post": "Our autumn collection is live.", "platforms": ["facebook", "instagram", "linkedin"], "mediaUrls": ["https://example.com/autumn.jpg"], "scheduleDate": "2026-11-02T08:00:00Z", "firstComment": { "comment": "Shop it at example.com/autumn" } }' ``` OmniSocials: ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our autumn collection is live.", "accounts": ["facebook", "instagram", "linkedin"], "media_urls": ["https://example.com/autumn.jpg"], "schedule_at": "2026-11-02T08:00:00Z", "facebook": { "first_comment": "Shop it at example.com/autumn" }, "instagram": { "first_comment": "Shop it at example.com/autumn" }, "linkedin": { "first_comment": "Shop it at example.com/autumn" } }' ``` If the Ayrshare LinkedIn connection is a company page, use `linkedin_page` instead of `linkedin` in both `accounts` and the first-comment key. The response is a `201` with the post under `data` and `"status": "scheduled"`. ## Field mapping | Ayrshare field | OmniSocials field | Notes | |----------------|-------------------|-------| | `post` | `content` | A string, or an object with `default` and channel keys for per-channel text. | | `platforms` | `accounts` | Rename `twitter` to `x` and `gmb` to `google_business`. Alias: `channels`. | | `mediaUrls` | `media_urls` | Up to 10 URLs, 100 MB per file. Or upload first and pass `media_ids`. | | `isVideo` | None | The type is read from the file. Set `type: "reel"` for TikTok videos, Instagram reels, and YouTube Shorts. | | `scheduleDate` | `schedule_at` | ISO 8601 in UTC. Alias: `scheduled_at`. | | `firstComment.comment` | `first_comment` under each channel key | Text only, set per channel. Instagram, Facebook, LinkedIn, YouTube, TikTok, and Telegram. | | `firstComment.mediaUrls` | None | First comments carry text only. | | `disableComments` | `tiktok.disable_comment` | TikTok only. | | `requiresApproval` | `approval_workflow_id` | Requires `schedule_at`. | | `twitterOptions` | `x` | | | `instagramOptions` | `instagram` | Location, collaborators, and user tags are top-level fields (`location_id`, `collaborators`, `user_tags`). | | `faceBookOptions` | `facebook` | | | `linkedInOptions` | `linkedin` or `linkedin_page` | | | `youTubeOptions` | `youtube` | For example `youtube.title` and `youtube.privacy_status`. | | `tikTokOptions` | `tiktok` | | | `pinterestOptions` | `pinterest` | `pinterest.board_id` is required. | | `threadsOptions`, `blueskyOptions`, `telegramOptions` | `threads`, `bluesky`, `telegram` | | | `gmbOptions` | `google_business` | | | `redditOptions`, `snapChatOptions` | None | Reddit and Snapchat are not live yet. | | `autoSchedule`, `autoRepost`, `autoHashtag` | None | `hashtag_set` applies a saved hashtag set. | | `shortenLinks`, `unsplash`, `randomPost`, `randomMediaUrl` | None | | | `idempotencyKey` | None | Store the returned post `id` and check it before you retry a create. | | `validateScheduled`, `notes` | None | | See [Platforms](https://docs.omnisocials.com/platforms.md) for every channel's options. ## Media and scheduling **Media.** `mediaUrls` maps straight to `media_urls`: OmniSocials downloads each URL and attaches it. Each file can be up to 100 MB. For larger files, call `POST /media/upload-from-url`, which fetches up to 1 GB. Files above about 100 MB come back with `status: "processing"`. Poll `GET /media/{id}` until it is `ready`, then pass the ID in `media_ids`. Ayrshare's presigned flow takes files up to 5 GB. The largest file OmniSocials takes is 1 GB. A PDF is turned into one image per page. On LinkedIn it publishes as a document. Any media entry can carry alt text as `{ "url": "...", "alt": "..." }`. See [Media](https://docs.omnisocials.com/media.md). **Scheduling.** `scheduleDate` and `schedule_at` both take an ISO 8601 time in UTC, so the value carries over as is. OmniSocials publishes within one minute of that time. Two behaviors differ: - A time in the past returns `400 validation_error`. Ayrshare publishes a past `scheduleDate` at once. To publish now, send `publish_now: true`. - A post with neither `schedule_at` nor `publish_now` is saved as a draft. Ayrshare publishes a post without `scheduleDate` right away. ## Webhooks Register a webhook with `POST /webhooks` and a list of events. The response returns a `secret` once. Store it. ```bash curl -X POST https://api.omnisocials.com/v1/webhooks \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/omnisocials", "events": ["post.published", "post.failed"] }' ``` | Ayrshare action | OmniSocials event | Notes | |-----------------|-------------------|-------| | `scheduled` | `post.published`, `post.failed` | OmniSocials sends these for every post, scheduled or not. A partial publish is `post.published` with `data.status` set to `warning`. Check each entry in `data.targets`. | | None | `post.scheduled` | Sent when a post is scheduled through the API. | | `social` | None | Poll `GET /accounts` and check `needs_reconnect`. | | `comments`, `messages`, `mentions` | None | Poll `GET /inbox/conversations` or `GET /inbox/next`. | | `feed`, `batch`, `automations`, `suspended`, `ads`, `demo` | None | | | None | `post.approved`, `post.rejected` | Approval workflow outcomes. | **Signatures.** Ayrshare sends an HMAC-SHA256 of the body in `X-Authorization-Content-SHA256`, with `X-Authorization-Timestamp`. OmniSocials sends `X-OmniSocials-Signature: t=,v1=`. The HMAC-SHA256 covers the timestamp, a dot, and the raw body, keyed with your webhook secret. Deduplicate on `X-OmniSocials-Delivery`. See [Webhooks](https://docs.omnisocials.com/webhooks.md#verifying-signatures) for verification code. **Delivery.** Answer with a `2xx` within 10 seconds. Failed deliveries are retried twice, after 2 seconds and then 5 more. After that the event is dropped, so reconcile with `GET /posts?status=` if your endpoint was down. Webhook URLs must use HTTPS. ## Platform coverage As of October 21, 2026, from ayrshare.com/docs and the [OmniSocials platforms page](https://docs.omnisocials.com/platforms.md). | Platform | Ayrshare | OmniSocials | |----------|----------|-------------| | Facebook | Yes (`facebook`) | Yes (`facebook`), Pages | | Instagram | Yes (`instagram`) | Yes (`instagram`), Business and Creator accounts | | Threads | Yes (`threads`) | Yes (`threads`) | | LinkedIn | Yes (`linkedin`), a profile or a page per connection | Yes, profile (`linkedin`) and company page (`linkedin_page`) | | YouTube | Yes (`youtube`) | Shorts only (`youtube`) | | TikTok | Yes (`tiktok`) | Yes (`tiktok`) | | Pinterest | Yes (`pinterest`) | Yes (`pinterest`) | | Bluesky | Yes (`bluesky`) | Yes (`bluesky`) | | X | Yes (`twitter`), with your own X developer app keys | Yes (`x`) | | Google Business Profile | Yes (`gmb`) | Yes (`google_business`) | | Telegram | Yes (`telegram`) | Yes (`telegram`), a channel or group | | Mastodon | Not listed | Yes (`mastodon`) | | Reddit | Yes (`reddit`) | Coming soon | | Snapchat | Yes (`snapchat`) | Coming soon | | WhatsApp | Messaging only | No | ## What is different - **Account connection.** OmniSocials has no link session or `generateJWT` endpoint. Accounts are connected in the dashboard. If your product lets end users connect their own accounts through your app, OmniSocials does not support that today. - **No profiles API.** Workspaces are created in the dashboard, and each API key reaches one workspace. - **Deleting published posts.** `DELETE /posts/{id}` removes the post from OmniSocials only. Ayrshare's `DELETE /post` also removes it from most networks. On OmniSocials, use "Delete from [platform]" on the post in the dashboard, or delete it in the platform's app. - **X.** OmniSocials needs no X developer app. Posts with a link use prepaid credits. Posts without a link do not. See [X](https://docs.omnisocials.com/platforms/x.md). - **Rate limit.** OmniSocials allows 100 requests per minute per key. Ayrshare documents 300 requests per 5 minutes per user profile. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). - **Fewer post options.** There is no auto schedule, auto repost, auto hashtag, link shortening, idempotency key, or post validation call. First comments are text only. - **Times and drafts.** Past times are refused, and a post without a time is a draft. - **Fewer webhook events.** There are no account, comment, or message events. Failed deliveries are retried twice within seconds, not over hours. - **File size.** The largest file OmniSocials takes is 1 GB. - **Error shape.** Ayrshare errors carry `status: "error"` and an `errors` array. OmniSocials errors nest under `error`, with `code`, `message`, and optional `details`. Branch on `error.code`. - **Fewer channels.** Reddit and Snapchat are not live yet, and YouTube is Shorts only. - **No test mode.** Neither API documents one. On OmniSocials, use drafts or a workspace with test accounts. - **What OmniSocials adds.** Mastodon, a LinkedIn profile and page in one workspace, a [Social inbox](https://docs.omnisocials.com/inbox.md) with a next-unanswered queue and comment hiding, approval workflow webhooks, alt text per image, PDF to carousel, hashtag sets, Instagram comment-to-DM automations, and a hosted [MCP server](https://docs.omnisocials.com/mcp-server.md) with OAuth sign-in. ## Migration steps 1. Create an OmniSocials account. Add one workspace for each Ayrshare user profile. 2. Connect each social account in the dashboard, under **Settings → Organisation → Workspaces**. Connect the LinkedIn profile, the LinkedIn page, or both. 3. Create one API key per workspace under **Settings → API**. Start with `posts:write`, `posts:read`, `media:write`, `accounts:read`, and `webhooks:manage`. 4. Replace each profile key in your database with that workspace's OmniSocials key. Remove the `Profile-Key` and X OAuth headers. 5. Change the base URL. Rename the post fields with the [field mapping](#field-mapping) table, and rename `twitter` to `x` and `gmb` to `google_business`. 6. Add `publish_now: true` wherever you posted without `scheduleDate`. Without it, the post is saved as a draft. 7. Register your webhooks with `POST /webhooks`. Swap the signature check and map the actions to events. 8. Recreate the posts that are still scheduled in Ayrshare with `schedule_at`. Then cancel them in Ayrshare with `DELETE /post`, so nothing publishes twice. 9. If you use the `social-media-api` npm package or the `social-post-api` Python package, switch to `@omnisocials/sdk` or `omnisocials`. See [SDKs](https://docs.omnisocials.com/sdks.md). 10. Test with drafts first, since there is no test mode. Then publish one post with `publish_now: true` and check `published_urls` on `GET /posts/{id}`. :::tip Ayrshare publishes a post without `scheduleDate` right away. OmniSocials saves it as a draft. Step 6 is the change most likely to be missed. ::: --- ## Document: Migrate from the Postiz public API to the OmniSocials API Move a Postiz public API integration to the OmniSocials API: auth, endpoint and field mapping, media, threads, scheduling, webhooks, and platform coverage. URL: https://docs.omnisocials.com/migrate/postiz # Migrate from the Postiz public API to the OmniSocials API This guide is for developers who post through the Postiz public API, on Postiz Cloud or a self-hosted instance, and want to move to the OmniSocials API. In Postiz, one request carries a separate entry for each channel, with its own text, media, and settings. In OmniSocials, one post lists its channels in `accounts`, and per-channel text and options sit in objects keyed by channel ID. Scheduling moves from a `type` field to `schedule_at` and `publish_now`. Social accounts are connected in the OmniSocials dashboard. Postiz facts on this page come from docs.postiz.com and were checked on October 21, 2026. ## Concepts | Postiz | OmniSocials | Notes | |--------|-------------|-------| | API key (`Authorization: your-api-key`, no `Bearer`) | API key (`Authorization: Bearer omsk_live_...`) | | | Organization (the key reaches all its channels) | Workspace (the key reaches one workspace) | Create one key per workspace. | | Customer (`GET /groups`) | Workspace | Give each customer its own workspace and key. | | Integration (a channel) with an `id` | Account with an `id`, or the bare channel ID | A workspace has one account per channel ID. | | Connect URL (`GET /social/{integration}`) | Dashboard connection | Accounts are connected only in the dashboard, never through the API. | | One `posts[]` entry per integration | One post with an `accounts` array | One post `id` covers every channel. | | Extra `value[]` entries (thread or comments) | `thread_parts` on X, Bluesky, Mastodon, and Threads, or `first_comment` | See [Threads and comments](#threads-and-comments). | | `settings.__type` and its fields | A top-level object named after the channel | For example `x`, `pinterest`, `youtube`. | | `type`: `draft`, `schedule`, `now` | No `schedule_at`, `schedule_at`, `publish_now: true` | | | States `QUEUE`, `PUBLISHED`, `ERROR`, `DRAFT` | `scheduled`, `published` (or `warning`), `failed`, `draft` | `warning` means some channels published and some failed. | | Tags | None | | | Find slot | `GET /analytics/best-times` | Recommended slots from your own history. You pick the time. | ## Authentication ```text Postiz: Authorization: your-api-key OmniSocials: Authorization: Bearer omsk_live_... ``` Postiz takes the raw key in the header. OmniSocials needs the `Bearer` prefix. A Postiz key reaches every channel of the organization. An OmniSocials key is bound to one workspace, chosen when you create the key under **Settings → API**. `GET /accounts` returns `workspace_id` and `workspace_name`, so you can check which workspace a key belongs to. Keys have scopes, such as `posts:write` and `media:write`. A key's scopes cannot be edited later. See [Authentication](https://docs.omnisocials.com/authentication.md). ## Endpoint mapping OmniSocials paths are relative to `https://api.omnisocials.com/v1`. Postiz paths are relative to `https://api.postiz.com/public/v1` on Postiz Cloud, or `https://{your-domain}/api/public/v1` when self-hosted. | Postiz | OmniSocials | Notes | |--------|-------------|-------| | `POST /posts` | `POST /posts/create` | `POST /posts` is an alias. `POST /posts/create-and-publish` publishes at once. | | `GET /posts` | `GET /posts` | No date range. Filter with `status` and page with `limit` (max 100) and `offset`, newest first. | | `DELETE /posts/{id}` | `DELETE /posts/{id}` | Removes the post from OmniSocials and cancels a scheduled publish. It does not delete a live post on the platform. | | `DELETE /posts/group/{group}` | `DELETE /posts/{id}` | One post covers all its channels, so there is no group. | | `PUT /posts/{id}/status` | `PATCH /posts/{id}` | Send `draft: true` to unschedule, or `draft: false` to schedule a draft that has a `schedule_at`. | | `PUT /posts/{id}/settings` | `PATCH /posts/{id}` | Drafts and scheduled posts only. Any create field can change. | | `GET /integrations` | `GET /accounts` | Returns the key's workspace and its accounts, with `status` and `needs_reconnect`. | | `GET /is-connected`, `GET /me` | `GET /accounts` | A `200` means the key works. | | `GET /groups` | None | Each customer becomes a workspace with its own key. | | `GET /social/{integration}` | None | Connect accounts in the dashboard. | | `DELETE /integrations/{id}` | None | Disconnect accounts in the dashboard. | | `GET /find-slot/{id}` | `GET /analytics/best-times` | Recommendations, not the next free slot. | | `POST /upload` | `POST /media/upload` | Multipart with a `file` field, up to 100 MB. | | `POST /upload-from-url` | `POST /media/upload-from-url` | Same `{ "url": "..." }` body. Up to 1 GB. | | `GET /media/{id}/status` | `GET /media/{id}` | Wait for `status: "ready"`. | | `GET /media` | `GET /media` | Search with `search` and filter with `folder_id`. | | `GET /analytics/{integration}` | `GET /analytics/accounts` | `GET /analytics/overview` gives workspace totals. | | `GET /analytics/post/{postId}` | `GET /analytics/posts/{id}` | `GET /analytics/posts?ids=` takes up to 100 post IDs. | | `GET /notifications` | None | Use webhooks or poll `GET /posts?status=failed`. | | `POST /generate-video`, `POST /video/function`, clipping endpoints | None | | ## Create a post The same post in both APIs: a tweet scheduled for 08:00 UTC on November 2, 2026, that everyone can reply to. Postiz: ```bash curl -X POST https://api.postiz.com/public/v1/posts \ -H "Authorization: $POSTIZ_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "schedule", "date": "2026-11-02T08:00:00.000Z", "shortLink": false, "tags": [], "posts": [ { "integration": { "id": "your-x-integration-id" }, "value": [{ "content": "We shipped dark mode today.", "image": [] }], "settings": { "__type": "x", "who_can_reply_post": "everyone" } } ] }' ``` OmniSocials: ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "We shipped dark mode today.", "accounts": ["x"], "schedule_at": "2026-11-02T08:00:00Z" }' ``` Everyone can reply by default. To limit replies, set `x.reply_settings` to `following` or `mentionedUsers`. To post to more channels, add them to `accounts`. Where Postiz needs a second `posts[]` entry for different text, OmniSocials uses one `content` object: ```json { "content": { "default": "We shipped dark mode today.", "linkedin": "We shipped dark mode today. Here is what changed for teams." }, "accounts": ["x", "linkedin", "bluesky"], "schedule_at": "2026-11-02T08:00:00Z" } ``` The response is a `201` with one post under `data`, not an array per channel. After publishing, `published_urls` on `GET /posts/{id}` maps each channel to its live URL. ## Field mapping | Postiz field | OmniSocials field | Notes | |--------------|-------------------|-------| | `type: "now"` | `publish_now: true` | Or call `POST /posts/create-and-publish`. | | `type: "schedule"` with `date` | `schedule_at` | ISO 8601 in UTC. Alias: `scheduled_at`. | | `type: "draft"` | Leave out `schedule_at` and `publish_now` | `draft: true` with `schedule_at` keeps the time on the calendar without publishing. | | `posts[].integration.id` | `accounts` | Account `id` from `GET /accounts`, or the channel ID. Alias: `channels`. | | `posts[].value[0].content` | `content`, or `content.` for one channel | | | `posts[].value[0].image[]` | `media_ids` or `media_urls` | Use the `id` from an OmniSocials upload, or pass public URLs. Per-channel media is an object keyed by channel. | | `image[].thumbnailTimestamp` | `video_cover` | `type: "frame"` with `thumb_offset` in milliseconds. | | `posts[].value[1..]` | `x.thread_parts` and the like, or `first_comment` | See below. | | `posts[].settings` | A top-level object named after the channel | | | `settings.__type` | The channel ID | Rename `linkedin-page` to `linkedin_page` and `gmb` to `google_business`. | | `who_can_reply_post` (X) | `x.reply_settings` | `following` or `mentionedUsers`. Leave it out for everyone. | | `shortLink` | None | Links are posted as written. | | `tags` | None | | | `posts[].group` | None | | ## Threads and comments In Postiz, extra entries in `value` become a thread or comments, depending on the platform. OmniSocials splits these two cases: - **Threads** on X, Bluesky, Mastodon, and Threads use `thread_parts` under the channel key. Each part has `text` and optional `media_ids` or `media_urls`. A thread has 2 to 25 parts. - **One first comment** on Instagram, Facebook, LinkedIn, YouTube, TikTok, and Telegram uses `first_comment` under the channel key. It is text only. ```json { "content": "We shipped dark mode today.", "accounts": ["x", "instagram"], "media_urls": ["https://example.com/dark-mode.png"], "x": { "thread_parts": [ { "text": "We shipped dark mode today." }, { "text": "It follows your system setting by default." } ] }, "instagram": { "first_comment": "Update the app to try it." } } ``` Chains of more than one comment have no equivalent. See [X threads](https://docs.omnisocials.com/platforms/x.md#posting-threads) for the details. ## Media and scheduling **Media.** The upload calls map one to one. `POST /media/upload` takes a multipart `file`, and `POST /media/upload-from-url` takes `{ "url": "..." }`. Both return the media record under `data`. Reference it on a post with `media_ids: [""]`. There is no `path` to send along. You can also skip uploads and pass public URLs in `media_urls`, up to 10 files of 100 MB each. OmniSocials accepts JPEG, PNG, GIF, WebP, HEIC, MP4, MOV, AVI, and PDF. AVIF, BMP, and TIFF are not on that list, so convert them first. A PDF is turned into one image per page. On LinkedIn it publishes as a document. See [Media](https://docs.omnisocials.com/media.md). **Scheduling.** Send `schedule_at` as an ISO 8601 time in UTC. OmniSocials publishes within one minute of that time. A time in the past returns `400 validation_error`. To publish now, send `publish_now: true`. ## Webhooks Postiz webhooks are set up in the app, under Settings, and fire when a post publishes. OmniSocials webhooks are registered through the API with `POST /webhooks`. The response returns a `secret` once. Store it. ```bash curl -X POST https://api.omnisocials.com/v1/webhooks \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/omnisocials", "events": ["post.published", "post.failed"] }' ``` | Event | When it fires | |-------|---------------| | `post.scheduled` | A post is scheduled through the API | | `post.published` | At least one channel published. A partial publish has `data.status` set to `warning` | | `post.failed` | Every channel failed | | `post.approved` | The last step of an approval workflow approved the post | | `post.rejected` | An approver rejected the post | Every delivery is signed. `X-OmniSocials-Signature: t=,v1=` is an HMAC-SHA256 of the timestamp, a dot, and the raw body, keyed with your webhook secret. Verify it before you trust the payload, and deduplicate on `X-OmniSocials-Delivery`. Answer with a `2xx` within 10 seconds. Failed deliveries are retried twice. See [Webhooks](https://docs.omnisocials.com/webhooks.md) for verification code. ## Platform coverage As of October 21, 2026, from docs.postiz.com and the [OmniSocials platforms page](https://docs.omnisocials.com/platforms.md). | Platform | Postiz | OmniSocials | |----------|--------|-------------| | Facebook | Yes (`facebook`) | Yes (`facebook`), Pages | | Instagram | Yes (`instagram`, `instagram-standalone`) | Yes (`instagram`), Business and Creator accounts | | Threads | Yes (`threads`) | Yes (`threads`) | | LinkedIn | Yes, profile (`linkedin`) and page (`linkedin-page`) | Yes, profile (`linkedin`) and page (`linkedin_page`) | | YouTube | Yes (`youtube`) | Shorts only (`youtube`) | | TikTok | Yes (`tiktok`) | Yes (`tiktok`) | | Pinterest | Yes (`pinterest`) | Yes (`pinterest`) | | Bluesky | Yes (`bluesky`) | Yes (`bluesky`) | | Mastodon | Yes (`mastodon`) | Yes (`mastodon`) | | X | Yes (`x`) | Yes (`x`) | | Google Business Profile | Yes (`gmb`) | Yes (`google_business`) | | Telegram | Yes (`telegram`) | Yes (`telegram`), a channel or group | | Reddit | Yes (`reddit`) | Coming soon | | Snapchat | Not listed | Coming soon | | Discord, Slack | Yes | No | | Farcaster, Nostr, VK, Lemmy | Yes | No | | Twitch, Kick | Yes | No | | Dribbble, Medium, Dev.to, Hashnode, WordPress, Tumblr, MeWe | Yes | No | | Listmonk, Moltbook, Skool, Whop | Yes | No | ## What is different - **Hosted only.** OmniSocials has no self-hosted version. - **Account connection.** OmniSocials has no connect-URL endpoint. Accounts are connected in the dashboard. - **One key per workspace.** A Postiz key reaches the whole organization. An OmniSocials key reaches one workspace, so a multi-customer setup needs one key per customer. - **One account per channel.** A workspace holds one account per channel ID. Two X accounts need two workspaces. - **One post, many channels.** Per-channel text and settings live in objects keyed by channel, not in separate entries. - **Threads and comments.** Threads work on X, Bluesky, Mastodon, and Threads. Other channels get one text-only first comment. - **Rate limit.** OmniSocials allows 100 requests per minute per key, on every endpoint. Postiz limits only `POST /posts`, per organization and per hour. Its docs give 30 and 90 requests per hour on different pages, and self-hosted instances set the value with `API_LIMIT`. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). - **No tags, link shortening, video generation, or clipping.** - **`GET /posts` has no date range.** Page through with `status`, `limit`, and `offset`. - **X links.** X posts whose text contains a link use prepaid credits. Posts without a link do not. See [X](https://docs.omnisocials.com/platforms/x.md). - **Fewer channels.** The channels in the last rows of the table above are not part of OmniSocials. YouTube is Shorts only, and Reddit is not live yet. - **No test mode.** On OmniSocials, use drafts or a workspace with test accounts. - **What OmniSocials adds.** Signed webhooks registered through the API, a [Social inbox](https://docs.omnisocials.com/inbox.md) API for DMs and comments, approval workflows, alt text per image, PDF to carousel, hashtag sets, Instagram comment-to-DM automations, and [SDKs](https://docs.omnisocials.com/sdks.md) in 8 languages. ## Migration steps 1. Create an OmniSocials account. Add one workspace per Postiz customer, or one for the whole organization if you use no customers. 2. Connect each social account in the dashboard, under **Settings → Organisation → Workspaces**. 3. Create one API key per workspace under **Settings → API**. Start with `posts:write`, `posts:read`, `media:write`, `accounts:read`, and `webhooks:manage`. 4. Call `GET /accounts` with each key. Map every Postiz integration `id` to an OmniSocials channel ID or account `id`. 5. Change the base URL and add `Bearer ` before the key. 6. Rewrite the post body. Merge the `posts[]` entries into one `accounts` array and one `content` object, and move `settings` to channel keys. Use the [field mapping](#field-mapping) table. 7. Re-upload media with `POST /media/upload` or `POST /media/upload-from-url`, and reference it with `media_ids`. 8. Register your webhooks with `POST /webhooks`, and add signature verification. 9. Recreate the posts that are still queued in Postiz with `schedule_at`. Then delete them in Postiz with `DELETE /posts/{id}`, so nothing publishes twice. 10. Test with drafts first, since there is no test mode. Then publish one post with `publish_now: true` and check `published_urls` on `GET /posts/{id}`. :::tip Posts without `schedule_at` or `publish_now` are drafts and never publish on their own. Use them to check your field mapping against real accounts. ::: --- ## Document: Migrate from Upload-Post to the OmniSocials API Move an Upload-Post integration to the OmniSocials API: auth, endpoint and field mapping, media uploads, scheduling, webhooks, and platform coverage. URL: https://docs.omnisocials.com/migrate/upload-post # Migrate from Upload-Post to the OmniSocials API This guide is for developers who post through the Upload-Post API and want to move to the OmniSocials API. Upload-Post has a separate multipart endpoint for each content type and picks the profile with a `user` field. OmniSocials has one JSON endpoint for every post type, and the API key itself picks the workspace. Media goes in as a URL or as an ID from an earlier upload. Social accounts are connected in the OmniSocials dashboard, not through a connect URL. Upload-Post facts on this page come from docs.upload-post.com and were checked on October 21, 2026. ## Concepts | Upload-Post | OmniSocials | Notes | |-------------|-------------|-------| | API key (`Authorization: Apikey ...`) | API key (`Authorization: Bearer omsk_live_...`) | An OmniSocials key belongs to one workspace. | | Profile (the `user` field, a username) | Workspace | Each workspace has its own API key. There is no `user` field. | | Social accounts on a profile | Accounts in a workspace | One account per channel ID per workspace. | | Connect URL (`generate-jwt`, `oauth/{platform}/start`) | Dashboard connection | Accounts are connected only in the dashboard, never through the API. | | `request_id` and `job_id` | Post `id` | One ID for drafts, scheduled posts, and published posts. | | Separate upload endpoints for video, photos, text, and documents | `POST /posts/create` | One endpoint. `type` picks a feed post, story, or reel. | | Facebook page, LinkedIn page, and Google Business location IDs per request | Chosen when the account is connected | A workspace posts to one Facebook Page, one LinkedIn page, and one Google Business location. | | Queue | None | `GET /analytics/best-times` recommends slots. You pick the time. | ## Authentication ```text Upload-Post: Authorization: Apikey YOUR_API_KEY OmniSocials: Authorization: Bearer omsk_live_... ``` With Upload-Post, one key serves every profile, and each request names the profile in `user`. An OmniSocials key is bound to one workspace, chosen when you create the key under **Settings → API**. To serve several brands, create one workspace and one key per brand, and store the key where you stored the Upload-Post username. `GET /accounts` returns `workspace_id` and `workspace_name`, so you can check which workspace a key belongs to. Keys have scopes, such as `posts:write` and `media:write`. A key's scopes cannot be edited later. See [Authentication](https://docs.omnisocials.com/authentication.md). ## Endpoint mapping OmniSocials paths are relative to `https://api.omnisocials.com/v1`. Upload-Post paths are relative to `https://api.upload-post.com`. | Upload-Post | OmniSocials | Notes | |-------------|-------------|-------| | `POST /api/upload` (video) | `POST /posts/create` | Pass the video in `media_urls` or `media_ids`. Use `type: "reel"` for TikTok videos, Instagram reels, and YouTube Shorts. | | `POST /api/upload_photos` | `POST /posts/create` | Images in `media_urls` or `media_ids`. Two or more images publish as a carousel where the platform supports it. | | `POST /api/upload_text` | `POST /posts/create` | No media. Instagram, TikTok, Pinterest, and YouTube need media. | | `POST /api/upload_document` | `POST /posts/create` | Pass a PDF. On LinkedIn it publishes as a document. PPT and DOC files are not accepted. | | `GET /api/uploadposts/status` | `GET /posts/{id}` | Read `status`, `published_urls`, and `errors`. | | `GET /api/uploadposts/schedule` | `GET /posts?status=scheduled` | Page with `limit` (max 100) and `offset`. | | `GET /api/uploadposts/schedule/` | `GET /posts/{id}` | | | `PATCH /api/uploadposts/schedule/` | `PATCH /posts/{id}` | Drafts and scheduled posts only. Media and channels can change too. | | `DELETE /api/uploadposts/schedule/` | `DELETE /posts/{id}` | Cancels a scheduled publish. On a published post it removes the post from OmniSocials only. | | `GET /api/uploadposts/history` | `GET /posts?status=published` | `GET /posts/recent-platform` reads recent posts made outside OmniSocials. | | `GET /api/analytics/{profile_username}` | `GET /analytics/accounts`, `GET /analytics/overview` | | | `GET /api/uploadposts/post-analytics/{request_id}` | `GET /analytics/posts/{id}` | `GET /analytics/posts?ids=` takes up to 100 post IDs. | | `GET /api/uploadposts/me` | `GET /accounts` | Returns the workspace and its connected accounts. | | `GET /api/uploadposts/pinterest/boards` | `GET /accounts/{id}` | A Pinterest account carries a `boards` array. Boards cannot be created through the API. | | `GET /api/uploadposts/facebook/pages`, `GET /api/uploadposts/linkedin/pages` | None | The page is chosen when the account is connected. | | `POST /api/uploadposts/users` and profile CRUD | None | Manage workspaces in the dashboard. | | `POST /api/uploadposts/users/generate-jwt`, `POST /api/uploadposts/oauth/{platform}/start` | None | Connect accounts in the dashboard. | | `DELETE /api/uploadposts/users/social` | None | Disconnect accounts in the dashboard. | | `/api/uploadposts/queue/*` | None | | | Notification settings | `POST /webhooks`, `GET`, `PATCH`, `DELETE /webhooks/{id}` | See [Webhooks](#webhooks). | ## Create a post The same post in both APIs: one video to TikTok and YouTube, scheduled for 08:00 UTC on November 2, 2026. Upload-Post sends the file and the fields in one multipart request: ```bash curl -X POST https://api.upload-post.com/api/upload \ -H "Authorization: Apikey $UPLOAD_POST_API_KEY" \ -F "user=acme" \ -F "platform[]=tiktok" \ -F "platform[]=youtube" \ -F "video=@/path/to/launch.mp4" \ -F "title=Launch day behind the scenes" \ -F "scheduled_date=2026-11-02T08:00:00Z" ``` OmniSocials takes JSON. Upload the file first, then create the post with the returned ID: ```bash curl -X POST https://api.omnisocials.com/v1/media/upload \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -F "file=@/path/to/launch.mp4" curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "reel", "content": "Launch day behind the scenes", "accounts": ["tiktok", "youtube"], "media_ids": ["123"], "youtube": { "title": "Launch day behind the scenes" }, "schedule_at": "2026-11-02T08:00:00Z" }' ``` Replace `123` with `data.id` from the upload response. If the video is already at a public URL, skip the upload and pass `"media_urls": ["https://example.com/launch.mp4"]` instead. On YouTube, `content` becomes the video description and `youtube.title` the title. The create call answers with a `201` and the post under `data`. Publishing always runs in the background. Read the outcome with `GET /posts/{id}` or a `post.published` webhook. ## Field mapping | Upload-Post field | OmniSocials field | Notes | |-------------------|-------------------|-------| | `user` | None | The API key selects the workspace. | | `platform[]` | `accounts` | A JSON array. `x` and `google_business` keep their names. Alias: `channels`. | | `video` | `media_urls` or `media_ids` | A URL, or the ID of an earlier upload. The file itself is not sent in the post request. | | `photos[]` | `media_urls` or `media_ids` | Up to 10 URLs per post. | | `document` | `media_urls` or `media_ids` with a PDF | One image per page. A LinkedIn document on LinkedIn. | | `title` | `content` | The post text. A string, or an object with `default` and channel keys. | | `[platform]_title`, such as `x_title` | `content.x`, `content.linkedin`, and so on | Per-channel text. On YouTube, set the title in `youtube.title`. | | `description` | `content` | On YouTube, `content` becomes the description. | | `scheduled_date` | `schedule_at` | ISO 8601 in UTC. Alias: `scheduled_at`. | | `timezone` | None | Convert to UTC before you send. | | `first_comment`, `[platform]_first_comment` | `first_comment` under the channel key, such as `instagram.first_comment` | Instagram, Facebook, LinkedIn, YouTube, TikTok, and Telegram. | | `privacy_level` | `tiktok.privacy_level`, `youtube.privacy_status` | | | `pinterest_board_id` | `pinterest.board_id` | Required on every Pinterest post. | | `facebook_page_id` | None | The Page is chosen when Facebook is connected. | | `target_linkedin_page_id` | `linkedin_page` in `accounts` | `linkedin` is the personal profile. | | `gbp_location_id` | None | The location is chosen when Google Business is connected. | | `add_to_queue`, `max_posts_per_slot` | None | | | `async_upload` | None | Publishing is always asynchronous. | | `external_id`, `request_id`, `Idempotency-Key` | None | Store the returned post `id` next to your own ID. | ## Media and scheduling **Media.** Upload-Post takes the file inside the post request. OmniSocials keeps media and posts apart: - **`media_urls` on the post.** Pass public URLs and OmniSocials downloads them. Up to 10 files, each up to 100 MB. - **`POST /media/upload`.** A multipart upload with a `file` field, up to 100 MB. Use `data.id` in `media_ids`. - **`POST /media/upload-from-url`.** OmniSocials fetches a public URL of up to 1 GB. Files above about 100 MB come back with `status: "processing"`. Poll `GET /media/{id}` until it is `ready`. - **`POST /media/upload-from-base64`.** For a client that holds the file as base64. A file in the library can be used on many posts, so upload once when the same video goes out more than once. The largest file OmniSocials takes is 1 GB. Platform limits, such as video length, are checked when a post is scheduled or published. See [Media](https://docs.omnisocials.com/media.md). **Scheduling.** Send `schedule_at` as an ISO 8601 time in UTC. OmniSocials publishes within one minute of that time. There is no `timezone` field and no queue. A time in the past returns `400 validation_error`. To publish now, send `publish_now: true`, or call `POST /posts/create-and-publish`. A post with neither `schedule_at` nor `publish_now` is saved as a draft. ## Webhooks Register a webhook with `POST /webhooks`. Webhooks belong to the workspace of the key that creates them. The response returns a `secret` once. Store it. ```bash curl -X POST https://api.omnisocials.com/v1/webhooks \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/omnisocials", "events": ["post.published", "post.failed"] }' ``` | Upload-Post event | OmniSocials event | Notes | |-------------------|-------------------|-------| | `upload_completed` | `post.published`, `post.failed` | `post.published` fires when at least one channel succeeds. A partial publish has `data.status` set to `warning`. Check each entry in `data.targets`. | | `social_account_connected`, `social_account_disconnected`, `social_account_reauth_required` | None | Poll `GET /accounts` and check `needs_reconnect`. | | None | `post.scheduled` | Sent when a post is scheduled through the API. | | None | `post.approved`, `post.rejected` | Approval workflow outcomes. | **Signatures.** Both APIs sign the same string: the timestamp, a dot, and the raw body, with HMAC-SHA256 and your webhook secret. Only the headers change. Upload-Post sends `X-Upload-Post-Signature: sha256=` and `X-Upload-Post-Timestamp`. OmniSocials sends one header, `X-OmniSocials-Signature: t=,v1=`. Deduplicate on `X-OmniSocials-Delivery`. See [Webhooks](https://docs.omnisocials.com/webhooks.md#verifying-signatures) for verification code. **Delivery.** Answer with a `2xx` within 10 seconds. Failed deliveries are retried twice, after 2 seconds and then 5 more. After that the event is dropped. Webhook URLs must use HTTPS. ## Platform coverage As of October 21, 2026, from docs.upload-post.com and the [OmniSocials platforms page](https://docs.omnisocials.com/platforms.md). | Platform | Upload-Post | OmniSocials | |----------|-------------|-------------| | Facebook | Yes (`facebook`) | Yes (`facebook`), Pages | | Instagram | Yes (`instagram`), no text-only posts | Yes (`instagram`), Business and Creator accounts | | Threads | Yes (`threads`) | Yes (`threads`) | | LinkedIn | Yes (`linkedin`), profiles and pages | Yes, profile (`linkedin`) and company page (`linkedin_page`) | | YouTube | Yes (`youtube`) | Shorts only (`youtube`) | | TikTok | Yes (`tiktok`) | Yes (`tiktok`) | | Pinterest | Yes (`pinterest`) | Yes (`pinterest`) | | Bluesky | Yes (`bluesky`) | Yes (`bluesky`) | | X | Yes (`x`) | Yes (`x`) | | Google Business Profile | Yes (`google_business`) | Yes (`google_business`) | | Telegram | Yes (`telegram`) | Yes (`telegram`), a channel or group | | Mastodon | Yes (`mastodon`) | Yes (`mastodon`) | | Reddit | Listed, currently unavailable | Coming soon | | Snapchat | Not listed | Coming soon | | Discord | Yes (`discord`) | No | | WordPress | Yes (`wordpress`) | No | | Slack, Nostr, Lemmy, Dev.to, Hashnode, Whop, Listmonk | Text posts | No | ## What is different - **Account connection.** OmniSocials has no `generate-jwt` or OAuth start endpoint. Accounts are connected in the dashboard. If your product lets end users connect their own accounts through your app, OmniSocials does not support that today. - **No profiles API.** Workspaces are created in the dashboard, and each API key reaches one workspace. - **One account per channel.** A workspace posts to one account per channel ID, including one Facebook Page, one LinkedIn page, and one Google Business location. Pages cannot be picked per request. - **JSON, not multipart.** Posts are JSON. Media is a URL or an ID from an earlier upload. - **File size.** The largest file OmniSocials takes is 1 GB, through `upload-from-url`. Direct uploads and `media_urls` stop at 100 MB per file. - **YouTube is Shorts only**, up to 3 minutes. Documents must be PDF. - **Rate limit.** OmniSocials allows 100 requests per minute per key. Upload-Post documents 60 to 1,000 requests per minute, by plan and number of profiles. Limits are per key on OmniSocials, so you can split heavy work across keys. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). - **No queue, timezone field, or idempotency key.** - **X links.** X posts whose text contains a link use prepaid credits. Posts without a link do not. See [X](https://docs.omnisocials.com/platforms/x.md). - **Fewer webhook events.** There are no account events. - **Error shape.** Upload-Post errors use `success`, `message`, and `error_code`. OmniSocials errors nest under `error`, with `code`, `message`, and optional `details`. Branch on `error.code`. - **Fewer channels.** Discord, WordPress, Slack, and the other text-only channels are not part of OmniSocials. Reddit is not live yet. - **No test mode.** Neither API documents one. On OmniSocials, use drafts or a workspace with test accounts. - **What OmniSocials adds.** A [Social inbox](https://docs.omnisocials.com/inbox.md) API for DMs and comments, approval workflows with `post.approved` and `post.rejected` events, alt text per image, PDF to carousel on Instagram, TikTok, Threads, and Pinterest, hashtag sets, and Instagram comment-to-DM automations. ## Migration steps 1. Create an OmniSocials account. Add one workspace for each Upload-Post profile. 2. Connect each social account in the dashboard, under **Settings → Organisation → Workspaces**. Pick the Facebook Page, LinkedIn page, and Google Business location there. 3. Create one API key per workspace under **Settings → API**. Start with `posts:write`, `posts:read`, `media:write`, `accounts:read`, and `webhooks:manage`. 4. Replace each Upload-Post username in your config with that workspace's key. 5. Call `GET /accounts` with each key to see the connected channels. For Pinterest, copy the board IDs from `GET /accounts/{id}`. 6. Replace the four upload calls with one `POST /posts/create`. Send JSON. Move files to `media_urls`, or upload them first and pass `media_ids`. Rename the fields with the [field mapping](#field-mapping) table. 7. Convert `scheduled_date` and `timezone` to a UTC `schedule_at`. 8. Register your webhooks with `POST /webhooks`. Swap the signature check and map the events. 9. Recreate the posts that are still scheduled in Upload-Post with `schedule_at`. Then cancel them with `DELETE /api/uploadposts/schedule/`, so nothing publishes twice. 10. Test with drafts first, since there is no test mode. Then publish one post with `publish_now: true` and check `published_urls` on `GET /posts/{id}`. :::tip Posts without `schedule_at` or `publish_now` are drafts and never publish on their own. Use them to check your field mapping against real accounts. ::: --- ## Document: Migrate from Zernio (Late) to the OmniSocials API Move a Zernio (formerly Late) integration to the OmniSocials API: auth, endpoint and field mapping, media, scheduling, webhooks, and platform coverage. URL: https://docs.omnisocials.com/migrate/zernio # Migrate from Zernio (Late) to the OmniSocials API This guide is for developers who post through the Zernio API (formerly Late, getlate.dev) and want to move to the OmniSocials API. Most of the work is renaming fields. The create-post body is flatter, accounts are referenced by channel ID, scheduled times go in as UTC, and webhook signatures use a different header. The biggest change is that social accounts are connected in the OmniSocials dashboard, not through an API connect flow. Zernio facts on this page come from docs.zernio.com and were checked on October 21, 2026. If your code still uses the Late names (`LATE_API_KEY`, the `late` Python package), the same steps apply. ## Concepts | Zernio | OmniSocials | Notes | |--------|-------------|-------| | API key (`sk_...`) | API key (`omsk_live_...`) | Both go in `Authorization: Bearer`. An OmniSocials key belongs to one workspace. | | Restricted key (`zrk_...`) | Key scopes | Pick scopes such as `posts:write` or `media:read` when you create the key. See [Authentication](https://docs.omnisocials.com/authentication.md). | | Profile | Workspace | A workspace is one set of connected accounts, usually one brand or client. Create workspaces in the dashboard. | | Account (`_id`) | Account (`id`, for example `368204_instagram`) | You can also pass the bare channel ID, such as `instagram`. A workspace has one account per channel. | | Connect URL (`GET /v1/connect/{platform}`) | Dashboard connection | Accounts are connected only in the dashboard, never through the API. | | `platforms[]` on a post | `accounts` on a post | A flat array of account IDs or channel IDs. | | `customContent` per platform | `content` object keyed by channel | `default` is the fallback text. | | Queue and slots | None | `GET /analytics/best-times` recommends slots. You pick the time. | | Post status `publishing` | `posting` | | | Post status `partial` | `warning` | Published on some channels. `errors` names the rest. | | Webhook settings | Webhooks | Registered with `POST /webhooks`. | ## Authentication Both APIs use a Bearer token in the `Authorization` header. ```text Zernio: Authorization: Bearer sk_... OmniSocials: Authorization: Bearer omsk_live_... ``` The difference is in what a key can reach. With Zernio, you pass a `profileId` to pick a profile. An OmniSocials key is bound to one workspace, which you choose when you create the key. If you serve several brands, create one workspace and one key per brand, and store the key next to the brand in your own database. `GET /accounts` returns `workspace_id` and `workspace_name`, so you can check which workspace a key belongs to. Create keys under **Settings → API** in [app.omnisocials.com](https://app.omnisocials.com). A key's scopes cannot be edited later. If you need more, create a new key. ## Endpoint mapping All OmniSocials paths are relative to `https://api.omnisocials.com/v1`. Zernio paths are relative to `https://zernio.com/api`. | Zernio | OmniSocials | Notes | |--------|-------------|-------| | `POST /v1/posts` | `POST /posts/create` | `POST /posts` is an alias. `POST /posts/create-and-publish` publishes at once. | | `GET /v1/posts` | `GET /posts` | Filter with `status`. Page with `limit` (max 100) and `offset`. | | `GET /v1/posts/{postId}` | `GET /posts/{id}` | | | `PUT /v1/posts/{postId}` | `PATCH /posts/{id}` | Drafts and scheduled posts only. Send only the fields you change. | | `DELETE /v1/posts/{postId}` | `DELETE /posts/{id}` | Removes the post from OmniSocials and cancels a scheduled publish. It does not delete a live post on the platform. | | `POST /v1/posts/{postId}/retry` | `POST /posts/{id}/retry` | Retries only the failed channels, up to 3 times per channel. | | `POST /v1/posts/{postId}/unpublish` | None | Delete live posts from the dashboard or in the platform's app. | | `POST /v1/posts/{postId}/edit` | None | Published posts cannot be changed. | | `POST /v1/posts/bulk-upload` | None | Call `POST /posts/create` once per post. | | `POST /v1/media/presign` | `POST /media/create-upload-url` | Or skip uploads and pass `media_urls` on the post. See below. | | `GET /v1/profiles` and profile CRUD | None | Manage workspaces in the dashboard. | | `GET /v1/accounts` | `GET /accounts` | Returns the key's workspace and its connected accounts. | | `GET /v1/accounts/health` | `GET /accounts` | Read `status` and `needs_reconnect` on each account. | | `GET /v1/accounts/follower-stats` | `GET /analytics/accounts` | | | `GET /v1/connect/{platform}` | None | Connect accounts in the dashboard. | | `GET /v1/queue/slots`, `GET /v1/queue/next-slot` | `GET /analytics/best-times` | Recommendations from your own history, not a queue. | | `GET /v1/analytics` | `GET /analytics/overview`, `GET /analytics/posts/{id}`, `GET /analytics/posts?ids=` | Bulk reads take up to 100 post IDs. | | `POST /v1/webhooks/settings` and CRUD | `POST /webhooks`, `GET`, `PATCH`, `DELETE /webhooks/{id}` | `POST /webhooks/{id}/rotate-secret` rotates the secret. | | `GET /v1/webhooks/logs` | `GET /webhooks/{id}` | Returns `failure_count` and `last_failure`. There is no full delivery log. | | `POST /v1/webhooks/test` | None | Subscribe to `post.scheduled` and schedule a post through the API. That sends a real event. | | `POST /v1/tools/validate/post` | `POST /media/check` | Checks media against your connected channels. There is no full post validation call. | | `GET /v1/inbox/conversations`, `GET /v1/inbox/comments` | `GET /inbox/conversations` | DMs, comments, and mentions in one list. Filter with `type`. | | `POST /v1/inbox/conversations/{conversationId}/messages` | `POST /inbox/conversations/{conversationId}/reply` | Needs the opt-in `inbox:write` scope. See [Social inbox](https://docs.omnisocials.com/inbox.md). | ## Create a post The same post in both APIs: one image to LinkedIn and Instagram, scheduled for 09:00 in Amsterdam on November 2, 2026. Zernio: ```bash curl -X POST https://zernio.com/api/v1/posts \ -H "Authorization: Bearer $ZERNIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Golden hour at the pier.", "mediaItems": [ { "url": "https://example.com/pier.jpg", "type": "image" } ], "platforms": [ { "platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" }, { "platform": "instagram", "accountId": "66b2e19d8c3f5a7e9d0b1c2e" } ], "scheduledFor": "2026-11-02T09:00:00", "timezone": "Europe/Amsterdam" }' ``` OmniSocials: ```bash curl -X POST https://api.omnisocials.com/v1/posts/create \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Golden hour at the pier.", "media_urls": ["https://example.com/pier.jpg"], "accounts": ["linkedin", "instagram"], "schedule_at": "2026-11-02T08:00:00Z" }' ``` OmniSocials has no `timezone` field, so the local time is converted to UTC first (Amsterdam is UTC+1 in November). The response is a `201` with the post under `data` and `"status": "scheduled"`. Per-platform text works the same way, but it lives in one `content` object instead of on each platform entry: ```json { "content": { "default": "Golden hour at the pier.", "linkedin": "Golden hour at the pier. Shot on our team walk this week." }, "accounts": ["linkedin", "instagram"], "media_urls": ["https://example.com/pier.jpg"], "publish_now": true } ``` ## Field mapping | Zernio field | OmniSocials field | Notes | |--------------|-------------------|-------| | `content` | `content` | A string, or an object with `default` and channel keys. | | `platforms[].platform` and `platforms[].accountId` | `accounts` | Account `id` from `GET /accounts`, or the channel ID. Alias: `channels`. | | `platforms[].customContent` | `content.linkedin`, `content.x`, and so on | One key per channel ID. | | `platforms[].customMedia` | `media_urls` or `media_ids` as an object keyed by channel | An empty array opts a channel out of media. | | `platforms[].platformSpecificData` | A top-level object named after the channel | For example `tiktok.privacy_level`, `pinterest.board_id`, `youtube.privacy_status`. See [Platforms](https://docs.omnisocials.com/platforms.md). | | `tiktokSettings` | `tiktok` | | | `facebookSettings` | `facebook` | | | `title` | `youtube.title`, `tiktok.title`, `pinterest.title` | Titles are set per channel. | | `mediaItems[].url` | `media_urls` | Up to 10 URLs, 100 MB per file. | | `mediaItems[].type` | None | The type is read from the file. | | `scheduledFor` | `schedule_at` | ISO 8601 in UTC, for example `2026-11-02T08:00:00Z`. Alias: `scheduled_at`. | | `timezone` | None | Convert to UTC before you send. | | `publishNow` | `publish_now` | | | `isDraft` | Leave out `schedule_at` and `publish_now` | A post without either is a draft. `draft: true` with `schedule_at` keeps the time on the calendar without publishing. | | `queuedFromProfile`, `queueId` | None | Choose the time yourself, or use `GET /analytics/best-times`. | | `recycling` | None | | | `dryRun` | None | | | `Idempotency-Key` header | None | Store the returned post `id` and check it before you retry a create. | ## Media and scheduling **Media.** Zernio uploads take three calls: presign, `PUT` the file, then use the `publicUrl`. OmniSocials offers three shorter routes: - **`media_urls` on the post.** Pass public URLs and OmniSocials downloads them. Up to 10 files, each up to 100 MB. - **`POST /media/upload`.** A multipart upload with a `file` field, up to 100 MB. Use the returned `data.id` in `media_ids`. - **`POST /media/upload-from-url`.** OmniSocials fetches a public URL of up to 1 GB. Files above about 100 MB come back with `status: "processing"`. Poll `GET /media/{id}` until it is `ready`. For a client that cannot send your API key, `POST /media/create-upload-url` returns a single-use `upload_url` that expires after 10 minutes. POST a multipart `file` to it with no auth header. The largest file OmniSocials takes is 1 GB, through `upload-from-url`. Zernio's presign limit is 5 GB. A PDF is turned into one image per page. On LinkedIn it publishes as a document. See [Media](https://docs.omnisocials.com/media.md). **Scheduling.** Send `schedule_at` as an ISO 8601 time in UTC. OmniSocials publishes within one minute of that time. Two behaviors differ from Zernio: - A time in the past returns `400 validation_error`. Zernio publishes a past `scheduledFor` at once. To publish now, send `publish_now: true`. - There is no queue. Store your own slots, or read `GET /analytics/best-times?platform=instagram` for recommended times. ## Webhooks Register a webhook with `POST /webhooks` and a list of events. The response returns a `secret` once. Store it. ```bash curl -X POST https://api.omnisocials.com/v1/webhooks \ -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/omnisocials", "events": ["post.published", "post.failed"] }' ``` | Zernio event | OmniSocials event | Notes | |--------------|-------------------|-------| | `post.scheduled` | `post.scheduled` | Sent only for posts scheduled through the API. | | `post.published` | `post.published` | Fires when at least one channel succeeds. | | `post.partial` | `post.published` | `data.status` is `warning`. Check each entry in `data.targets`. | | `post.failed` | `post.failed` | Every channel failed. | | `post.platform.published`, `post.platform.failed` | None | Read the per-channel results in `data.targets`. | | `post.cancelled`, `post.recycled`, `post.platform.deleted`, `post.external.*` | None | | | `account.connected`, `account.disconnected` | None | Poll `GET /accounts` and check `needs_reconnect`. | | `message.received`, `comment.received` | None | Poll `GET /inbox/conversations` or `GET /inbox/next`. | | None | `post.approved`, `post.rejected` | Approval workflow outcomes. | **Signatures.** Zernio sends `X-Zernio-Signature`, an HMAC-SHA256 of the raw body. OmniSocials sends `X-OmniSocials-Signature: t=,v1=`. The HMAC-SHA256 covers the timestamp, a dot, and the raw body, keyed with your webhook secret. Deduplicate on `X-OmniSocials-Delivery`. See [Webhooks](https://docs.omnisocials.com/webhooks.md#verifying-signatures) for verification code. **Delivery.** Answer with a `2xx` within 10 seconds. Failed deliveries are retried twice, after 2 seconds and then 5 more. After that the event is dropped. Webhook URLs must use HTTPS. ## Platform coverage As of October 21, 2026, from docs.zernio.com and the [OmniSocials platforms page](https://docs.omnisocials.com/platforms.md). | Platform | Zernio | OmniSocials | |----------|--------|-------------| | Facebook | Yes (`facebook`) | Yes (`facebook`), Pages | | Instagram | Yes (`instagram`) | Yes (`instagram`), Business and Creator accounts | | Threads | Yes (`threads`) | Yes (`threads`) | | LinkedIn | Yes (`linkedin`) | Yes, profile (`linkedin`) and company page (`linkedin_page`) | | YouTube | Yes (`youtube`) | Shorts only (`youtube`) | | TikTok | Yes (`tiktok`) | Yes (`tiktok`) | | Pinterest | Yes (`pinterest`) | Yes (`pinterest`) | | Bluesky | Yes (`bluesky`) | Yes (`bluesky`) | | X | Yes (`twitter`) | Yes (`x`) | | Google Business Profile | Yes (`googlebusiness`) | Yes (`google_business`) | | Telegram | Yes, as a messaging channel (`telegram`) | Yes (`telegram`), a channel or group | | Mastodon | Not listed | Yes (`mastodon`) | | Reddit | Yes (`reddit`) | Coming soon | | Snapchat | Closed beta (`snapchat`) | Coming soon | | WhatsApp, Discord, Slack | Yes, as messaging channels | No | ## What is different - **Account connection.** OmniSocials has no connect-URL endpoint. Accounts are connected in the dashboard. If your product lets end users connect their own accounts through your app, OmniSocials does not support that today. - **No profiles API.** Workspaces are created in the dashboard, and each API key reaches one workspace. - **One account per channel.** A workspace holds one account per channel ID. Two Instagram accounts need two workspaces. - **Rate limit.** OmniSocials allows 100 requests per minute per key. Zernio's documented limit runs from 60 to 1,200 requests per minute, by the number of connected accounts. Limits are per key on OmniSocials, so you can split heavy work across keys. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). - **Fewer post operations.** There is no queue, recycling, bulk upload, idempotency key, dry run, edit of a published post, or unpublish through the API. - **Times are UTC.** There is no `timezone` field, and past times are refused. - **X links.** X posts whose text contains a link use prepaid credits. Posts without a link do not. See [X](https://docs.omnisocials.com/platforms/x.md). - **Fewer webhook events.** There are no account, inbox, or per-platform events, no test endpoint, and no full delivery log. - **Error shape.** Zernio errors are flat, with `error`, `type`, and `code` at the top level. OmniSocials errors nest under `error`, with `code`, `message`, and optional `details`. Branch on `error.code`. - **No messaging or ads channels.** WhatsApp, Discord, Slack, SMS, and ads APIs are not part of OmniSocials. YouTube is Shorts only, and Reddit is not live yet. - **No test mode.** Neither API documents a general sandbox. On OmniSocials, use drafts or a workspace with test accounts. - **What OmniSocials adds.** Approval workflows with `post.approved` and `post.rejected` events, alt text per image, PDF to carousel and LinkedIn documents, automatic first comments, hashtag sets, Instagram comment-to-DM automations, and Mastodon. ## Migration steps 1. Create an OmniSocials account. Add one workspace for each Zernio profile. 2. Connect each social account in the dashboard, under **Settings → Organisation → Workspaces**. 3. Create one API key per workspace under **Settings → API**. Start with `posts:write`, `posts:read`, `media:write`, `accounts:read`, and `webhooks:manage`. 4. Call `GET /accounts` with each key. Map every Zernio `accountId` to an OmniSocials channel ID or account `id`. 5. Change the base URL and the key. Rename the post fields with the [field mapping](#field-mapping) table. Convert `scheduledFor` and `timezone` to a UTC `schedule_at`. 6. Replace the presign upload with `media_urls`, or with `POST /media/upload` and `media_ids`. 7. Register your webhooks with `POST /webhooks`. Swap the signature check and map the events. 8. Recreate the posts that are still scheduled in Zernio with `schedule_at`. Then delete them in Zernio with `DELETE /v1/posts/{postId}`, so nothing publishes twice. 9. Test with drafts first, since there is no test mode. Then publish one post with `publish_now: true` and check `published_urls` on `GET /posts/{id}`. 10. Handle `429` responses. Wait for the `Retry-After` header, then retry. :::tip Posts without `schedule_at` or `publish_now` are drafts and never publish on their own. Use them to check your field mapping against real accounts. ::: --- ## Document: Glossary of OmniSocials API terms Short definitions of OmniSocials API terms: workspaces, channels, accounts, posts, media, the inbox, webhooks, MCP and the CLI, with the field each maps to. URL: https://docs.omnisocials.com/glossary # Glossary of OmniSocials API terms The terms you meet in these docs, grouped by topic. Each entry names the field or endpoint it maps to and links to the page that covers it in full. ## Access and billing **Organisation.** The top-level OmniSocials account that holds your workspaces, team members, billing and credits. It is managed in the dashboard under **Settings → Organisation**; the API has no organisation endpoint. See [Billing](https://docs.omnisocials.com/billing.md). **Workspace.** One set of connected social accounts, typically one brand or one client. Every API key belongs to one workspace, and `GET /accounts` returns its `workspace_id` and `workspace_name`. See [Authentication](https://docs.omnisocials.com/authentication.md). **Seat.** A team member on the organisation's plan, billed as its own line item next to the workspaces. The organisation owner does not use a seat. See [Pricing](https://docs.omnisocials.com/pricing.md#team-members). **API key.** A long-lived token created under **Settings → API**, always starting with `omsk_live_`. Send it on every request as `Authorization: Bearer omsk_live_...`. See [Authentication](https://docs.omnisocials.com/authentication.md). **Scope.** A permission on an API key, such as `posts:write` or `analytics:read`. A request without the needed scope returns `403`, and the inbox and automation scopes are opt-in when you create the key. See [Scopes](https://docs.omnisocials.com/authentication.md#scopes). **Rate limit.** 100 requests per minute per API key, on a rolling 60 second window. Over the limit the API answers `429 rate_limit_exceeded` with a `Retry-After` header. See [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md). **Credits.** A prepaid balance on the organisation, used only for X posts that contain a link (20 credits each) and for opt-in X DMs. A short balance returns `402 x_credits_insufficient`; credits are topped up in the dashboard. See [Billing](https://docs.omnisocials.com/billing.md#credits-and-x-link-posts). ## Channels and accounts **Platform.** A social network, such as Instagram or LinkedIn. OmniSocials supports 12 platforms. See [Platforms](https://docs.omnisocials.com/platforms.md). **Channel.** A posting target with a stable string ID, such as `instagram` or `linkedin_page`. There are 13 channels, because LinkedIn counts twice: `linkedin` for a profile and `linkedin_page` for a company page. See [How channel IDs are used](https://docs.omnisocials.com/platforms.md#how-channel-ids-are-used). **Account.** One connected profile, page or chat in a workspace, listed by `GET /accounts`. Its `id` (for example `368204_instagram`) goes in a post's `accounts` array, and accounts are connected in the dashboard, never through the API. See [Connecting accounts](https://docs.omnisocials.com/platforms.md#connecting-accounts). **needs_reconnect.** A flag on an account in `GET /accounts`: its connection was revoked or expired, and posts to it fail until someone reconnects it in the dashboard. `reauth_reason` carries the platform's reason. See [Known issues](https://docs.omnisocials.com/known-issues.md). ## Posts **Post.** The core resource: text, media and options for one or more accounts, created with `POST /posts/create`. See [Posts](https://docs.omnisocials.com/creating-posts.md). **Post type.** The `type` field: `post` (a feed post, the default), `story` or `reel`. Stories and reels need media. See [Post types](https://docs.omnisocials.com/creating-posts.md#post-types). **Draft.** A post with `status: "draft"`. Every post is a draft unless the request has `publish_now` or `schedule_at`, and a draft never publishes on its own. See [Posts](https://docs.omnisocials.com/creating-posts.md#minimal-request). **Planned draft.** A draft that keeps a time: `draft: true` together with `schedule_at`. It shows on the dashboard calendar but never publishes until someone schedules it. See [Planned drafts](https://docs.omnisocials.com/creating-posts.md#planned-drafts). **Scheduled post.** A post with `schedule_at` set to a future ISO 8601 time in UTC. It has `status: "scheduled"` and publishes within a 1 minute window of that time. See [Scheduling](https://docs.omnisocials.com/creating-posts.md#scheduling). **publish_now.** `"publish_now": true` on `POST /posts/create` publishes right away; `POST /posts/create-and-publish` is the same call. The response has `status: "posting"`, so poll `GET /posts/:id` for the outcome. See [Publishing](https://docs.omnisocials.com/creating-posts.md#publishing). **Post status.** The `status` field: `draft`, `in_approval`, `rejected`, `scheduled`, `posting`, `published`, `warning` or `failed`. `warning` means some channels published and others failed. See [Post statuses](https://docs.omnisocials.com/creating-posts.md#post-statuses). **Carousel.** One feed post with several media items, passed as 2 or more entries in `media_ids` or `media_urls`. Each channel publishes it its own way, for example a carousel of up to 10 items on Instagram, a document carousel on LinkedIn, or a carousel pin of 2 to 5 images on Pinterest. See [Capability matrix](https://docs.omnisocials.com/platforms.md#capability-matrix). **Story.** `type: "story"`: a 24 hour post on Instagram or Facebook. Up to 10 media items, each published as its own story, and no caption on the story itself. See [Stories](https://docs.omnisocials.com/creating-stories.md). **Reel.** `type: "reel"`: one vertical video, published as an Instagram or Facebook Reel, a YouTube Short or a TikTok video. Telegram sends it as a video message. See [Reels](https://docs.omnisocials.com/creating-reels.md). **Thread.** `thread_parts` under `x`, `bluesky`, `mastodon` or `threads`: 2 to 25 parts, each published as a reply to the one before. See [X threads](https://docs.omnisocials.com/platforms/x.md#posting-threads). **Poll.** `poll` under the `x`, `threads`, `mastodon` or `telegram` key, where the post text is the question. LinkedIn polls use the top-level `linkedin_poll` field. See [Polls](https://docs.omnisocials.com/creating-posts.md#polls). **First comment.** `first_comment` under a channel's key (Instagram, Facebook, LinkedIn, YouTube, TikTok and Telegram): a comment posted right after the post publishes. The outcome comes back as `first_comment_result`. See [Automatic first comment](https://docs.omnisocials.com/creating-posts.md#automatic-first-comment). **Hashtag set.** A named group of hashtags, managed with `/hashtag-sets` and applied with `hashtag_set` (or `hashtag_set_id`) when you create a post. The tags are merged into the text once, so editing the set later never changes existing posts. See [Hashtag sets](https://docs.omnisocials.com/creating-posts.md#hashtag-sets). **Approval workflow.** A chain of review steps and approvers, set up in the dashboard. Pass its id as `approval_workflow_id` with `schedule_at`, and the post waits as `in_approval` until the last step approves it. See [Approval workflows](https://docs.omnisocials.com/creating-posts.md#approval-workflows). **Retry.** `POST /posts/:id/retry` publishes the failed channels of a `failed` or `warning` post again, on the same post. Each channel can be retried 3 times. See [Retries and duplicate posts](https://docs.omnisocials.com/retries.md). **Native post ID.** The platform's own id of a published post, such as an Instagram media id or a tweet id. Webhooks carry it as `data.targets[].native_post_id`, and the live links are in `published_urls` on the post. See [Webhooks](https://docs.omnisocials.com/webhooks.md#payload-shape). ## Media **Media item.** A file in the workspace's media library (an image, a video or a PDF) with an `id`. Create one with `POST /media/upload` or `POST /media/upload-from-url`. See [Media](https://docs.omnisocials.com/media.md). **media_ids and media_urls.** The two ways to attach media to a post: `media_ids` takes library ids, and `media_urls` takes public URLs that the API downloads (up to 10, 100 MB each). Both accept a flat array or an object keyed by channel ID. See [Attaching media](https://docs.omnisocials.com/creating-posts.md#attaching-media). **Alt text.** An accessibility description per media item, passed as `{ "url": "...", "alt": "..." }` or `{ "id": "...", "alt": "..." }`. It is delivered to the platforms that support it, up to 1500 characters. See [Alt text](https://docs.omnisocials.com/creating-posts.md#alt-text-accessibility-descriptions). ## Inbox and automations **Inbox conversation.** A DM thread, or all comments or mentions on one post, with an opaque `conversation_id` that you URL-encode in paths. List them with `GET /inbox/conversations`, which needs the opt-in `inbox:read` scope. See [Social inbox](https://docs.omnisocials.com/inbox.md). **Work queue.** `GET /inbox/next`: the one conversation that should be answered next, with its messages and post. Reply with `include_next: true` to get the following item in the same response. See [Working through the inbox](https://docs.omnisocials.com/inbox.md#working-through-the-inbox). **Automation (comment-to-DM).** An Instagram rule that sends a DM to everyone who comments a keyword (or sends it in a DM or a story reply), once per person. Manage it with `/automations` or `comment_dm_automation` on a post; it needs the opt-in `automations:write` scope. See [Automations](https://docs.omnisocials.com/automations.md). ## Webhooks and tools **Webhook.** A public HTTPS endpoint you register with `POST /webhooks` to receive signed POST requests for five post events. It needs the `webhooks:manage` scope. See [Webhooks](https://docs.omnisocials.com/webhooks.md). **Webhook secret.** The 64 character hex `secret` returned once when you create a webhook. It signs every delivery (HMAC-SHA256, in `X-OmniSocials-Signature`) and is replaced with `POST /webhooks/:id/rotate-secret`. See [Verifying signatures](https://docs.omnisocials.com/webhooks.md#verifying-signatures). **MCP server.** OmniSocials' Model Context Protocol server, hosted at `https://mcp.omnisocials.com` or run locally with `npx -y @omnisocials/mcp-server`. It exposes the API as tools, such as `create_post`, for AI assistants. See [MCP server](https://docs.omnisocials.com/mcp-server.md). **CLI.** `omnisocials`, a single-file Node.js command line tool that covers the v1 API, with `--json` output on every command. See [CLI](https://docs.omnisocials.com/cli.md). **Agent skill.** The OmniSocials skill for coding agents, installed with `npx skills add OmniSocials/omnisocials-agent-skills`: a `SKILL.md` instruction file plus the CLI. It suits agents that can run shell commands but have no MCP support. See [Agent Skills](https://docs.omnisocials.com/integrations/agent-skills.md). **SDK.** An official client library for the API, available for Node, Python, Go, Ruby, PHP, Java, .NET and Rust. See [SDKs](https://docs.omnisocials.com/sdks.md). **OpenAPI file.** The machine-readable spec of every endpoint, at [docs.omnisocials.com/openapi.json](https://docs.omnisocials.com/openapi.json). The [API reference](https://docs.omnisocials.com/api) is generated from it. --- ## Document: Changelog URL: https://docs.omnisocials.com/changelog # Changelog {/* GENERATED FILE - do not edit. Built from frontend/src/content/changelog/ by docs-zudoku/scripts/generate-changelog.mjs during docs:build. */} New features, improvements, and fixes across OmniSocials, newest first. Updated with every release. ## October 20, 2026 ### Every post in Analytics Analytics now shows the posts you make outside OmniSocials too: the reel you post in the Instagram app, the TikTok you film on the spot, the video you upload to YouTube yourself. They sit in the post list next to the posts you send through OmniSocials, and they count in your cards, your channels and your reports. _A Reel posted in the Instagram app arrives in the Analytics post list with a small phone mark, and the switch turns the posts made outside OmniSocials off._ A small phone mark next to the date shows which posts were made outside OmniSocials. Click one to see its numbers over time, or open it on the platform. ### On your calendar too Your calendar shows them as well, on the day and at the time they went live, between the posts you plan in OmniSocials. They are quiet grey cards with the same phone mark, so the posts you plan stay the ones that stand out. Click one to see its numbers over time. _A Reel posted in the Instagram app shows up on today in the calendar as a grey card with a phone mark, and the switch next to Week and Month turns the posts made outside OmniSocials off and on._ ### One switch Turn off **Include posts made outside OmniSocials** above the post list to see only your OmniSocials posts again. The switch works for the whole page and for your reports. The same switch sits on your calendar next to Week and Month: turn it off in one place and it is off in both. ### Which channels Instagram, Facebook, TikTok, YouTube, Threads, Pinterest, Bluesky and Mastodon. OmniSocials reads the last 90 days of each account when it starts and keeps the numbers fresh for 90 days after a post goes live. A new post shows up within a few hours, on YouTube within a day. LinkedIn does not let apps read the posts on a personal profile, and Telegram does not let bots read a channel's history, so those two stay out. ### Your posts as a card in Claude When you make, change or publish a post with Claude, the post now shows up in the chat as a card. You see it the way each platform will show it, with the photo, the caption and the account, so you no longer have to open OmniSocials to check it. _In a chat with Claude, the OmniSocials card shows a scheduled post, and a click on LinkedIn and then X switches the preview._ The card lists every channel of the post with its state: the planned time, Posting, Posted with a link to the live post, or Failed with the reason. Click a channel to see its preview. **Show all channels** puts every channel side by side, and **Open in OmniSocials** takes you to the post in the app. ### Follow a post as it goes live Ask Claude to post something now, and the card follows the post until every channel is done. Then the card shows a button that opens the live post. ### Where the card works The card works in Claude on the web, in the desktop app and in the phone apps, with the OmniSocials connector. The first time, Claude asks whether it may show the card. Not connected yet? Follow the [Claude guide](https://docs.omnisocials.com/integrations/claude). #### Improved - **A link to the post in every answer** - when an AI agent publishes, retries, approves or rejects a post, its answer now ends with an "Open in OmniSocials" link, as it already did after creating a post. Tools without cards, such as Claude Code, show this link. ## October 17, 2026 ### Post to Telegram Telegram is now a channel in OmniSocials. Plan and schedule posts for your Telegram channel or group in the same composer and on the same calendar as your other channels. _A post from the OmniSocials composer arrives in a Telegram channel with a link button, and the first reactions come in._ A Telegram post can hold text, photos, videos, GIFs, files or an album of up to 10 photos and videos. The Telegram options let you send a post without a sound, pin it, stop forwarding, blur the media until someone taps it, put the text above the media, add up to three link buttons and add a first comment. Telegram polls work too: the text of the post is the question. A long text under media is no problem. Telegram allows 1,024 characters under a photo or video, so OmniSocials sends the media first and the full text as a reply under it. ### Connect it in Telegram Open Channels and pick Telegram. The OmniSocials bot opens in Telegram. Press **Start**, tap **Choose a channel** or **Choose a group** and pick your chat. Telegram adds the bot as an admin that can post. OmniSocials checks that you are an admin of that chat, so nobody can connect a chat that is not theirs. ### Reactions and polls The post page shows the reactions per emoji and the votes of a poll. Telegram does not share view counts with apps, so views show N/A. The channel list shows your subscribers. #### Improved - **Repost to Telegram** - a repost automation can now copy posts from Instagram, TikTok, Facebook and the other sources to your Telegram channel or group. - **Telegram in the API** - the public API, the MCP server, the CLI and the SDKs take the `telegram` channel and its options. See the [Telegram guide](https://docs.omnisocials.com/platforms/telegram). - **N/A for numbers a channel does not report** - the post page and the Posts list show N/A instead of 0 when no channel of a post reports a number, such as views on Bluesky, Mastodon and Telegram. When some channels of a post report a number and others do not, a line under the numbers says which channel is left out. - **A fairer engagement rate** - on the post page, the engagement rate of a post on several channels now counts only the channels that report views. Before, the likes on Bluesky, Mastodon or Telegram counted without their views and made the rate too high. ## October 16, 2026 ### Dark mode OmniSocials now has a dark theme. By default it follows your computer or phone: a dark system gives you a dark OmniSocials, a light one keeps what you had. To choose for yourself, open your account menu at the bottom of the sidebar and pick System, Light or Dark under Theme. The same switch sits at the bottom of Settings. Every page, dialog and menu was redrawn for the dark theme, including the calendar, the composer, the inbox, analytics and the post previews. A preview of a post copies the platform's own dark look, so an Instagram or X post looks the way it will on the app. Your link-in-bio page keeps its own colours in both themes, and the editor now shows it on your page's background, as visitors see it. ### Review a post and fix it on one card Every post in Approvals is now one card. The text sits on the left in a field you can type in, the comments sit under it, and the post preview sits on the right with the date it publishes. At the bottom you pick one of three: **Request changes** sends it back with a note, **Edit yourself** opens the post in the editor, and **Approve** approves it. When you change the text on the card, the buttons turn into Undo, Save and Save and approve. When several channels share the text, the card says which other channels change with it, and the "Instagram only" switch gives that one channel its own text. The line above the buttons shows where the post is in the review, for example "Waiting for you, step 1 of 2". Click it to see every step and who approved. The same steps show in the list view, and under the approval workflow when you schedule a post, where you can also pick No approval. When you open a post from Approvals in the editor, a bar at the top shows whose post it is and the step of the review, with Request changes and Approve. After your decision you go back to Approvals. ### Drag drafts and files onto the calendar At the bottom of the calendar, a **Drafts** pill shows how many drafts wait for a date. Open it and drag a draft onto a day: the schedule window opens on that day, so you check the time and schedule it as usual. Drafts from the Drafts list in the sidebar drop the same way. Drop pictures or a video from your computer on a day to start a new post for that day. One video starts a Reel. ### Schedule a planned draft in one click A draft with a date now shows that date on its button, for example "Schedule for Wed 9 Dec, 10:00". One click schedules it at that time. The arrow next to it lets you change the date and time, or remove the date. When the date has passed, when the post needs approval or when TikTok is one of the channels, the button opens the schedule window first. #### Improved - **Small grey text is easier to read** - dates, ages and helper lines use a darker grey in the light theme and a lighter one in the dark theme, so they meet the readability line on every surface. - **Blue links read better on white** - the blue of links and text labels is a step darker in the light theme; buttons keep the OmniSocials blue. - **The browser bar on a phone matches the app** - in the dark theme the status and address area of Safari and Chrome turns dark with the page. - **Settings in one click** - the gear next to your name at the bottom of the sidebar opens Settings. Your account menu, with the theme and Log out, opens above your name. - **Every menu looks the same** - dropdowns and right-click menus now share one spacing and one corner shape, and a row that deletes or removes something is always red. #### Fixed - **A post sent back for changes skipped the review the second time** - when you edited a sent-back post and sent it again, it was scheduled without a new approval. It now goes back to the reviewers. - **A post left Approvals after the first step** - in a review with two steps, a post approved at step 1 disappeared from the Approvals page until you reloaded it. It now stays and moves on to step 2. ## October 14, 2026 #### Fixed - **Credit purchases had no invoice** - a top-up of credits for X link posts was charged to your card, but no invoice was made for it, and the Invoices tab listed your plan only. Every top-up and auto-refill is now an invoice on your organisation, listed under Settings, Organisation, Billing, Invoices, with a PDF to download. The confirmation email links to the invoice. ## October 12, 2026 ### Polls on X, Threads and Mastodon Write the question as the post text, then open **Poll** in the options bar of X, Threads or Mastodon and add 2 to 4 answers. On X you choose how long the poll runs, from an hour to 7 days. On Mastodon the choices follow your server, and you can allow several answers per person and hide the totals until the poll ends. Threads closes a poll 24 hours after it is published. The preview shows the poll the way each platform draws it. A poll replaces the media on that channel: none of the three platforms takes both, so turning a poll on takes the pictures off that channel and leaves the other channels as they are. On a thread the poll goes on the first post. LinkedIn polls work as before. The results arrive with the post statistics: the post page shows a Poll results card with the share of each answer and the time left. Through the API, pass `poll` under `x`, `threads` or `mastodon` on create and update. The Claude and ChatGPT connectors, the CLI and the 8 SDKs take the same field. #### Fixed - **An Instagram post that went live was reported as failed** - Instagram sometimes answers a publish with "Action blocked" after the post is already on the account. OmniSocials recorded the post as failed, and a retry published it a second time. It now checks your account before it reports a failure: when the post is there, it is kept with its link, and nothing is published twice. - **A false "Reconnect Instagram"** - the same answer, and Instagram's rate limits, flagged the connection as expired and sent the reconnect email, although the connection worked. One workspace reconnected Instagram 14 times in five days. A rate limit no longer asks for a reconnect. - **A free month offered while a payment was still open** - when the first payment after a trial failed and you then accepted a free month, the free month applied to the month after, while the open payment was collected a few days later. The offer is no longer shown while a payment is open. #### Improved - **A caption too long for the destination of a repost automation** - until now a caption over the limit was left out without a word up front, or cut without asking. The automation now asks you once: post it as a thread (X, Bluesky, Mastodon, Threads), cut it at a word, or skip the post. The limit follows your account, such as 25,000 characters on X Premium or your Mastodon server's own limit. The Skipped tab shows how many posts were left out for this reason and applies your choice to all of them with one click. You find the setting in the setup of the automation. - **A post the source shared no file for** - a reel with licensed music comes without its video, so the automation cannot copy it. The Skipped tab now has "Post it yourself" on such a post: it opens the editor with the caption and every channel that skipped the post filled in, and you add the video. - **The "Next" date of a repost automation** - with a long line of posts the date showed the day of the newest import, months away, instead of the next post. It now shows the real next post. ## October 11, 2026 A roadmap update: comment to DM for Instagram is live for every workspace with an Instagram account connected. ### Comment to DM for Instagram is live _Someone comments GUIDE under a post, gets a public reply and the guide in a DM, and the automation counts one more DM sent._ Ask people to comment a keyword under your post or reel, for example GUIDE. Each person who does gets your message as a private DM, with your link, offer or guide. The DM arrives a few seconds after the comment, and each person gets it once. You can also answer in public under the comment ("Sent you a DM! 📩"). Add a few variations and OmniSocials rotates them, so your replies do not all look the same. ### Three ways to start the DM One automation can listen to all three at the same time: - **A comment** with the keyword: on one post, on the next post you publish, or on every post of the account. A post you published outside OmniSocials works too. - **A DM** with the keyword. - **A reply to your story** with the keyword. The keyword matches as a whole word by default, in capitals or not, so "guide", "GUIDE" and "the guide please" all count. ### Set it up in a minute Open **Automations**, choose **New automation** and then **Send a lead magnet**. Pick the trigger, type the keyword, write the DM and turn it on. The phone next to the steps shows what people will see, and the bottom of the editor tells you what is still missing. You can also turn on comment to DM in the Instagram options when you create a post. The automation starts the moment the post is live. Every automation has an activity page: each person who matched, what they wrote, and what happened. DM sent, skipped with the reason, or failed with Instagram's message. You can also stop an automation after a number of DMs or on a date. ### Or ask your AI assistant With the OmniSocials connector in Claude or ChatGPT, ask for it in plain words: "Send everyone who comments GUIDE on my last reel the link to my guide". The assistant sets up the automation after you confirm. Developers use the new `/automations` endpoints of the API, with the **Automations** switch on when they create the API key. ### Instagram's rules Instagram allows one private reply per comment, within 7 days of the comment. A second message is possible only after the person answers you. OmniSocials sends up to 180 DMs an hour per workspace. ### What's next on the roadmap #### Coming soon - **OmniSocials Ads** - create and manage ad campaigns across your connected platforms, next to your organic posts. - **Embed OmniSocials in your own product** - build social media posting into your own software for your own users, with OmniSocials behind the scenes. - **Analytics for posts made outside OmniSocials** - the numbers of every post on your connected accounts, also the ones you publish directly on a platform. - **New integrations** - Telegram, Discord, Tumblr, WordPress, Whop, Slack, and X Articles. Is there something you want us to build? Send an email to [robert@omnisocials.com](mailto:robert@omnisocials.com). ## October 10, 2026 A big update. Most of the app got a new look, and a few things are new. Here is what changed, one part at a time. ### One bell for updates from every workspace _The bell gets a red badge with 3, opens, and lists updates from two workspaces._ The bell next to the workspace selector collects what needs you, from every workspace you are in: a post that failed, a post that waits for your approval, an approval, a request for changes, a comment or an edit in a review, a channel to reconnect, a teammate who joined, and milestones for your posts and followers. The red badge counts what is new, and a blue dot marks each update you have not opened yet. Click an update to go there; OmniSocials switches to its workspace first. On the phone, the bell sits in the top bar. ### Review posts in a feed and comment on any word _A reviewer selects words in a caption, suggests new ones and approves the post._ Approvals is now a feed. Every post that waits for you is a card with its real preview per channel. Approve it, or choose **Request changes** and write what to change. The tabs are For you, Sent by you, In review, Approved and Sent back, and the list view is one click away. Select words in a caption to comment on them, or suggest new words that the author applies with one click. On a Reel you comment on a moment in the video, on a Story on a point of a slide. You can also edit a caption in the card itself and choose **Save and approve**. A post you have decided folds into one line, and the message tells you who reviews next. Edits during a review keep the approvals: a change to the caption, the first comment or the time keeps every approval given, and the people who approved get a notice. A change to the media or the channels starts the review again from step 1, and the editor asks you first. ### Every post option in one bar under the editor _A switch chip turns on, the Location chip opens the panel and a place is picked._ The options of each channel are now chips in a bar under the editor. A switch turns on or off in place, a short list opens on its chip, and the rest opens one panel above the bar, on the page of that channel. After a pick, the panel goes back to the channel page, so you can set several options in a row. The channel icons at the top of the panel switch channel; **Done** or Escape closes it. ### The month view scrolls like a real calendar _The month view scrolls from October into November and the month list shows post counts._ The Month view is one long strip of weeks, like the calendar on a Mac. Scroll up or down and the title follows the month at the top. The arrows, **Today** and the month list jump there. Every past day is grey, and the 1st of a month carries its name. Click the title ("October 2026") to see every month with the number of posts in it; in Week view the list shows the weeks. A note on the calendar now looks like a paper note, and a notable day colours the top of its day in the colour of its category: holidays orange, shopping days red, awareness days green. ### A new Library with folders you can see into _A photo is dropped on the Library and appears under Today, next to the folders._ The tabs All, Images, Videos and PDFs show how many files each has. Folders are the first tiles of the grid, with their three newest files on top. Files sit in rows at their own shape, grouped by the day you uploaded them, and open full screen. Drop files anywhere on the page to upload them, or choose **Use in a post** to start a post with them. **New folder** sits at the top of the page, an empty folder offers **Move files here**, and you can sort by newest, oldest or biggest. **Add media** in the editor now shows the same Library, with an **In this post** column next to it: the files of the post in the order they publish. Drag to change the order, or remove a file with the x. The column shows the limits of your channels, and a picker opened from a channel tab changes only that channel. ### See how a post does at a glance _The post page with a card per metric, a chart over time and a line for a channel that did not post._ The post page opens with the numbers: a card per metric with a small chart and the change since the last check, one chart over time (all channels or per channel) with a line such as "Threads brought 99.8% of the views", and every channel with its share of the views. A channel that did not post gets one line with the cause and the next step: Try again, Edit post, Reconnect, open the platform, or Top up. When an automatic retry is planned, the line says at what time. **Details** shows the full message from the platform. The statistics also arrive on time now: every 2 hours in the first week of a post. Until now the checks ran up to 8 hours late, and a new post could show zeros for most of a day. ### Comments grouped per post in the Inbox _A post with new comments opens in the Inbox, the count in the sidebar goes down and a reply field opens._ All comments on a post are one conversation. The row shows who commented ("Daan and 2 others") and how many comments are new. Opening the post marks them all as read, so the count in the sidebar goes down at once. The filter takes several types and platforms at the same time, **Mark as unread** puts a conversation back, and **Open post** now also works for older Instagram comments. A comment that a comment-to-DM automation answered carries a tag. Like, hide, delete and reply work the same way on every platform that allows them. ### A new Settings, with Change password _Settings in full screen: a search for password leads to Profile, where Change password opens its dialog._ Settings opens over the whole window, with a menu on the left, a search (⌘K or Ctrl+K) and a switch between your organisations. Every page is new and calmer, from Profile (your name can now be changed) to Roles with one switch per permission and Approval workflows written as a sentence. To change your password, open Profile, then Security, and choose **Change password**. If you do not remember the current one, the same dialog emails you a reset link. After a change, or a reset by email, only the browser that made the change stays signed in, and you get an email with a button to reset the password right away in case it was not you. ### Tall images fit the Instagram feed _A 9:16 photo is moved inside the 4:5 frame and the Instagram preview follows._ A 9:16 image in an Instagram post is now cropped to 4:5 around the subject before it publishes, so the post looks the way you see it in the preview and Meta can boost it. Until now Instagram cut it itself, around the centre, and a boost was refused. Under the Instagram options, **Image fit** lets you drag the picture to choose what stays, or keep the original. Stories, Reels and your other channels keep the whole image. The API and the AI assistants take `media_fit` for the same choice. #### Improved - **Billing at a glance** - the Overview tab shows your plan, its price and your use of workspaces, extra users and credits in one block, with a bar for each. Credits and Invoices have their own tabs. Extra users are added with the card on file, without a new checkout, and the window says what is charged and when. - **Channels in one list** - the Channels window has three parts: Needs attention, Connected and Add a channel. Every step of a connection (a Facebook Page, a LinkedIn Page, a YouTube channel or a Google location to pick, a Bluesky handle or a Mastodon server to enter) now happens in that same window. After a sign-in the window opens with the new channel marked **Just added**. A channel that needs to be reconnected tells you how many planned posts wait for it. - **A clearer workspace menu** - each workspace shows its channels as a line of icons, with the ones to reconnect first. One **Channels** row opens the Channels window. If you are in more than one organisation, a row of organisation tiles switches between them. - **A shorter user menu** - the menu under your name has Profile, Settings, Billing (with the days left in a trial), Invite team, What's new, Refer and earn, Resources, Help and Log out. - **A new Reports page** - Reports shows complete numbers for the period you pick: totals with a chart, a card per channel, your audience, profile activity, and every post with a link to it on the platform. A number that a platform does not report says so, instead of showing 0. - **Scheduled posts in the editor** - a scheduled post shows when it publishes ("Publishes Sat 10 Oct, 22:45") next to **Save changes**; click the time to pick another one. The arrow on **Save changes** holds Change date and time, Post now, Turn into draft and Delete. - **Drafts in the sidebar** - each draft shows the first line of its caption as the title, the next lines in grey, its picture, its channels and its planned date. - **Automations at a glance** - every automation is one row with its flow (Instagram to TikTok, or a keyword to a DM), its status, its schedule and its counts. The comment-to-DM editor shows a phone with the comment and the DM next to its four steps, and the bottom of the editor always tells you what still stops a save. A new repost is set up one question at a time, and the activity page shows the picture of each person who commented. - **Comment-to-DM automations from the API and your AI assistant** - the new `/automations` endpoints create, pause, edit and delete a comment-to-DM automation and read its activity, and a post made through the API can carry its automation (`comment_dm_automation`). The MCP server (1.42.0), the CLI and the eight SDKs have the same. The API key needs the new **Automations** switch at key creation; an automation sends DMs in your name, so the scopes are opt-in, like the inbox. - **Notable days, one by one** - under Notable days, open a category and choose each day you want on your calendar. - **Filters on the Posts page** - each filter has its own icon and shows what you picked, with an x to clear it. The steps for views, likes and comments now fit each number. - **Empty pages show the next step** - an empty page (Approvals, Posts, Inbox, Library, Drafts and the lists in Settings) shows a small drawing and what to do next. - **What's new in the sidebar** - the Latest update card shows a small picture, and a big update like this one brings a card per change. Use the arrows to go through them, or the X to close one. - **A clearer reset page** - the reset link now shows what is wrong with a password under the field, and a link that has expired or was already used says so and offers a new one. #### Fixed - **X videos that failed with "stream is not defined"** - since October 8, an X post with a video that had to be converted first failed with this message. Open the post and choose **Try again** to publish it. - **A TikTok first comment that came hours late** - the first comment goes out once TikTok has the video ready, and that took up to 10 hours while the statistics checks ran late. **Try again** now sends it as soon as the video is ready, instead of saying it is still processing. - **The Videos tab in the Library** - it stopped after the newest files; it now finds every video. - **Week numbers** - in a year that starts in the middle of a week, the Week view skipped week 1 (4 January 2027 showed as week 2). - **Inviting a team member** - the role list in the invite window opened empty. - **Feature requests** - opening a feature request showed an error. - **Notable days** - the Super Bowl now falls on the second Sunday of February, and Cyber Monday shows again in a year when it falls in December. ## October 8, 2026 #### Fixed - **TikTok videos with static instead of sound** - a video exported with uncompressed sound (the default of DaVinci Resolve and Final Cut) played on TikTok as static with no audio, while the same post was fine on Facebook and Instagram. OmniSocials now converts the sound to the format every platform plays when you upload a video, and once more when an older video from your library goes to TikTok. The picture is not touched. - **A post without a video file stayed in the workflow queue** - when Instagram does not share the video of a post (a reel with licensed audio, or a post flagged for copyright), a workflow cannot repost it. Such a post stayed in the queue without a reason, the card counted it as "1 queued", and a workflow for existing posts never reached "completed". The post is now skipped with the reason on the workflow page, and the other posts continue as before. To publish it anyway, upload the video in a new post. - **The workflow card showed a date in the past as "Next"** - a workflow that reposts new content right away showed the time of its last repost as the next one, for days. The card now shows "Last" with that time, and "Next" only for a repost that is planned ahead. The "queued" number counts the posts that wait to publish, not the skipped ones. ## October 7, 2026 #### New - **Comment to DM for Instagram** - under **Automations**, choose **New automation** and **Send a lead magnet**, type the keyword, pick the post (one you published through OmniSocials or not, the next one you publish, or any post on the account), write the DM and turn it on. When someone comments the keyword, they get your message as a private reply, once per person. Optional: a public reply under the comment ("Sent you a DM! 📩") with variations that rotate, a limit on the number of DMs, and a stop date. The activity log shows every comment that matched and what happened to it: sent, skipped with the reason, or failed with Instagram's message. - **A keyword in a DM, or a reply to a story** - the trigger step has three triggers you can tick together: a comment on a post or reel, a direct message, and a reply to one of your stories. With the last two, the person's message starts the automation and your answer goes out at once as a DM, once per person. - **Comment to DM in the composer** - the Instagram options of a new post have the same section, so a scheduled post starts its automation the moment it is live. A section you switch off removes the post's automation. - **Workflows are now Automations** - the sidebar entry, the page and the links changed name; the repost workflows themselves did not change. Every automation on the page reads as one sentence ("Repost Reels from Instagram to TikTok, every day at 12:00 Amsterdam, oldest first"), with its status and counts next to it. Old links to Workflows keep working. - **Drafts with a date show on the calendar** - **Save draft** now asks for a date, or you pick **Save without a date**. A draft with a date sits on the calendar at that time with a dashed outline and a Draft label, on desktop and on the phone, and it never publishes until you schedule it. Drag a draft from the Drafts sidebar onto a day to plan it there, or drag a dashed card to another day to move it. The schedule window has **Save as draft** next to **Schedule Post**. - **The composer knows what it is editing** - a scheduled post opens with its time in a chip next to **Save changes**; Post now, Unschedule and Delete sit behind the more menu. Unschedule keeps the date and turns the post back into a draft on the calendar. A dated draft shows its date in a dashed chip next to **Schedule Post**; click either chip to change the time. - **API and MCP: planned drafts** - `draft: true` with `schedule_at` on `POST /api/v1/posts/create` keeps the time but stores a draft, so an agent can propose a slot that you confirm in the app. `PATCH /api/v1/posts/{id}` with `draft: false` schedules it at that time, and `draft: true` unschedules a post without losing its time. The MCP tools `create_post` and `update_post` have the same `draft` field (MCP 1.40.0). #### Improved - **Repost automations tell you when a connection is lost** - a repost whose source account needs to be reconnected now shows that on the list, with "Needs attention" first. Until now only a failed import did. - **Skip reasons on repost items** - an item the repost skipped (a text-only post for Instagram, a Pin without a caption) now shows why, and the activity has a **Posted** filter. ## October 5, 2026 #### New - **Tag products on a Pinterest Pin** - in the Pinterest options of a post, select "Add products" and pick the products that are in your image. People can then shop them from the Pin. You can tag up to 24 of your own product Pins. Pick them from your Pinterest catalog (select "Connect catalog" one time), or paste the link of a product Pin. Pinterest accepts your own products only: a public product Pin that links to a website you claimed on Pinterest. When Pinterest refuses a product, the Pin still publishes with the other products, and the post page shows which product was not tagged and why. - **Product tags in the API, MCP, CLI and SDKs** - `pinterest.product_tags` on a post takes the Pin IDs of the products. `GET /pinterest/products` lists your product Pins and `GET /pinterest/products/validate` checks one. The MCP tool is `list_pinterest_products`, the CLI commands are `pinterest:products` and `pinterest:validate`. Details in the [API docs](https://docs.omnisocials.com/platforms/pinterest#product-tags). #### Fixed - **A channel with its own caption lost its media when you opened the post again** - when a scheduled post had a caption for one channel that was different from the main caption (other hashtags for TikTok, a shorter text for X), that channel opened without its images or video. TikTok, Instagram and Pinterest then asked you to add the media again before you could save. On Facebook, X, Threads, Bluesky and Mastodon the post could be saved without the media. Posts created through the API or MCP with a caption per channel were affected on every channel. The editor now loads the media of each channel as it was saved. If you edited and saved a scheduled post with its own channel caption since August 17, open it once and check that the media is still on each channel. #### Changed - **One summary a day when the same problem keeps failing your posts** - a failed scheduled post sends you an email. When a channel keeps failing for the same reason (Instagram restricted the account, Facebook asks you to confirm your identity), every post sent its own email, which could be dozens a day. You now get the email for the first post, and after that one summary a day with the number of posts and the reason, for as long as the problem lasts. A new problem on the same channel still sends its own email at once. ## October 3, 2026 #### Improved - **X posts that name a website without `https://` are free** - X charges its link fee only for a link that starts with `http://` or `https://`. A post that names a website without it, such as `omnisocials.com` or `www.omnisocials.com`, no longer uses credits. The cost warning in the editor, the credits reserved for scheduled posts and the warning in the API and MCP follow the same rule. A post with a full link still uses 20 credits. #### Changed - **X post statistics refresh on a fixed schedule** - X charges for every time we read the numbers of a post. An X post now gets its first numbers a few hours after it publishes and a refresh after day 1, day 3, day 7 and day 30. After day 30 the numbers stay as they are. In our data an X post has 82% of its final views after a day and a half and 98% after a week, so the totals you see stay accurate. Other platforms are unchanged. ## October 2, 2026 #### New - **Emails for every step of a review** - when a reviewer rejects your post, leaves a comment, changes the post, or gives the last approval, you get an email with what happened and a link to the post. When you answer in the review thread, the reviewers get an email. Several comments in a row send one email, not one per comment. - **Reminders before a post misses its time** - a post that still waits for approval sends its reviewers a reminder 24 hours before the publish time. When the time passes, the reviewers and the person who asked for approval both hear that the post did not publish. A post that is approved after its time publishes at once, so change the time first when you want another moment. You can turn these emails off under Settings, then Notifications (routine reminders and post failure alerts). - **The review in the API, MCP, CLI and SDKs** - `GET /posts/{id}/approval` returns the review of a post: every step, each approver's decision, the rejection with its reason, and the comments. The MCP tool is `get_post_approval`, the CLI command is `posts:approval`. Two new webhook events, `post.approved` and `post.rejected`, tell your system how a review ended. Details in the [API docs](https://docs.omnisocials.com/creating-posts). - **Approved and rejected posts in Slack** - the Slack channel that hears "a post is waiting for approval" now also hears when the post was approved or rejected, with the reason. #### Improved - **Approval emails open the right workspace** - the link in an approval email now opens the post itself and switches to its workspace. Before, the link opened the Approvals page of the workspace you had open, which could be empty. - **Failure alerts reach the person who made the post** - when a scheduled post fails, the team member who created it now gets the alert too, next to the workspace owner. #### Fixed - **Review comments were not shown to the person who asked for approval** - "Requested by you" on the Approvals page opened the editor without the comments, and a rejected post left the Approvals page. Each post in "Requested by you" now shows its number of comments and opens with the review next to it, rejected posts stay in that list with a "Rejected" label, and the post page shows the full thread. A rejected post opens in the editor with the feedback beside it, so you can edit it and send it for approval again. ![A rejected post in the editor with the review comments next to it](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/approval-review.png) ## September 28, 2026 #### New - **LinkedIn receives your original PDF** - when you upload a PDF, OmniSocials still turns every page into a slide for Instagram, TikTok, Threads and Pinterest, and now also keeps the file itself. Attach the slides to a LinkedIn post, in order, and LinkedIn receives that original PDF as the document instead of one rebuilt from the slide images: text stays sharp, links inside the document keep working, people who download it get the real file, and every page is included, even when the PDF has more than the 20 pages we turn into slides. The LinkedIn preview says "Original PDF" in the document's title bar, and the Carousel style picker on the images now offers three choices: Original PDF, Document carousel (rebuilt from the slides), or Image gallery. Reorder, remove or add a slide and the document is rebuilt from the slides as before. ![The LinkedIn preview of a PDF with Original PDF in its title bar, and the Carousel style choices](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/linkedin-pdf.png) - **A PDF is one item in your library** - upload a PDF and the library shows one card for it (page 1 as the thumbnail, a "PDF · 5 pages" badge, a PDFs filter next to Images and Videos) instead of one image per page. Attach it to a post and its pages become the slides on every channel, in order, while LinkedIn receives the original file. Open the card for a page-by-page preview, or use "Split into slides" when you want a single page as a normal image. Deleting the card removes the pages and the file together. ![A PDF card with a PDF · 5 pages badge in the library, next to the PDFs filter](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/pdf-library.png) - **Also in the API, MCP, CLI and SDKs** - every PDF upload keeps the original (`pdf.id`, `pdf.name`, `pdf.url` in the upload response; each slide carries `pdf` with its page number), and a new `document_source` option on the `linkedin` and `linkedin_page` objects (`original_pdf`, the default, or `slides`) chooses what LinkedIn receives. Pass `pdf_mode: "document"` on an upload to keep the PDF as one media item of type `document` whose single id in `media_ids` expands into every page; `POST /media/{id}/split` turns it into one image item per page. Full details in the [API docs](https://docs.omnisocials.com/media#keep-the-pdf-as-one-item). - **Repost to Pinterest with Workflows** - Pinterest is now a destination in Workflows. Pick the board once, and every Instagram, Facebook, TikTok, Threads, Bluesky, Mastodon or LinkedIn Page post the workflow picks up is pinned there. The first line of the caption becomes the pin title, the caption becomes the description, and a video's own thumbnail becomes the pin cover. Posts without a caption are skipped with a note, because Pinterest asks for a description. YouTube shows in the destination list as "coming soon" when you have a channel connected. ![A Workflow from Instagram to Pinterest with a board selected](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/workflow-pinterest.png) - **Muse joins the AI integrations** - Muse, Meta's personal AI agent, now has its own page under Settings, Organisation, Integrations, a tab in the guided setup and a [guide in the docs](https://docs.omnisocials.com/integrations/muse). Muse builds the connector from one chat message and signs in with your API key, which you paste in its secure prompt, never in the chat. The MCP server now also accepts an API key sent as a bearer token, as the docs already described, so any client with a token field can connect without the key in the address. ![The Muse page under Settings, Organisation, Integrations](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/muse-integration.png) #### Improved - **Followers card shows new followers** - next to the growth percentage the card now shows how many followers you gained or lost in the selected period, for example "279.4K +320 new ↑0.1%". The channel table and the Followers detail page already had this number. ![The Followers card with 12.4k followers, +320 new and the growth percentage](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/analytics-cards.png) #### Fixed - **A Facebook post could publish twice** - when Facebook answered a publish request with an error after it had already created the post, OmniSocials sent the request again and the post appeared twice on the Page. Before it sends a publish request again, OmniSocials now checks the Page for the post it just sent and keeps that one. The same check protects Facebook feed videos. - **Facebook and Instagram account numbers were collected before the day had started** - the nightly account snapshot ran minutes after midnight UTC and asked Meta for that same day. Meta fills a day's numbers as the day goes on, so the Page and Instagram account metrics on the Analytics overview (views, viewers, engagement, profile views, follows gained and lost) were stored as 0 for nearly every account, while the followers count was right. The snapshot now runs at 10:30 UTC for the completed previous day and replaces any earlier partial numbers for that day. Days before this release keep their stored values unless we re-collect them; the numbers fill in from today onwards. - **Links with an apostrophe were cut short** - a web address that contains an apostrophe (for example `.../nick-cannon's-wild-'n-out-live`) was read up to the apostrophe only. The link preview in the composer, the Facebook preview card and the LinkedIn article card were fetched for that shorter address, which is a different page. After publishing, the Facebook clean-up removed only the shorter part from the post text and left the rest of the address behind. The full address is now detected, previewed and published on every channel, and the Facebook clean-up only removes a link that stands on its own in the text, never the start of a longer one. - **Approval workflows from any organisation's settings** - the approver picker, the workflow list and Create Workflow now follow the organisation you have open in Settings, not the workspace you happened to be in. Before, opening another organisation's settings listed only yourself as a possible approver, showed the wrong organisation's workflows, and saving failed. - **Deleting a workflow now tells you what is in the way** - if posts are still waiting for approval in the workflow, the delete dialog lists them with their step and approver, and an "Open post" button. Move a post to another workflow, finish its review, or delete it, then delete the workflow. Posts that were published or scheduled straight from the editor while they were still in review no longer keep a workflow alive, and their approval request is closed when they leave the review. ## September 27, 2026 #### Fixed - **Facebook reach showed 0 on every post** - Meta retired the Facebook post reach metric on June 15, 2026. The replacement metric Meta documents is accepted by the API but returns 0 for every Page post, while views and clicks on the same posts keep counting. OmniSocials stored that 0 as a real number, so the Reach column, the Reach card and the channel totals read 0 for Facebook. They now read **N/A** with the reason on hover, the same as X, which has never had a reach metric. The Reach card and the channel total also show N/A when every selected channel lacks the metric, instead of a summed 0. Reach still counts for Instagram and LinkedIn, and the moment Meta starts populating the metric the Facebook numbers appear again without any change on your side. - **API, MCP and CLI** - `reach` is now absent (never 0) in Facebook post analytics. A Facebook row collected before this release may still carry `reach: 0` until its next collection; the API drops it, so treat a missing key as "not reported". ## September 24, 2026 #### Improved - **DMs come first in the work queue** - `GET /inbox/next` (and the "next unanswered" action in ChatGPT and Claude) now serves DMs that can still be answered before comments, the one whose 24-hour reply window closes soonest first. Before, everything was served oldest first, so a fresh DM could sit behind a backlog of older comments until Meta's reply window had passed. - **DMs past the reply window are no longer hidden** - an Instagram or Facebook DM that is older than 24 hours cannot be answered through OmniSocials (a Meta rule), and the queue used to drop it without a word. It is now served with `reply_window.open = false`, so you know the customer is waiting and can answer from the Instagram or Facebook app. That reply is mirrored into the inbox and clears the item. The `unanswered` filter on the conversation list includes these DMs too. #### Fixed - **Comment replies landed under the wrong comment** - all comments on one post share a conversation, and a reply sent through the API or an AI assistant was always posted under the newest comment on the post, not the one you had drafted for. The reply endpoint now takes `message_id`, the queue names it, and the ChatGPT and Claude tools pass it automatically. If you use the OmniSocials app in ChatGPT, open chatgpt.com/plugins, open the OmniSocials app and click Refresh once so it picks up the change. - **Instagram "hidden" that was not hidden** - hiding an Instagram comment reported success while the comment stayed visible on a reel that is also shared to Facebook. OmniSocials now reads the comment back from Instagram and only marks it hidden when Instagram confirms it. When Instagram does not apply the hide, you get a clear message to hide it in the Instagram or Facebook app instead. #### Added - **Team invites you can see inside the app** - when someone invites you to their organisation, a banner now shows at the top of OmniSocials the next time you sign in, with a Review invite button. Until now the invite only existed as an email, and an email that never arrived (a spam filter, or a one-click unsubscribe on an earlier email that made our email provider hold every message to that address) meant a lost invite. On the Team page, the menu on a pending invite now has **Copy invite link** and **Resend invite**, so you can hand the link over directly. ![The in-app banner for a team invite with Not now and Review invite](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/team-invite-banner.png) ![The menu on a pending invite with Copy invite link, Resend invite and Cancel invitation](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/team-invite-menu.png) ## September 18, 2026 #### New - **A new schedule window** - scheduling a post now happens in one window, with your channels, the date and the time together. Turn on **Different time per channel** when one channel needs its own time. The **Best time to post** line suggests the best hour from your own results: click **Use** and it fills in. ![The new schedule window with the date and time, a best time to post suggestion, and a different time per channel for Instagram](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/schedule-modal.png) #### Improved - **Cleaner Facebook link posts** - a Facebook post with a link and no image now publishes the way the native composer does. Once the post is live, OmniSocials checks whether Facebook rendered the preview card. If it did, the link is removed from the post text, so the feed shows your text and the card, not a long URL in between. If Facebook did not render the card, the link stays in the text, so the post always has a working link. The read-only preview in the calendar shows the same clean version. No change needed on your side: write the post with the link in the text as before. ![A Facebook link post with clean text and the link preview card](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/facebook-link-post.png) ## September 14, 2026 #### New - **Threads (Meta) threads** - in the composer, click **Add post below** on the Threads preview, or paste a long text and use **Split into thread**. Each post takes up to 500 characters and up to 10 images or videos. Parts publish in order; every part after the first is posted as a reply to the one before it, so it shows as one thread under your first post. ![A three-part thread in the Threads preview of the OmniSocials composer, with Add post below](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/threads-thread.png) - **API, MCP, CLI and SDKs** - pass `threads.thread_parts` on post create or update (2 to 25 parts, `media_ids` or `media_urls` per part, `{ url, alt }` for alt text). The CLI flag is `--threads-thread "part one || part two"`. Read the parts back from the post object and the per-part metrics from post analytics. Full details in the [Threads docs](https://docs.omnisocials.com/platforms/threads). - **Retry continues where it stopped** - if a thread fails halfway, the retry picks up at the first unpublished part instead of posting the earlier parts again. - **Threads replies and mentions in the Social Inbox** - replies people leave on your Threads posts, and posts that mention you, show up next to your other conversations. Reply from the inbox (published as a native Threads reply) or hide a reply on your own post. - **Reconnect Threads once** - these features need new Threads permissions. If your Threads account was connected before today, reconnect it under Settings -> Organisation -> Workspaces. OmniSocials tells you in the composer and the inbox when this is needed. ## September 6, 2026 #### New - **Replies from the native apps now sync to the inbox** - answer a DM from the Instagram app or Messenger, or reply to a comment on Instagram or Facebook itself, and the reply shows up in the Social Inbox thread as your message. The customer's message counts as answered, so nobody on the team replies twice. Facebook Pages connected before today need a one-time reconnect under Settings -> Organisation -> Workspaces to pick up Messenger replies; Instagram works without a reconnect. - **The post next to every comment** - comment and mention conversations now carry the post they belong to in the API, MCP, and SDKs: caption, image, a link to the post, and the media type. Your AI assistant sees the picture and the caption before it drafts a reply, so "what size is this?" gets a real answer. - **Hide and delete comments through the API and your AI assistant** - the Hide and Delete buttons from the inbox are now in the public API (`POST /inbox/messages/{id}/hide` and `DELETE /inbox/messages/{id}`) and as tools in ChatGPT, Claude, and any other MCP client (`hide_inbox_comment`, `delete_inbox_comment`). Hide covers Instagram, Facebook, TikTok, YouTube, and Threads comments; delete covers Instagram, Facebook, and TikTok. YouTube does not allow tools to delete other people's comments, so hide is the option there. Deleting also removes the replies under the comment. - **A "next unanswered" work queue** - `GET /inbox/next` (in ChatGPT and Claude: `get_next_unanswered`) hands you the one item that has waited longest for an answer, together with the whole thread and the post it is on. Reply with `include_next` and the next one comes back in the same response, so an assistant can work through the inbox in a loop: "answer my unanswered comments" now does exactly that. Marking a conversation read skips it for good, hidden comments drop out automatically, and `GET /inbox/conversations?unanswered=true` lists everything still waiting. Details in the [inbox docs](https://docs.omnisocials.com/inbox#working-through-the-inbox). ## September 2, 2026 #### Fixed - **Facebook link posts keep their link** - since June, a Facebook post with a link and no image published without the link text, relying on Facebook's preview card to show the destination. Facebook does not always render that card, and those posts then ended up with no link at all. The link now stays in the post text, so it is always clickable. When Facebook does render the card, you get both. ## August 29, 2026 #### New - **Hide and delete comments in the Social Inbox** - every Instagram, Facebook, and TikTok comment now has **Hide** and **Delete** next to Reply. Hiding removes the comment from public view on the platform but keeps it in your inbox with a "Hidden" badge, so you can unhide it any time. Deleting removes it for good, on the platform and in your inbox, including its replies. - **YouTube comments in the Social Inbox** - comments on your YouTube videos now arrive in the same inbox as everything else, grouped per video with the video preview. Reply as your channel and hide comments you do not want shown; hiding uses YouTube's own moderation, so the comment and its replies disappear from public view and can be restored later. YouTube does not allow tools to delete another person's comment, so there is no Delete for YouTube. No extra setup needed: your existing YouTube connection already covers it. YouTube sends no notifications for comments, so OmniSocials checks each channel once per day; a new comment can take up to a day to appear. ![A YouTube video with its comments in the Social Inbox, with Reply and Hide](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/inbox-youtube.png) - **YouTube inbox in the API, MCP, CLI, and SDKs** - `GET /inbox/conversations` accepts `platform=youtube`, and the reply endpoint posts comment replies as your channel. The same works through the MCP server and the CLI, so your AI assistant can read and answer YouTube comments too. Details in the [inbox docs](https://docs.omnisocials.com/inbox). ## August 24, 2026 #### New - **Time zone setting** - under Settings -> Preferences you can now pin a time zone. Every schedule you set, and every post time you see on the calendar and in your post lists, uses that time zone no matter where you or your device are. Great if you travel but your audience does not. The default stays Automatic, which follows your device like before. Best-time suggestions use the same zone, so the chips and your schedule always agree. ![The time zone setting under Settings, Preferences, with New York pinned](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/timezone.png) ## August 23, 2026 #### New - **More data on the Analytics page** - three new cards and detail pages: **Reach** (unique accounts that saw your posts), **Clicks** (link, pin, and other clicks) and **Video Views**. The posts table and the channel table show them too. - **Choose your own overview cards** - the Analytics page starts with four cards (Followers, Impressions, Engagements, Published Posts). Click **Add** to pick from ten metrics, including Reach, Clicks, Video Views, Engagement Rate, Best Time to Post and Posting Streak; click **Edit** to remove cards or drag them into a new order. Hover a card's chart to see the value of any day. Your layout is remembered in this browser. ![The Analytics overview cards for followers, reach and video views, with the Add and Edit buttons](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/analytics-cards.png) - **Posts and Accounts tabs** - under the cards you can switch between the posts of the period and a per-account table with followers, posts, impressions, reach, engagements, engagement rate and more, with a total row. - **Account insights in the API and MCP** - the numbers each platform reports about the account itself: reach, profile views, link clicks, new and lost followers, accounts engaged, Pinterest monthly views, Google Business calls, direction requests, website clicks, rating and review count, and more. Where the platform provides it you also get **audience demographics** (age, gender, country, city; job function and seniority for LinkedIn pages) and **followers online by hour**. Read them from `GET /analytics/accounts`. - **All metrics per platform on a post** - on a post's detail page every platform row now has a **Show all metrics** button with everything the platform reported: reach, link clicks, profile visits, follows from the post, saves, reactions by type, watch time, completion rate, skip rate, where it was seen and viewer types (TikTok), pin and outbound clicks (Pinterest), story taps and completion (Instagram), poll results (Mastodon). - **Best time to post now uses when your audience is online** - for Instagram and TikTok Business accounts the recommendation blends your own results with the hours your followers are active. New workspaces without enough posts get the audience-based suggestion instead of industry averages. - **API, MCP, CLI, and SDKs** - the new metric keys come back on `GET /analytics/posts`, `GET /analytics/accounts` (day values, demographics, online followers) and `GET /analytics/best-times` (`audience_online`). A missing key now means the platform does not report it; you no longer get zeros for metrics a platform cannot measure. See the [analytics docs](https://docs.omnisocials.com/analytics). - **TikTok first comment** - add a first comment to a TikTok video in the composer, the same as on Instagram, LinkedIn, and YouTube. It posts automatically right after the video goes live, up to 150 characters. You need comments enabled on the TikTok channel (the **Enable comments** button on the channel card under Settings -> Organisation -> Workspaces). The video must be public and allow comments. ![The TikTok first comment field in the OmniSocials composer](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/tiktok-first-comment.png) - **TikTok watch-time analytics** - OmniSocials now collects average watch time, the share of viewers who watched to the end, total watch time, favorites, and reach for TikTok videos. These come from TikTok's business API, so they need comments enabled on the channel. If you enabled comments before today, click **Unlock watch-time analytics** on the TikTok channel card once to grant the extra permission. The new metrics are available through the API, MCP, and SDKs (post analytics and recent-platform posts); the dashboard keeps its standard cards for now. - **Also in the API, MCP, and SDKs** - pass `tiktok.first_comment` on post create or update, and read the new metrics from the post analytics and recent-platform endpoints. Full details in the [API docs](https://docs.omnisocials.com/platforms/tiktok). #### Fixed - **Pinterest analytics showed zero for every pin** - a parsing bug read the wrong field of Pinterest's response. Impressions, saves, pin clicks and outbound clicks now come through, plus reactions, comments, profile visits, follows and video metrics for video pins. - **Facebook reach was always zero and video posts reported nothing** - reach uses Facebook's current metric, feed videos are read from the right object, sad and angry reactions are no longer dropped on videos and reels, and reels now report shares, replays, average watch time and follows gained. - **Threads posts could not be deleted from OmniSocials** - the delete permission was never requested. Reconnect your Threads account once to enable it. - **LinkedIn Page posts had no Shares column** on the post detail page. - **Follower charts no longer show a phantom drop to zero** when a platform API is briefly unavailable; the day is skipped instead. - **Bluesky connections lost less often** - analytics reads no longer touch the Bluesky login session, which was rotating the refresh token on every check. - **TikTok comments connection stopped working after one day** - the daily token renewal called the wrong TikTok endpoint, so comment names, replies, and likes silently failed 24 hours after enabling comments. Renewal now works, and a background check refreshes every connection twice a day. Comments that were stuck showing "TikTok User" pick up the real name on the next sync. ## August 22, 2026 #### Fixed - **Large videos publish reliably** - posting one big video (hundreds of megabytes) to several platforms at once could interrupt publishing for everyone for a few minutes. OmniSocials now streams videos to LinkedIn, X, Facebook Reels and Stories, and YouTube instead of holding the whole file in memory, so very large files publish without affecting other posts. - **Clear error when a post cannot publish** - a post whose publish keeps getting interrupted now stops after a few attempts and shows a clear error (usually a media file that is too large to process) instead of retrying silently every few minutes. ## August 21, 2026 #### New - **Stories with multiple slides** - a Story can now hold up to 10 images or videos. Each slide publishes as its own story on Instagram and Facebook, in the order you set. The composer shows the slides as a strip under the preview: drag a slide or use the arrows to reorder, click one to preview it, and add more with the plus tile. The Instagram and Facebook previews show the real segmented progress bar, and you can page through the slides there too. Each channel can have its own slides via Manage slides. ![The Instagram story preview with its segmented progress bar and the slide strip with five slides](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/stories-slides.png) - **Per-slide statistics** - the post detail page lists every slide on its own row (Instagram · Story 1/3, Story 2/3, ...) with its own views, reach, and interactions. - **Also in the API, MCP, and SDKs** - pass several `media_urls` or `media_ids` with `type: "story"`. After publishing, `story_slides` lists every slide's native id and URL, and post analytics include a per-slide breakdown. Full details in the [API docs](https://docs.omnisocials.com/creating-stories). #### Improved - **Reordering images** - dragging images in the composer and in Manage images no longer flickers, works on touch screens, and every image has left/right arrows for a one-tap move. #### Fixed - **Team members could not reply to Instagram DMs and comments** - replies looked for the Instagram connection under the member's own account instead of the workspace. Members now reply with the workspace connection, like the owner. ## August 20, 2026 #### New - **Choose your LinkedIn carousel style** - by default, a LinkedIn post with two or more images publishes as LinkedIn's swipeable document carousel. A new button on the images in the composer (next to Reorder images) lets you switch that post to a classic image gallery instead. The preview changes with it, so you see exactly what LinkedIn will show, and you can choose separately for your personal profile and your company page. - **Also in the API, MCP, and SDKs** - set `carousel_as_images` on the `linkedin` or `linkedin_page` options when creating or updating a post. Full details in the [API docs](https://docs.omnisocials.com/platforms/linkedin). ## August 13, 2026 #### New - **TikTok comments in the Social Inbox** - as promised, TikTok joins the inbox. New comments on your TikTok videos arrive in real time, and you can reply and like them straight from the inbox. Enable it with the **Enable comments** button on the TikTok channel card, a quick extra TikTok authorization. One heads-up: TikTok holds fresh comments and replies in spam review for about 15-20 minutes before they show publicly. - **TikTok inbox in the API, MCP, and SDKs** - the public API inbox endpoints, the MCP server tools, all eight SDKs, and the CLI now accept platform `tiktok`. Replies are text-only and capped at 150 characters. Full details in the [API docs](https://docs.omnisocials.com/inbox#tiktok-comments). ## August 12, 2026 #### New - **LinkedIn polls** - create a poll for LinkedIn straight from the composer: a question, up to four answer options, and how long it stays open for votes (1 day up to 2 weeks). Works on your personal profile or your company page. A poll post can't include an image, video, or link preview - the same rule LinkedIn applies natively. ![A LinkedIn poll and its editor in the OmniSocials composer](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/linkedin-poll.png) - **LinkedIn polls in the API, MCP, and SDKs** - the public API, MCP server, and all eight SDKs now accept `linkedin_poll` on post creation, so you can automate polls the same way you automate any other post. Full details in the [API docs](https://docs.omnisocials.com/platforms/linkedin). ## August 9, 2026 #### New - **Delete published posts** - you can now delete a post after it has published, not only while it is a draft or scheduled. OmniSocials removes it from the real platform too, not just from your OmniSocials calendar. Look for the delete option on any published post in your Post list. ![The Delete post dialog for a published post, with the option to delete it on the platforms too](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/delete-published.png) - **Instagram AI-content disclosure** - the EU AI Act requires a label on posts made with AI. Turn on "AI-generated content" when you create a single image, video, Reel, or carousel post for Instagram, and OmniSocials applies Meta's official AI-content label for you. YouTube and TikTok already supported this, and Instagram now does too. ![The AI-Generated Content switch in the Instagram options of the OmniSocials composer](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/instagram-ai-label.png) - **Repost across workspaces with Workflows** - if your company runs more than one workspace, a Workflow can now pull content from one workspace and post it to another. Useful for agencies and multi-brand teams who want to reuse a post across workspaces without recreating it. ![A Workflow that pulls content from another workspace in your company](https://cdn.omnisocials.com/file/omnisocials-website-assets/changelog/workflow-pinterest-workspace.png) ## August 5, 2026 #### New - **X DMs in your Social Inbox** - X direct messages now land in your Social Inbox alongside Instagram and Facebook, and you can reply without leaving the app. Turn it on from the X DMs card in the Social Inbox settings (a one-time reconnect of your X account may be required). Because X charges for DM API access, this uses credits: 1 credit to receive a message, 2 credits to send a reply, passed through at X's exact rate. ![X DMs turned on in the OmniSocials Social Inbox, with a new message landing in the list](https://cdn.omnisocials.com/file/omnisocials-website-assets/site/changelog/x-dms-credits/x-dms-inbox.png) - **Credits wallet** - your organisation now has a prepaid credits balance under Settings, then Organisation, then Billing, then Credits. Top up any amount from 5 to 500 EUR (1 credit = 0.01 EUR), see every charge in a transparent ledger, export it as CSV, and optionally turn on auto-refill so your balance tops itself up when it runs low. - **Earn free credits** - the same Credits tab lists simple ways to earn credits for free, like starring us on GitHub or sharing a video about OmniSocials. More ways are coming. ![Ways to earn free credits in OmniSocials billing settings](https://cdn.omnisocials.com/file/omnisocials-website-assets/site/changelog/x-dms-credits/x-credits-earn.png) - **200 free credits for X users** - every organisation with an X account connected has already received 200 credits from us to get started. Nothing to claim, they are on your balance now. #### Changed - **X posts containing a link will use credits from August 14** - X changed its API pricing this spring and now charges 13x more for posts whose text contains a link (0.20 USD instead of 0.015 USD per post). Rather than raising prices for everyone or dropping X like some tools did, we pass this one fee through at X's exact rate with zero markup: 20 credits per link post, threads counted per tweet that contains a link. **Everything else on X stays free and unlimited**: posts without links, threads without links, analytics, scheduling, media uploads. You will see the exact cost in the editor before you schedule, credits are only used once the post actually publishes (a failed post is never charged), and if a scheduled post ever comes up short we email you ahead of time. Until August 14 nothing is charged, so there is plenty of time to top up or earn credits first. ## July 29, 2026 #### New - **Custom Mastodon & Bluesky servers** - connect to any Mastodon instance and any Bluesky host, not just mastodon.social. On Mastodon, enter your server (fosstodon.org, your team's own instance, anything running Mastodon) and OmniSocials sets itself up with that server automatically, and its own character limit is respected. On Bluesky, sign in with any handle, including custom domains like yourbrand.com and accounts on self-hosted or third-party servers. Bring the account you already use, wherever it lives. ## July 27, 2026 #### New - **Hashtag sets** - save your go-to hashtags once as a named set, then apply them to any post in one step. Sets live per workspace and work everywhere: pick one from the new toolbar button in the post composer, pass `hashtag_set` to the [API](https://docs.omnisocials.com/creating-posts#hashtag-sets) when creating a post, or let your AI agent use the new `list_hashtag_sets` and `create_hashtag_set` tools in the MCP server and Agent Skills CLI. Choose whether the tags land in the caption or in the automatic first comment, and OmniSocials skips tags the caption already has and guards Instagram's 30-hashtag limit for you. ![The hashtag sets panel in the OmniSocials composer, with a saved set and the option to save the caption's hashtags as a set](https://cdn.omnisocials.com/file/omnisocials-website-assets/email/feature-images/hashtag-sets.png) - **Hashtag sets in all eight SDKs** - the Node.js, Python, PHP, Ruby, Go, Java, Rust and .NET SDKs each ship a hashtag sets resource, so the new endpoints work the same in every language. - **Alt text on every image** - add accessibility descriptions to your media with the new ALT button on each image in the composer. Alt text publishes natively to Mastodon, Bluesky, X and Pinterest, so screen-reader users get a real description of your images. Also available in the [API](https://docs.omnisocials.com/creating-posts), MCP server and Agent Skills: any media entry can now be `{ "url": "...", "alt": "..." }`, including per-tweet and per-toot media in threads. ![The ALT button on an image in the OmniSocials composer, opening an alt text editor with a per-platform override for X](https://cdn.omnisocials.com/file/omnisocials-website-assets/email/feature-images/alt-text.png) #### Fixed - **Bluesky alt text placeholder** - images posted to Bluesky no longer carry a generic "Image attached to post" description; they use your alt text, or none, so Bluesky's own alt-text reminders work as intended. ## July 21, 2026 #### Improved - **Refreshed sidebar** - a cleaner, more scalable layout that moves your workspaces and connected channels into a single switcher at the top, leaving room for the features we have coming next.