{"openapi":"3.0.0","servers":[{"url":"https://api.omnisocials.com/v1","description":"Production"},{"url":"/api/v1","description":"Current Server"}],"info":{"title":"OmniSocials API","version":"1.0.0","description":"\n## Introduction\n\nThe OmniSocials API allows you to programmatically manage your social media posts, media, and analytics across multiple platforms.\n\n## Base URL\n\n```\nhttps://api.omnisocials.com/v1\n```\n\n## Authentication\n\nInclude your API key in the `Authorization` header:\n\n```\nAuthorization: Bearer omsk_live_your_api_key_here\n```\n\n### Creating an API Key\n\n1. Go to **Settings > API** in the OmniSocials dashboard\n2. Click **Create API Key**\n3. Give it a name and select the scopes you need (`posts:read`, `posts:write`, `media:write`, `accounts:read`, `analytics:read`, `webhooks:manage`, and — opt-in, for the social inbox — `inbox:read`, `inbox:write`)\n4. Copy the generated key — it will only be shown once\n5. Use the key in the `Authorization` header as shown above\n\nYou can create multiple API keys with different scopes for different integrations. Keys can be revoked at any time from the Settings page.\n\n## MCP Server\n\nOmniSocials provides an MCP (Model Context Protocol) server so you can manage your social media directly from AI assistants like Claude, Cursor, Windsurf, and more.\n\nInstall the npm package: [`@omnisocials/mcp-server`](https://www.npmjs.com/package/@omnisocials/mcp-server)\n\n### Claude Code\n\n```bash\nclaude mcp add omnisocials -- npx -y @omnisocials/mcp-server\n```\n\nThen set your API key: `export OMNISOCIALS_API_KEY=omsk_live_your_key_here`\n\n### Claude Desktop / Cursor / Windsurf\n\nAdd to your MCP config file:\n\n```json\n{\n  \"mcpServers\": {\n    \"omnisocials\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@omnisocials/mcp-server\"],\n      \"env\": {\n        \"OMNISOCIALS_API_KEY\": \"omsk_live_your_api_key_here\"\n      }\n    }\n  }\n}\n```\n\nConfig file locations:\n- **Claude Desktop:** `claude_desktop_config.json`\n- **Cursor:** `.cursor/mcp.json`\n- **Windsurf:** `~/.codeium/windsurf/mcp_config.json`\n\nThe MCP server supports the same scopes as the REST API. Your API key determines what actions the AI assistant can perform.\n\nSee the [full setup guide](https://www.npmjs.com/package/@omnisocials/mcp-server) for more details.\n\n## Supported Platforms\n\n| Platform | Feed Post | Story | Reel/Short | Special Features |\n|----------|-----------|-------|------------|------------------|\n| Instagram | ✓ | ✓ | ✓ | Carousel, First Comment, Cover Image |\n| Facebook | ✓ | ✓ | ✓ | Page Posts |\n| LinkedIn | ✓ | - | - | Personal & Company Pages |\n| YouTube | - | - | ✓ | Shorts with full metadata |\n| TikTok | ✓ | - | ✓ | Photo Posts, Privacy Controls |\n| Pinterest | ✓ | - | - | Boards, Links, Video Covers |\n| X (Twitter) | ✓ | - | - | Reply Settings |\n| Threads | ✓ | - | - | |\n| Bluesky | ✓ | - | - | |\n| Mastodon | ✓ | - | - | |\n| Google Business | ✓ | - | - | Local Posts with EVENT/OFFER topic types & CTA buttons |\n| Reddit | _coming soon_ | - | - | |\n| Snapchat | - | _coming soon_ | - | |\n\n## Platform-Specific Options\n\n### Pinterest\n| Field | Type | Description |\n|-------|------|-------------|\n| `pinterest.board_id` | string | **Required.** Pinterest board ID |\n| `pinterest.title` | string | Pin title (max 100 characters) |\n| `pinterest.link` | string | Destination URL the pin clicks through to |\n| `pinterest.video_cover` | string | Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted. |\n| `pinterest.alt_text` | string | Accessibility alt text for the pin image (max 500 characters) |\n\n### YouTube (Shorts only)\n| Field | Type | Description |\n|-------|------|-------------|\n| `youtube.title` | string | Video title (default: \"YouTube Short\") |\n| `youtube.tags` | array | Video tags |\n| `youtube.privacy_status` | string | `public`, `private`, or `unlisted` |\n| `youtube.category_id` | string | Category ID (default: \"22\" People & Blogs) |\n| `youtube.made_for_kids` | boolean | Mark as made for kids |\n| `youtube.notify_subscribers` | boolean | Notify subscribers on publish |\n| `youtube.contains_synthetic_media` | boolean | Mark as AI-generated |\n\n### Instagram (Reels)\n| Field | Type | Description |\n|-------|------|-------------|\n| `instagram.share_to_feed` | boolean | Also share reel to feed |\n| `instagram.thumbnail_type` | string | `from-video` or `from-library` |\n| `instagram.thumb_offset` | number | Video timestamp in ms (for from-video) |\n| `instagram.cover_url` | string | Custom thumbnail URL (for from-library) |\n| `instagram.is_ai_generated` | boolean | Mark as AI-generated (also works on Posts/carousels, not just Reels) |\n\n### TikTok\n| Field | Type | Description |\n|-------|------|-------------|\n| `tiktok.privacy_level` | string | `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY` |\n| `tiktok.disable_comment` | boolean | Disable comments |\n| `tiktok.disable_duet` | boolean | Disable duets |\n| `tiktok.disable_stitch` | boolean | Disable stitches |\n| `tiktok.is_aigc` | boolean | Mark as AI-generated |\n| `tiktok.brand_content_toggle` | boolean | Paid partnership disclosure |\n\n### X (Twitter)\n| Field | Type | Description |\n|-------|------|-------------|\n| `x.reply_settings` | string | `` (everyone), `following`, or `mentionedUsers` |\n\n## Creating Posts\n\nUse `POST /posts/create` to create a new post. The legacy `POST /posts` also works as a backward-compatible alias.\n\n### Attaching Media\n\nThere are two ways to attach media to a post:\n\n1. **Upload first** — Upload via `POST /media/upload` or `POST /media/upload-from-url`, then pass the returned IDs in the `media` (or `media_ids`) field.\n2. **Inline URLs** — Pass external image/video URLs in the `media_urls` field. The API will download, process, and attach them automatically. Max 10 URLs.\n\nBoth methods can be combined in a single request. Media IDs are validated — you'll get a `400` error if any ID doesn't exist in your library.\n\n```json\n{\n  \"content\": \"Check out these photos!\",\n  \"accounts\": [\"242857_instagram\"],\n  \"media_urls\": [\"https://example.com/photo.jpg\"],\n  \"media_ids\": [\"123\"]\n}\n```\n\n## Post Types\n\nUse the `type` field to specify what kind of post to create:\n\n- `post` - Regular feed post (default)\n- `story` - Story (Instagram, Facebook)\n- `reel` - Reel/Short (Instagram, Facebook, YouTube, TikTok)\n\n## Rate Limiting\n\n**100 requests per minute** per API key.\n\nResponse headers:\n- `X-RateLimit-Limit`: Maximum requests per minute\n- `X-RateLimit-Remaining`: Remaining requests in current window\n- `X-RateLimit-Reset`: Unix timestamp when the limit resets\n\n## Webhooks\n\nSubscribe to events for real-time notifications when posts are published, fail, or are scheduled.\n\n### Events\n\n| Event | Fired when |\n|-------|------------|\n| `post.scheduled` | A post is created or updated into the scheduled state via the API |\n| `post.published` | A scheduled post finishes publishing (at least one platform succeeded) |\n| `post.failed` | A scheduled post finishes publishing with every platform failing |\n\n### Payload\n\nDeliveries are HTTP `POST` with a JSON body:\n\n```json\n{\n  \"id\": \"e7c9a1b2-3d4e-5f6a-7b8c-9d0e1f2a3b4c\",\n  \"type\": \"post.published\",\n  \"created_at\": \"2026-06-24T14:00:05.000Z\",\n  \"data\": {\n    \"post_id\": \"123456\",\n    \"workspace_id\": 789,\n    \"status\": \"posted\",\n    \"post_type\": \"Post\",\n    \"scheduled_at\": \"2026-06-24T14:00:00.000Z\",\n    \"published_at\": \"2026-06-24T14:00:05.000Z\",\n    \"targets\": [\n      { \"platform\": \"instagram\", \"status\": \"success\", \"native_post_id\": \"17912345678901234\" },\n      { \"platform\": \"x\", \"status\": \"success\", \"native_post_id\": \"1799999999999999999\" }\n    ]\n  }\n}\n```\n\n`targets[].native_post_id` is the post's identifier on the platform itself (e.g. the Instagram media ID or tweet ID), or `null` if that platform did not return one. Failed targets include an `error` string instead. `post.scheduled` is delivered before publishing, so its `targets` array is empty.\n\n### Headers\n\nEach delivery includes:\n\n- `X-OmniSocials-Event`: the event name\n- `X-OmniSocials-Webhook-Id`: the subscription id\n- `X-OmniSocials-Delivery`: a unique id for this delivery attempt set\n- `X-OmniSocials-Timestamp`: unix timestamp (seconds)\n- `X-OmniSocials-Signature`: `t=<timestamp>,v1=<hmac>`\n\n### Verifying the signature\n\nPayloads are signed with HMAC-SHA256 using the secret returned when you created the webhook. Compute the HMAC of `<timestamp>.<raw_request_body>` and compare it (in constant time) to the `v1` value:\n\n```js\nconst crypto = require(\"crypto\");\n\nfunction verify(rawBody, signatureHeader, secret) {\n  const parts = Object.fromEntries(\n    signatureHeader.split(\",\").map((kv) => kv.split(\"=\"))\n  );\n  const expected = crypto\n    .createHmac(\"sha256\", secret)\n    .update(`${parts.t}.${rawBody}`, \"utf8\")\n    .digest(\"hex\");\n  return crypto.timingSafeEqual(\n    Buffer.from(expected),\n    Buffer.from(parts.v1)\n  );\n}\n```\n\nFailed deliveries are retried up to 3 times with backoff. Respond with a `2xx` status to acknowledge receipt.\n\n## Errors\n\n| Status | Description |\n|--------|-------------|\n| `200` | Success |\n| `201` | Created |\n| `204` | No content (successful deletion) |\n| `400` | Bad request (validation error) |\n| `401` | Unauthorized (invalid/missing API key) |\n| `403` | Forbidden (insufficient permissions) |\n| `404` | Not found |\n| `429` | Too many requests (rate limit exceeded) |\n| `500` | Internal server error |\n      ","contact":{"name":"OmniSocials Support","email":"support@omnisocials.com","url":"https://omnisocials.com"}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key authentication. Get your key from Settings > API."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","description":"Error code for programmatic handling"},"message":{"type":"string","description":"Human-readable error message"},"details":{"type":"array","description":"Optional structured details. Returned today by validation_error responses on POST /posts/create, POST /posts/create-and-publish, and POST /posts/{id}/publish when one or more media items violate a target platform's size/format limits. Each entry pinpoints a single platform/file combo so clients can surface inline errors.","items":{"type":"object","properties":{"platform":{"type":"string","description":"Channel that rejected the media (instagram, facebook, x, etc.)"},"mediaUrl":{"type":"string","description":"URL of the offending media item"},"code":{"type":"string","enum":["media_too_large","media_format_unsupported","media_unreachable","media_type_unsupported"],"description":"Machine-readable failure reason"},"message":{"type":"string","description":"Human-readable explanation"}}}}}}}},"Pagination":{"type":"object","properties":{"total":{"type":"integer","description":"Total number of items"},"limit":{"type":"integer","description":"Items per page"},"offset":{"type":"integer","description":"Number of items skipped"},"has_more":{"type":"boolean","description":"Whether there are more items"}}},"Post":{"type":"object","properties":{"id":{"type":"string","description":"Post ID"},"status":{"type":"string","enum":["draft","in_approval","scheduled","posting","published","failed","warning"],"description":"`in_approval` = waiting for a reviewer in an approval workflow; PATCH /posts/{id} can still move its `schedule_at` without changing this status. `posting` = a publish is in flight. `warning` = some platforms published and some failed (see `errors` for the failures, `published_urls` for the successes); retry the failures with POST /posts/{id}/retry. `failed` = every platform failed. NOTE: a failed post whose dashboard 'retry as new post' succeeded flips to `published` even though it has no `published_urls` of its own — check `retries` for the post that actually carries the live URLs."},"type":{"type":"string","enum":["post","story","reel"],"description":"Post type. 'reel' media must be a video (MP4/MOV) — an image is rejected with 400 validation_error at schedule/publish; use 'post' to share an image. A 'story' carries 1 to 10 media items; every item is one slide and publishes as its own story, in order."},"story_slides":{"type":"object","description":"Stories only, after publishing. Per-platform list of the published slides in publish order. `published_urls` keeps the first slide's URL per platform; this field has every slide.","additionalProperties":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer","description":"Zero-based slide position."},"native_post_id":{"type":"string","description":"The slide's story id on the platform."},"url":{"type":"string","nullable":true,"description":"Story URL (valid for 24 hours)."},"media_url":{"type":"string","nullable":true},"media_type":{"type":"string","enum":["image","video"]},"published_at":{"type":"string","format":"date-time"}}}}},"content":{"type":"object","description":"Post content per platform"},"accounts":{"type":"array","items":{"type":"string"},"description":"Account IDs to post to"},"media":{"type":"object","additionalProperties":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string"},"id":{"type":"string"},"channel":{"type":"string"},"alt":{"type":"string","description":"Accessibility description (alt text) for this media item, when set."}}}},"description":"Per-platform media object. Keys: default, instagram, facebook, etc. Each value is an array of {url, id, channel, alt?}."},"location_id":{"type":"string","description":"Instagram location tag (Facebook Place/Page ID), echoed back from the create/update request. Omitted when not set."},"collaborators":{"type":"array","items":{"type":"string"},"description":"Instagram co-author usernames (the 'Collab' feature), echoed back from the create/update request. Omitted when none set."},"user_tags":{"type":"array","items":{"type":"object","properties":{"username":{"type":"string"},"x":{"type":"number"},"y":{"type":"number"},"image_index":{"type":"integer"}}},"description":"Instagram photo user tags, echoed back from the create/update request. Omitted when none set."},"schedule_at":{"type":"string","format":"date-time","description":"Scheduled publish time (ISO 8601)"},"app_url":{"type":"string","format":"uri","description":"Deep link to open this post inside the OmniSocials app, correct for the environment (staging vs production). Draft, scheduled, and failed posts open in the composer; published posts open in the details view. Hand this to the user so they can click straight through to review a draft you just created.","example":"https://app.omnisocials.com/create-post/1024"},"published_urls":{"type":"object","additionalProperties":{"type":"string","format":"uri"},"description":"Live URLs of the post on each platform after publishing. Keys are platform identifiers (`facebook`, `instagram`, `linkedin`, `linkedin_page`, `youtube`, `tiktok`, `pinterest`, `x`, `threads`, `bluesky`, `mastodon`, `google_business`). Only platforms that successfully published appear; failed platforms are omitted (see `errors`). Empty object `{}` while the post is still draft, scheduled, or processing.","example":{"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","x":"https://x.com/yourhandle/status/1791234567890123456"}},"retry_of":{"type":"string","description":"Present when this post was created by the dashboard 'retry as new post' flow: the ID of the original failed post it retries. The live platform URLs are on THIS post's `published_urls`."},"retries":{"type":"array","items":{"type":"string"},"description":"Present on a post that has been retried as a new post: the IDs of its retry posts (newest last). A `published` post with empty `published_urls` and `retries` set is a resolved failure — the actual publish lives on the retry post, so don't double-count it."},"created_at":{"type":"string","format":"date-time"},"approval_status":{"type":"string","nullable":true,"description":"Approval workflow status (if applicable)"},"errors":{"type":"object","nullable":true,"additionalProperties":{"type":"string"},"description":"Per-platform user-friendly error messages, keyed by platform identifier (`facebook`, `instagram`, `linkedin`, `linkedin_page`, `youtube`, `tiktok`, `pinterest`, `x`, `threads`, `bluesky`, `mastodon`, `google_business`). Populated when `status` is `failed` or `warning`. Only platforms that failed appear; successful platforms are omitted (see `published_urls`). `null` while the post is still draft/scheduled/processing or every platform succeeded. A platform's entry is removed once a retry of it succeeds (via POST /posts/{id}/retry or the dashboard). Mirrors the per-platform error a user sees in the dashboard with a Retry button.","example":{"facebook":"Already Posted: These photos were already posted.","instagram":"Instagram couldn't accept one of your images. Check that every image is JPEG/PNG, at least 320×320, has an aspect ratio between 4:5 and 1.91:1, and isn't visually flagged by Instagram. Re-upload or replace the offending image and try again."}},"source":{"type":"string","nullable":true,"description":"Origin of post creation: web, api, mcp, workflow, zapier, make, n8n, etc."},"updated_at":{"type":"string","format":"date-time"},"x":{"type":"object","nullable":true,"description":"X (Twitter) options on the post. Includes per-publish settings (reply_settings, made_with_ai, paid_partnership) plus the canonical thread shape. Present when the post has X-specific data; omitted otherwise.","properties":{"reply_settings":{"type":"string","enum":["","following","mentionedUsers"]},"paid_partnership":{"type":"boolean"},"made_with_ai":{"type":"boolean"},"thread_parts":{"type":"array","description":"When the post is a chained X thread (2–25 parts), this returns the canonical tweet text in publish order. For single-tweet posts the array is omitted and the text lives in `content.x` (or `content.default`). Each part also includes any per-tweet `media_urls`.","items":{"type":"object","properties":{"id":{"type":"string","description":"Stable client-side id for the part"},"text":{"type":"string","maxLength":280},"media_urls":{"type":"array","items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","description":"Accessibility description (alt text) for this media item."}}}]}}}}}}},"bluesky":{"type":"object","nullable":true,"description":"Bluesky options on the post. Present when the post is a Bluesky thread; omitted otherwise.","properties":{"thread_parts":{"type":"array","description":"When the post is a chained Bluesky thread (2–25 parts), this returns the canonical post text in publish order. For single posts the array is omitted and the text lives in `content.bluesky` (or `content.default`). Each part also includes any per-post `media_urls`.","items":{"type":"object","properties":{"id":{"type":"string","description":"Stable client-side id for the part"},"text":{"type":"string","maxLength":300},"media_urls":{"type":"array","items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","description":"Accessibility description (alt text) for this media item."}}}]}}}}}}},"mastodon":{"type":"object","nullable":true,"description":"Mastodon options on the post. Present when the post is a Mastodon thread; omitted otherwise.","properties":{"thread_parts":{"type":"array","description":"When the post is a chained Mastodon thread (2–25 parts), this returns the canonical status text in publish order. For single posts the array is omitted and the text lives in `content.mastodon` (or `content.default`). Each part also includes any per-status `media_urls`.","items":{"type":"object","properties":{"id":{"type":"string","description":"Stable client-side id for the part"},"text":{"type":"string","maxLength":500},"media_urls":{"type":"array","items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","description":"Accessibility description (alt text) for this media item."}}}]}}}}}}},"threads":{"type":"object","nullable":true,"description":"Threads options on the post. Present when the post is a Threads thread (multi-post chain); omitted otherwise.","properties":{"thread_parts":{"type":"array","description":"When the post is a chained Threads thread (2 to 25 parts), this returns the canonical post text in publish order. For single posts the array is omitted and the text lives in `content.threads` (or `content.default`). Each part also includes any per-post `media_urls`.","items":{"type":"object","properties":{"id":{"type":"string","description":"Stable client-side id for the part"},"text":{"type":"string","maxLength":500},"media_urls":{"type":"array","items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","description":"Accessibility description (alt text) for this media item."}}}]}}}}}}},"instagram":{"type":"object","nullable":true,"description":"Instagram options on the post. Present when set; includes `first_comment` (the configured auto first comment) and, after publish, `first_comment_result` describing the outcome.","properties":{"first_comment":{"type":"string","description":"The configured first-comment text."},"first_comment_result":{"$ref":"#/components/schemas/FirstCommentResult"}}},"facebook":{"type":"object","nullable":true,"description":"Facebook options on the post — `first_comment` and its post-publish `first_comment_result`.","properties":{"first_comment":{"type":"string"},"first_comment_result":{"$ref":"#/components/schemas/FirstCommentResult"}}},"linkedin":{"type":"object","nullable":true,"description":"LinkedIn Profile options on the post — `first_comment`, its post-publish `first_comment_result`, and `carousel_as_images` (present when the post opts out of the default PDF document carousel).","properties":{"first_comment":{"type":"string"},"first_comment_result":{"$ref":"#/components/schemas/FirstCommentResult"},"carousel_as_images":{"type":"boolean"}}},"linkedin_page":{"type":"object","nullable":true,"description":"LinkedIn Company Page options on the post — `first_comment`, its post-publish `first_comment_result`, and `carousel_as_images` (present when the post opts out of the default PDF document carousel).","properties":{"first_comment":{"type":"string"},"first_comment_result":{"$ref":"#/components/schemas/FirstCommentResult"},"carousel_as_images":{"type":"boolean"}}},"youtube":{"type":"object","nullable":true,"description":"YouTube options on the post. After publish, `first_comment` and its `first_comment_result` are nested here alongside the Shorts metadata.","properties":{"first_comment":{"type":"string"},"first_comment_result":{"$ref":"#/components/schemas/FirstCommentResult"}}},"tiktok":{"type":"object","nullable":true,"description":"TikTok options on the post. After publish, `first_comment` and its `first_comment_result` are nested here alongside the TikTok publish options. First comments need the workspace's TikTok comments authorization.","properties":{"first_comment":{"type":"string"},"first_comment_result":{"$ref":"#/components/schemas/FirstCommentResult"}}}}},"FirstCommentResult":{"type":"object","description":"Outcome of an auto first-comment attempt, written after the main post publishes.","properties":{"status":{"type":"string","enum":["posted","failed","skipped"],"description":"`posted` = comment is live; `failed` = the main post published but the comment did not (see `error`; retry via POST /posts/retry-first-comment); `skipped` = the main post did not publish so the comment was never attempted."},"pending":{"type":"boolean","nullable":true,"description":"TikTok only. `true` while the comment waits for TikTok to return the final video id (status is `failed` with an explanatory error meanwhile); the comment is posted automatically once the id resolves, usually within minutes."},"comment_id":{"type":"string","nullable":true},"url":{"type":"string","nullable":true},"error":{"type":"string","nullable":true},"posted_at":{"type":"string","format":"date-time","nullable":true}}},"LinkedInPollFields":{"type":"object","required":["question","options","duration"],"properties":{"question":{"type":"string","maxLength":140,"description":"The poll question."},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"string","maxLength":30},"description":"2-4 answer options."},"duration":{"type":"string","enum":["ONE_DAY","THREE_DAYS","SEVEN_DAYS","FOURTEEN_DAYS"],"description":"How long the poll stays open for votes."}}},"CreatePostRequest":{"type":"object","required":["content","accounts"],"properties":{"content":{"type":"object","description":"Post content. Use 'default' for all platforms, or platform-specific keys: instagram, facebook, linkedin, linkedin_page, youtube, tiktok, pinterest, x, threads, bluesky, mastodon, google_business. Coming soon: reddit, snapchat.","example":{"default":"Hello world!","instagram":"Hello from IG! #awesome"}},"accounts":{"type":"array","items":{"type":"string"},"description":"Social accounts to post to. Each entry is either:\n- A bare platform name (e.g. `\"youtube\"`, `\"linkedin_page\"`), or\n- The composite ID returned by `GET /v1/accounts` in the form `\"<workspace_id>_<platform>\"` (e.g. `\"844008_youtube\"`).\n\nThe composite form's workspace prefix must match the workspace your API key belongs to. Mismatched or unknown ids return 400 `validation_error` with an \"Unknown account/platform\" message — always source ids from `GET /v1/accounts` for the API key in use.\n"},"type":{"type":"string","enum":["post","story","reel"],"default":"post","description":"Post type: 'post' (feed), 'story' (Instagram/Facebook), 'reel' (Instagram/Facebook/YouTube/TikTok). A 'reel' must have video media (MP4/MOV); an image is rejected with 400 validation_error at schedule/publish — use 'post' to share an image. A 'story' takes 1 to 10 media items: each item is one slide, published as its own story in the order given (Instagram and Facebook have no multi-slide story container). More than 10 returns 400 validation_error."},"media":{"oneOf":[{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) for this media item. Delivered to Mastodon (media description), Bluesky (embed alt), X (media metadata, photos/GIFs only), Pinterest (pin alt_text fallback), Instagram (images and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents)."}}}]},"description":"Flat array of media IDs — same for all platforms. Entries are bare IDs or { id, alt } objects."},{"type":"object","additionalProperties":{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"alt":{"type":"string","maxLength":1500}}}]}},"description":"Per-platform object — keys: 'default', 'instagram', 'pinterest', etc. Entries are bare IDs or { id, alt } objects."}],"description":"Media IDs from POST /media/upload. Flat array or per-platform object. Any entry may be an { id, alt } object to carry a per-media accessibility description (alt text, max 1500 chars). For type 'story', each item is one slide (max 10, published in order)."},"media_urls":{"oneOf":[{"type":"array","items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) for this media item. Delivered to Mastodon (media description), Bluesky (embed alt), X (media metadata, photos/GIFs only), Pinterest (pin alt_text fallback), Instagram (images and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents)."}}}]},"description":"Flat array — same media for all platforms. Entries are URL strings or { url, alt } objects."},{"type":"object","additionalProperties":{"type":"array","items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1500}}}]}},"description":"Per-platform object — keys: 'default' (fallback for all platforms), 'instagram', 'pinterest', etc. Selected platforms without an explicit key inherit from 'default'. Pass an empty array (e.g. 'facebook': []) to explicitly opt a platform out of media. Entries are URL strings or { url, alt } objects.","example":{"default":[{"url":"https://example.com/photo.jpg","alt":"A red bicycle leaning against a brick wall"}],"instagram":["https://example.com/square.jpg"]}}],"description":"External image/video/PDF URLs to download and attach (max 10 total, each file ≤ 100 MB). Flat array or per-platform object. Any entry may be a { url, alt } object to carry a per-media accessibility description (alt text, max 1500 chars) — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). When using per-platform format, 'default' acts as fallback for selected platforms without their own key. A PDF URL is rasterized into one image slide per page (max 20, in order) — on LinkedIn it publishes as a swipeable document, elsewhere as an image carousel; slides inherit the entry's alt. For files over 100 MB (up to 1 GB), upload first via POST /v1/media/upload-from-url and attach the returned media id via the `media` field; oversized media_urls fail with code `file_too_large`."},"schedule_at":{"type":"string","format":"date-time","description":"ISO 8601 datetime to schedule. Omit for draft."},"publish_now":{"type":"boolean","description":"Set true to publish immediately"},"link_url":{"type":"string","format":"uri","description":"URL to share as a rich preview card on platforms that support link-share posts (currently LinkedIn and Facebook). When set on a text post, the URL renders as a preview tile with thumbnail / title / description instead of plain text. Ignored on platforms that don't support link shares (Instagram, TikTok, etc.) and on posts that already have media attached — media takes precedence.","example":"https://goodlightai.com/blog/new-feature"},"link_title":{"type":"string","description":"Optional title for the link-share preview. LinkedIn uses this directly when set. Facebook ignores this — it fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."},"link_description":{"type":"string","description":"Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description. Omit to defer to OG metadata."},"link_thumbnail_url":{"type":"string","format":"uri","description":"Optional thumbnail image URL for the preview card. Currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first); included for forward compatibility."},"source":{"type":"string","description":"Self-reported origin of the integration creating this post. Defaults to 'api' if omitted. Use 'zapier', 'make', 'n8n', etc. to identify your integration."},"location_id":{"type":"string","description":"Instagram only. Facebook Place/Page ID to tag the post's location with (e.g. a dealership or storefront). Applied to single-image and carousel feed posts via Instagram's content-publishing `location_id`. Find a Place's Page ID via Facebook's Pages Search API. Ignored by other platforms. Send an empty string on PATCH to clear it.","example":"1234567890"},"collaborators":{"type":"array","items":{"type":"string"},"maxItems":3,"description":"Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Applied to image, carousel, and reel posts — NOT Stories. Invited users get an invitation in the Instagram app; once accepted, the post also appears on their profile and feed. Usernames are case-insensitive and a leading '@' is stripped. Private accounts and non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms. Send an empty array on PATCH to clear collaborators.","example":["therock","kevinhart4real"]},"user_tags":{"type":"array","description":"Instagram only. Tag public Instagram accounts at specific positions on a photo. Photos only — NOT video, reels, or stories. For a single image omit `image_index` (or set 0); for a carousel, set `image_index` to the 0-based slide the tag belongs to (tags attach to that slide's image). Public accounts only; private/non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms. Send an empty array on PATCH to clear tags.","items":{"type":"object","required":["username","x","y"],"properties":{"username":{"type":"string","description":"Public Instagram username to tag. A leading '@' is stripped."},"x":{"type":"number","minimum":0,"maximum":1,"description":"Horizontal position of the tag, 0.0–1.0 from the photo's left edge."},"y":{"type":"number","minimum":0,"maximum":1,"description":"Vertical position of the tag, 0.0–1.0 from the photo's top edge."},"image_index":{"type":"integer","minimum":0,"description":"0-based carousel slide the tag applies to. Defaults to 0 (the only image for a single-image post)."}}},"example":[{"username":"therock","x":0.5,"y":0.8}]},"hashtag_set":{"type":"string","description":"Name of a saved hashtag set (see GET /hashtag-sets) to apply to this post, matched case-insensitively. The set's tags are merged in ONCE at create time — the post stores plain text, so later edits to the set never change this post. Tags already present in a caption are skipped (case-insensitive), and hard platform caps (Instagram 30) are enforced with a 400 `hashtag_limit_exceeded` before the platform can reject the post. Mutually interchangeable with `hashtag_set_id`.","example":"Fitness Brand"},"hashtag_set_id":{"type":"string","description":"Id of a saved hashtag set to apply. Same behavior as `hashtag_set`; pass one or the other."},"hashtag_placement":{"type":"string","enum":["caption_append","first_comment"],"default":"caption_append","description":"Where the set's tags land. `caption_append` (default) appends them to each target platform's caption after a blank line. `first_comment` puts them in the auto first comment on comment-capable platforms (Instagram, Facebook, LinkedIn Profile/Page, YouTube, TikTok with comments enabled) — appended after any `first_comment` you passed explicitly — and falls back to caption_append on platforms without a comment API. Stories always use captions (first comments are never posted on Stories)."},"hashtag_platforms":{"type":"array","items":{"type":"string"},"description":"Optional subset of the post's selected platforms to apply the set to (e.g. only [\"instagram\", \"tiktok\"]). Defaults to all selected platforms."},"pinterest":{"type":"object","description":"Pinterest-specific options. Attach 2–5 images via `media_urls.pinterest` (or the flat `media_urls`) to publish a single carousel pin; the title, description, link, and alt_text apply to the whole pin. A single image or a single video still publishes as a normal image / video pin. **Carousel constraints:** carousels hard-cap at 5 images — attaching more responds 400 `validation_error` at create/schedule time. Pinterest's v5 API also requires every slide in a carousel pin to share the same aspect ratio (1% tolerance). If you attach mixed-ratio images and try to schedule or publish, the API responds 400 `validation_error` with `mismatched_slides: [n, ...]` listing the 1-indexed slide numbers that need to be re-cropped or removed. Both checks run when `publish_now: true`, when `schedule_at` is set, and when `PATCH` flips a draft to a scheduled status — drafts are exempt so you can iterate on media.","properties":{"board_id":{"type":"string","description":"Required. Pinterest board ID"},"title":{"type":"string","description":"Pin title (max 100 characters). For carousel pins, applies to the whole pin, not individual slides."},"link":{"type":"string","description":"Destination URL for the pin"},"video_cover":{"type":"string","description":"Cover image URL for video pins (image URL — JPEG/PNG)"},"alt_text":{"type":"string","description":"Accessibility alt text for the pin image (max 500 characters)"}}},"youtube":{"type":"object","description":"YouTube Shorts options. Only applies when `type` is `reel` and `youtube` is among the selected accounts.","properties":{"title":{"type":"string","description":"Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."},"tags":{"type":"array","items":{"type":"string"},"description":"Video tags"},"privacy_status":{"type":"string","enum":["public","private","unlisted"],"default":"public"},"category_id":{"type":"string","description":"YouTube category ID (default \"22\" People & Blogs)"},"made_for_kids":{"type":"boolean","default":false},"notify_subscribers":{"type":"boolean","default":false},"contains_synthetic_media":{"type":"boolean","default":false,"description":"Mark as AI-generated content"},"first_comment":{"type":"string","maxLength":10000,"description":"Text auto-posted as the first comment on the video right after it publishes (commentThreads.insert). The video must have comments enabled. Empty/omitted = no first comment."}}},"instagram":{"type":"object","description":"Instagram Reel options","properties":{"share_to_feed":{"type":"boolean","default":false,"description":"Also share reel to feed"},"thumbnail_type":{"type":"string","enum":["from-video","from-library"],"description":"How to select thumbnail"},"thumb_offset":{"type":"integer","description":"Video timestamp in milliseconds (when thumbnail_type is from-video)"},"cover_url":{"type":"string","description":"Custom thumbnail URL (when thumbnail_type is from-library)"},"audio_id":{"type":"string","description":"Licensed music for the Reel — an audio ID from `GET /audio/search`. Replaces/mixes with the video's own audio at publish time. Requires a Facebook account connected to the workspace whose Page is linked to this Instagram account. Reels only.","example":"587784541076604"},"audio_volume":{"type":"integer","minimum":0,"maximum":100,"default":100,"description":"Volume of the attached music track (0 = muted). Only used with audio_id."},"video_volume":{"type":"integer","minimum":0,"maximum":100,"default":100,"description":"Volume of the video's original audio (0 = muted, e.g. music-only Reel). Only used with audio_id."},"first_comment":{"type":"string","maxLength":2200,"description":"Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories. Empty/omitted = no first comment."},"is_trial_reel":{"type":"boolean","default":false,"description":"Publish the reel as an Instagram Trial Reel: shown to non-followers first to test performance before (optionally) graduating to everyone. Reels only. NOT available on every account — Instagram requires roughly 1,000+ followers and enables the feature per account (the account sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error."},"trial_graduation_strategy":{"type":"string","enum":["MANUAL","SS_PERFORMANCE"],"default":"MANUAL","description":"How a Trial Reel graduates to all followers. MANUAL: you decide in the Instagram app. SS_PERFORMANCE: Instagram automatically shares it with followers if it performs well. Only used with is_trial_reel."},"is_ai_generated":{"type":"boolean","default":false,"description":"Self-disclosure that this post's media is AI-generated. Adds Instagram's 'AI info' label. Applies to single images, videos, and Reels; for a carousel the label applies to the whole post, not individual slides. Cannot be added or removed after publish — must be set at post time. Not available for Stories."}}},"facebook":{"type":"object","description":"Facebook options","properties":{"first_comment":{"type":"string","maxLength":8000,"description":"Text auto-posted as the first comment on the post/reel right after it publishes. Only works on Page posts (the API cannot comment on personal-profile posts). Not posted for Stories. Empty/omitted = no first comment."}}},"linkedin":{"type":"object","description":"LinkedIn Profile options","properties":{"first_comment":{"type":"string","maxLength":1250,"description":"Text auto-posted as the first comment on the profile post right after it publishes. Common for 'link in first comment' to avoid the in-caption link reach penalty. Empty/omitted = no first comment."},"carousel_as_images":{"type":"boolean","default":false,"description":"Multi-image style for the profile post. By default a post with 2+ images publishes as LinkedIn's swipeable PDF document carousel. Set true to publish the images as a plain multi-image gallery instead. Ignored for 0-1 images, video posts, and polls."}}},"linkedin_page":{"type":"object","description":"LinkedIn Company Page options","properties":{"first_comment":{"type":"string","maxLength":1250,"description":"Text auto-posted as the first comment on the company-page post right after it publishes. Empty/omitted = no first comment."},"carousel_as_images":{"type":"boolean","default":false,"description":"Multi-image style for the company-page post. By default a post with 2+ images publishes as LinkedIn's swipeable PDF document carousel. Set true to publish the images as a plain multi-image gallery instead. Ignored for 0-1 images, video posts, and polls."}}},"linkedin_poll":{"type":"object","nullable":true,"description":"Non-sponsored LinkedIn poll(s) — independent per channel, keyed by `linkedin` (personal profile) / `linkedin_page` (company page). A poll is mutually exclusive with media and a link share on that channel's post — a poll takes priority over both at publish time. Requires `content.linkedin` (or `content.default`) as that channel's caption/commentary; the poll itself only carries the question/options/duration.","properties":{"linkedin":{"allOf":[{"$ref":"#/components/schemas/LinkedInPollFields"}],"nullable":true,"description":"Poll for the personal profile post. Omit or null = not a poll."},"linkedin_page":{"allOf":[{"$ref":"#/components/schemas/LinkedInPollFields"}],"nullable":true,"description":"Poll for the company page post. Omit or null = not a poll."}}},"tiktok":{"type":"object","description":"TikTok options","properties":{"privacy_level":{"type":"string","enum":["PUBLIC_TO_EVERYONE","MUTUAL_FOLLOW_FRIENDS","FOLLOWER_OF_CREATOR","SELF_ONLY"],"default":"PUBLIC_TO_EVERYONE"},"disable_comment":{"type":"boolean","default":false},"disable_duet":{"type":"boolean","default":false},"disable_stitch":{"type":"boolean","default":false},"video_cover_timestamp_ms":{"type":"integer","default":0,"description":"Reels only. Timestamp (ms) of the video frame to use as the cover."},"is_aigc":{"type":"boolean","default":false,"description":"Mark as AI-generated content"},"brand_content_toggle":{"type":"boolean","default":false,"description":"Paid partnership disclosure (promotes a third-party brand)"},"brand_organic_toggle":{"type":"boolean","default":false,"description":"Your own brand disclosure (promotes your own business)"},"auto_add_music":{"type":"boolean","default":false,"description":"Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."},"first_comment":{"type":"string","maxLength":150,"description":"Text auto-posted as the first comment on the TikTok video right after it publishes (max 150 characters). Requires the workspace's TikTok comments authorization (the \"Enable comments\" button on the TikTok channel card, a second TikTok consent); without it `first_comment_result.status` is `failed` with an explanatory error. The video must be public with comments allowed. TikTok sometimes returns the final video id a few minutes after publish — in that case the comment is posted automatically as soon as the id resolves (`first_comment_result.pending` is true meanwhile). Empty/omitted = no first comment."}}},"x":{"type":"object","description":"X (Twitter) options","properties":{"reply_settings":{"type":"string","enum":["","following","mentionedUsers"],"description":"Who can reply (empty = everyone)"},"paid_partnership":{"type":"boolean","default":false,"description":"Mark as a paid partnership disclosure"},"made_with_ai":{"type":"boolean","default":false,"description":"Mark as AI-generated content"},"thread_parts":{"type":"array","minItems":2,"maxItems":25,"description":"Publish as a chained thread instead of a single tweet. When omitted (or length < 2), `content.x` (or `content.default`) is used as a single tweet. Each part is posted in order via `in_reply_to_tweet_id`. Attach media to any part (first tweet or any reply) with either `media_ids` (numeric Library IDs from POST /v1/media — the same IDs single posts use) or `media_urls` (external links). Combined max is 4 media items per part. If the post also has top-level `media_urls.x` / media and the first part has no media of its own, the top-level media is attached to the first tweet.","items":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":280,"description":"Tweet text (≤ 280 chars; X Premium accounts can go higher but the API enforces 280). Counted the way X counts, where every link weighs 23 characters (its t.co length) regardless of the real URL length."},"media_ids":{"type":"array","maxItems":4,"items":{"oneOf":[{"type":"string"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) for this media item."}}}]},"description":"Optional per-part media as numeric Library IDs returned by POST /v1/media (bare IDs or { id, alt } objects). Resolved to hosted URLs at create time. Combined with media_urls, max 4 per tweet."},"media_urls":{"type":"array","maxItems":4,"items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) for this media item."}}}]},"description":"Optional per-part media as external URLs (downloaded + hosted), as URL strings or { url, alt } objects. Combined with media_ids, max 4 per tweet."}}},"example":[{"text":"1/3 — our results are in 👇","media_ids":["12345"]},{"text":"2/3 — grab your free pick here: https://example.com/signup"},{"text":"3/3 — and here's the slip 🎁","media_ids":["12346"]}]}}},"bluesky":{"type":"object","description":"Bluesky options","properties":{"thread_parts":{"type":"array","minItems":2,"maxItems":25,"description":"Publish as a chained Bluesky thread instead of a single post. When omitted (or length < 2), `content.bluesky` (or `content.default`) is used as a single post. Each part is posted in order via AT Protocol reply refs (root + parent) so it renders as a thread. Attach media to any part with either `media_ids` (numeric Library IDs from POST /v1/media — the same IDs single posts use) or `media_urls` (external links). A part is a single video OR up to 4 images. If the post also has top-level `media_urls.bluesky` / media and the first part has no media of its own, the top-level media is attached to the first post. Links, mentions and hashtags are made clickable automatically (rich-text facets).","items":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":300,"description":"Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."},"media_ids":{"type":"array","maxItems":4,"items":{"oneOf":[{"type":"string"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) for this media item."}}}]},"description":"Optional per-part media as numeric Library IDs returned by POST /v1/media (bare IDs or { id, alt } objects). Resolved to hosted URLs at create time. A part is one video OR up to 4 images."},"media_urls":{"type":"array","maxItems":4,"items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) for this media item."}}}]},"description":"Optional per-part media as external URLs (downloaded + hosted), as URL strings or { url, alt } objects. A part is one video OR up to 4 images."}}},"example":[{"text":"1/3 — our results are in 👇"},{"text":"2/3 — read the full writeup: https://example.com/post"},{"text":"3/3 — thanks for reading! #buildinpublic"}]}}},"mastodon":{"type":"object","description":"Mastodon options","properties":{"thread_parts":{"type":"array","minItems":2,"maxItems":25,"description":"Publish as a chained Mastodon thread instead of a single status. When omitted (or length < 2), `content.mastodon` (or `content.default`) is used as a single status. Each part is posted in order as a native reply to the previous status (in_reply_to_id) so it renders as a thread. Attach media to any part with either `media_ids` (numeric Library IDs from POST /v1/media — the same IDs single posts use) or `media_urls` (external links) — max 4 media items per part. If the post also has top-level `media_urls.mastodon` / media and the first part has no media of its own, the top-level media is attached to the first status.","items":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":500,"description":"Status text (≤ 500 characters by default; some instances allow more)."},"media_ids":{"type":"array","maxItems":4,"items":{"oneOf":[{"type":"string"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) shown to screen-reader users — the Mastodon community strongly values alt text on images."}}}]},"description":"Optional per-part media as numeric Library IDs returned by POST /v1/media (bare IDs or { id, alt } objects). Resolved to hosted URLs at create time. Max 4 media items per part."},"media_urls":{"type":"array","maxItems":4,"items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) shown to screen-reader users — the Mastodon community strongly values alt text on images."}}}]},"description":"Optional per-part media as external URLs (downloaded + hosted), as URL strings or { url, alt } objects. Max 4 media items per part."}}},"example":[{"text":"1/3 — our picks are in 👇"},{"text":"2/3 — read the full writeup: https://example.com/post"},{"text":"3/3 — thanks for reading! #fediverse"}]}}},"threads":{"type":"object","description":"Threads options","properties":{"thread_parts":{"type":"array","minItems":2,"maxItems":25,"description":"Publish as a chained Threads thread instead of a single post. When omitted (or length < 2), `content.threads` (or `content.default`) is used as a single post. Each part is posted in order as a native reply to the previous post (reply_to_id) so it renders as a thread under the first post. Attach media to any part with either `media_ids` (numeric Library IDs from POST /v1/media, the same IDs single posts use) or `media_urls` (external links), max 10 media items per part (images and videos can be mixed; 2+ items publish as a carousel). If the post also has top-level `media_urls.threads` / media and the first part has no media of its own, the top-level media is attached to the first post.","items":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":500,"description":"Post text (up to 500 characters)."},"media_ids":{"type":"array","maxItems":10,"items":{"oneOf":[{"type":"string"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) sent to Threads as alt_text."}}}]},"description":"Optional per-part media as numeric Library IDs returned by POST /v1/media (bare IDs or { id, alt } objects). Resolved to hosted URLs at create time. Max 10 media items per part."},"media_urls":{"type":"array","maxItems":10,"items":{"oneOf":[{"type":"string","format":"uri"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1500,"description":"Accessibility description (alt text) sent to Threads as alt_text."}}}]},"description":"Optional per-part media as external URLs (downloaded + hosted), as URL strings or { url, alt } objects. Max 10 media items per part."}}},"example":[{"text":"1/3: our results are in"},{"text":"2/3: read the full writeup: https://example.com/post"},{"text":"3/3: thanks for reading! #buildinpublic"}]}}},"google_business":{"type":"object","description":"Google Business Profile options. Use to publish EVENT or OFFER posts in addition to STANDARD, attach a call-to-action button, or both. STANDARD posts can also use this object purely to add a CTA. The shape mirrors Google's LocalPost JSONB exactly; see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost.\n\nCaption rules (enforced at scheduling — text that violates these returns `validation_error` 400 before the post is saved):\n  • No phone numbers in the caption (use a CALL button instead).\n  • No inline URLs / bare domains / email addresses (use a LEARN_MORE, BOOK, SHOP, SIGN_UP, or ORDER button instead).\n  • Caption max 1500 characters.\n  • Media optional (text-only posts allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n  • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling.\n","properties":{"topic_type":{"type":"string","enum":["STANDARD","EVENT","OFFER"],"default":"STANDARD","description":"Local post topic type. ALERT is reserved by Google and not exposed."},"cta":{"type":"object","description":"Optional call-to-action button rendered under the caption on the Google Business Profile.","properties":{"actionType":{"type":"string","enum":["LEARN_MORE","BOOK","ORDER","SHOP","SIGN_UP","CALL"]},"url":{"type":"string","format":"uri","description":"Required for every actionType except CALL. CALL uses the location's phone number from the business profile."}}},"event":{"type":"object","description":"Required when topic_type is EVENT.","properties":{"title":{"type":"string","maxLength":58},"schedule":{"type":"object","description":"Google's split date+time shape. startDate is required; startTime/endDate/endTime are optional. End (if present) must be ≥ start.","properties":{"startDate":{"type":"object","properties":{"year":{"type":"integer"},"month":{"type":"integer"},"day":{"type":"integer"}}},"startTime":{"type":"object","properties":{"hours":{"type":"integer"},"minutes":{"type":"integer"}}},"endDate":{"type":"object","properties":{"year":{"type":"integer"},"month":{"type":"integer"},"day":{"type":"integer"}}},"endTime":{"type":"object","properties":{"hours":{"type":"integer"},"minutes":{"type":"integer"}}}}}}},"offer":{"type":"object","description":"Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl.","properties":{"couponCode":{"type":"string","maxLength":58},"redeemOnlineUrl":{"type":"string","format":"uri","description":"Must be https://. http URLs are rejected."},"termsConditions":{"type":"string","maxLength":4000}}}}}}},"Media":{"type":"object","properties":{"id":{"type":"string","description":"Media ID (use this in post creation)"},"url":{"type":"string","description":"Permanent CDN URL for the media"},"thumbnail_url":{"type":"string","nullable":true,"description":"CDN URL of the generated preview thumbnail (videos only; null for images, which use `url` directly)"},"type":{"type":"string","enum":["image","video"]},"name":{"type":"string","nullable":true,"description":"Human-readable label set on upload or via PATCH (e.g. \"pp-play5get50\"). Falls back to the storage filename when unset. Search this with the `search` query param."},"folder_id":{"type":"string","nullable":true,"description":"Id of the folder this file lives in, or null for the root (\"All media\")"},"filename":{"type":"string","description":"Generated storage filename (stable, used internally)"},"size":{"type":"string","description":"Human-formatted file size (e.g. \"2.50 MB\")"},"status":{"type":"string","enum":["ready","processing","failed"],"description":"Processing state. 'ready' for normal uploads (usable immediately). Large videos uploaded via URL ingestion (over 100 MB) start as 'processing' — poll get media / list media until 'ready' before using them in a post. 'failed' means the source couldn't be fetched or validated.\n"},"created_at":{"type":"string","format":"date-time"}}},"MediaCompatibility":{"type":"object","description":"Whether the file fits the platforms connected to your workspace. `compatible` is true when no connected platform would reject it.\n","properties":{"compatible":{"type":"boolean"},"warnings":{"type":"array","items":{"type":"object","properties":{"platform":{"type":"string"},"display_name":{"type":"string"},"reasons":{"type":"array","items":{"type":"string"},"description":"e.g. [\"max 300 MB\"], [\"format not supported\"]"}}}},"summary":{"type":"string","nullable":true,"description":"One-line human summary, or null when fully compatible."}}},"PdfUploadResult":{"type":"object","description":"Returned when the uploaded file is a PDF. A PDF only renders natively on LinkedIn, so the server rasterizes each page into an image slide and stores each as its own media item. `data` mirrors the FIRST slide (so callers that read a single Media still work); `slides` and `media_ids` carry the whole carousel in page order. Pass ALL of `media_ids` to POST /posts as a carousel. On LinkedIn it posts as a swipeable document; on Instagram, TikTok, Threads and Pinterest as an image carousel. Capped at 20 pages.\n","properties":{"data":{"$ref":"#/components/schemas/Media"},"slides":{"type":"array","description":"Every page as a Media item, in page order.","items":{"$ref":"#/components/schemas/Media"}},"media_ids":{"type":"array","description":"Slide ids in page order — pass all of them to POST /posts.","items":{"type":"string"}},"pdf":{"type":"object","properties":{"total_pages":{"type":"integer"},"rendered_pages":{"type":"integer"},"truncated":{"type":"boolean","description":"True when the PDF had more than 20 pages and only the first 20 were imported."}}},"compatibility":{"$ref":"#/components/schemas/MediaCompatibility"},"message":{"type":"string"}}},"Folder":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"parent_id":{"type":"string","nullable":true,"description":"Parent folder id, or null for a top-level folder"},"item_count":{"type":"integer","description":"Number of media items directly in this folder"},"created_at":{"type":"string","format":"date-time"}}},"HashtagSet":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":"Set name, unique per workspace (case-insensitive)"},"hashtags":{"type":"array","items":{"type":"string"},"description":"Ordered tags WITHOUT the leading '#'"},"hashtag_count":{"type":"integer"},"preview":{"type":"string","description":"The tags rendered as caption text, e.g. \"#fitness"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Account":{"type":"object","properties":{"id":{"type":"string","description":"Account ID (use this when creating posts)"},"platform":{"type":"string","enum":["instagram","facebook","linkedin","linkedin_page","youtube","tiktok","pinterest","bluesky","threads","mastodon","x","google_business","reddit","snapchat"]},"username":{"type":"string","description":"Social media username/handle"},"display_name":{"type":"string","description":"Display name on the platform"},"profile_picture":{"type":"string","description":"Profile picture URL"},"content_types":{"type":"array","items":{"type":"string","enum":["post","story","reel"]},"description":"Supported content types for this platform"},"status":{"type":"string","enum":["active","needs_reconnect"],"description":"Connection status. `needs_reconnect` means the account is still wired up to the workspace but its OAuth token has been revoked or expired; posts to this account will fail until the user reconnects in the OmniSocials UI. Most often caused by the user changing their password, deauthorizing OmniSocials on the platform, or the token expiring without a successful refresh."},"needs_reconnect":{"type":"boolean","description":"Convenience boolean — `true` when `status` is `needs_reconnect`."},"reauth_reason":{"type":"string","nullable":true,"description":"Short reason from the platform (e.g. \"OAuthException: Session has expired\"). Present only when `needs_reconnect` is true. Token-health tracking exists for Instagram, Facebook, Threads, X, Pinterest, LinkedIn (profile + page); other platforms always report `needs_reconnect: false`."},"boards":{"type":"array","description":"Pinterest boards (only present for Pinterest accounts). Use board `id` as `board_id` when creating Pinterest posts.","items":{"type":"object","properties":{"id":{"type":"string","description":"Board ID (use as board_id when creating posts)"},"name":{"type":"string","description":"Board name"}}}},"platform_details":{"type":"object","description":"Platform-specific details. Present for X accounts with Premium subscription.","properties":{"subscription_type":{"type":"string","description":"X subscription tier (e.g. \"Premium\", \"PremiumPlus\")"}}},"connected_at":{"type":"string","format":"date-time"}}},"PostAnalytics":{"type":"object","properties":{"post_id":{"type":"string"},"platforms":{"type":"object","description":"One entry per platform the post was published to. For thread platforms (X, Bluesky, Mastodon, Threads) a post can publish as a chain of several parts; the entry sums each part's metrics so you get the thread's total (matching the in-app analytics), and reports the thread's root as `platform_post_id`.\n","additionalProperties":{"type":"object","properties":{"platform":{"type":"string"},"platform_post_id":{"type":"string","description":"The post's id on the platform. For a thread this is the root part (tweet/post/toot)."},"metrics":{"type":"object","description":"Platform-specific metrics, as the platform reports them. A missing key means the platform does not report it; a 0 is a measured zero. For a thread, counts are summed across all parts and rates/averages are averaged. Common keys: `views`, `impressions`, `reach`, `likes`, `comments`, `replies`, `shares`, `reposts`, `quotes`, `saves`, `bookmarks`, `clicks`, `link_clicks`, `profile_visits`, `follows` (follows gained from the post), `engagement`, `reactions` (object by type). Video keys: `video_views`, `duration` (s), `avg_watch_time` (s), `total_watch_time` (s), `watch_time_percentage`, `completion_rate`, `skip_rate`, `replays`, `engaged_views`, `traffic_sources` (YouTube, object), `impression_sources` and `audience_types` (TikTok Business, objects with percentages). Pinterest: `pin_clicks`, `outbound_clicks`, `save_rate`, `pin_click_rate`, `outbound_click_rate`, `video_starts`. Instagram stories: `navigation`, `navigation_breakdown`, `profile_activity_breakdown`, `completion_analysis`. Items a platform cannot measure (Google Business posts, deleted videos) carry `metrics_unavailable: true` and a `note` instead of zeros.\n"},"thread_parts":{"type":"integer","description":"Number of parts summed into this entry. 1 for a single (non-thread, single-slide) post. For a multi-slide story this is the number of slides with stats."},"story_slides":{"type":"array","description":"Multi-slide stories only. One entry per slide in publish order, each with that slide's own metrics (the parent `metrics` is the sum across slides). Absent on single-slide stories and on every other post type.\n","items":{"type":"object","properties":{"index":{"type":"integer","description":"Zero-based slide position."},"platform_post_id":{"type":"string","description":"The slide's own story id on the platform."},"metrics":{"type":"object"},"collected_at":{"type":"string","format":"date-time"}}}},"collected_at":{"type":"string","format":"date-time","description":"Most recent collection time across the summed parts."}}}}}},"AnalyticsOverview":{"type":"object","properties":{"total_posts":{"type":"integer"},"total_platforms":{"type":"integer"},"total_engagement":{"type":"number"},"total_impressions":{"type":"number"},"average_engagement_rate":{"type":"number","description":"Engagement rate as a percentage (0-100)"},"top_performing_platform":{"type":"string","nullable":true},"platform_breakdown":{"type":"object","additionalProperties":{"type":"object","properties":{"posts":{"type":"integer"},"total_engagement":{"type":"number"},"total_impressions":{"type":"number"},"average_engagement":{"type":"number"},"engagement_rate":{"type":"number"}}}}}},"AccountAnalytics":{"type":"object","properties":{"platform":{"type":"string"},"platform_account_id":{"type":"string"},"date":{"type":"string","format":"date"},"metrics":{"type":"object","description":"Account-level metrics for the snapshot date, as the platform reports them. Stock values: `followers`, `following`, `posts`, and platform extras (`monthly_views`, `pin_count`, `listed`, `media_count`, `average_rating`, `review_count`, `total_views`, `total_videos`). Day values (`period: \"daily\"`): `impressions` (views), `reach`, `engagement`, `likes`, `comments`, `shares`, `saves`, `profile_views`, `link_clicks`, `follows_gained`, `follows_lost`, `accounts_engaged`, `video_views`, `watch_time_minutes`, and Google Business `calls`, `direction_requests`, `website_clicks`, `bookings`, `impressions_breakdown`, `search_keywords`. Audience objects when available: `demographics` (age, gender, country, city, or LinkedIn function, seniority, industry) with `demographics_unit`, `online_followers` (UTC hour -> followers online), `link_clicks_breakdown` (Threads, per URL). LinkedIn profile rows keep `impressions_lifetime` and carry `period: \"lifetime\"` when the daily breakdown is not available. Read the `note` field where present.\n"},"collected_at":{"type":"string","format":"date-time"}}},"Webhook":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri","description":"URL to receive webhook events"},"events":{"type":"array","items":{"type":"string","enum":["post.scheduled","post.published","post.failed"]},"description":"Events to subscribe to"},"is_active":{"type":"boolean"},"last_triggered_at":{"type":"string","format":"date-time"},"failure_count":{"type":"integer","description":"Consecutive delivery failures since the last success. Resets to 0 on any successful delivery, so 0 means currently healthy."},"last_failure":{"type":"object","nullable":true,"description":"Details of the most recent failed delivery. Not cleared on success, so it persists as the last known problem even when failure_count is 0. Null if no delivery has ever failed.","properties":{"at":{"type":"string","format":"date-time","description":"When the failure was recorded"},"status":{"type":"integer","nullable":true,"description":"HTTP status your endpoint returned, or null for a network error/timeout"},"error":{"type":"string","description":"Human-readable reason (e.g. \"Endpoint responded with HTTP 404\", \"No response within 10s (timeout)\")"},"attempts":{"type":"integer","description":"Number of delivery attempts made before giving up"}}},"created_at":{"type":"string","format":"date-time"}}},"WebhookPayload":{"type":"object","properties":{"id":{"type":"string","description":"Unique delivery event ID (also sent as the X-OmniSocials-Delivery header)"},"type":{"type":"string","enum":["post.scheduled","post.published","post.failed"],"description":"Event type"},"created_at":{"type":"string","format":"date-time"},"data":{"type":"object","description":"Event payload","properties":{"post_id":{"type":"string","description":"The post's ID (numeric string)"},"workspace_id":{"type":"integer"},"status":{"type":"string","description":"Final post status (scheduled, posted, warning, failed)"},"post_type":{"type":"string"},"scheduled_at":{"type":"string","format":"date-time","nullable":true},"published_at":{"type":"string","format":"date-time","nullable":true},"targets":{"type":"array","description":"Per-platform results. Empty for post.scheduled.","items":{"type":"object","properties":{"platform":{"type":"string"},"status":{"type":"string","description":"success or failed"},"native_post_id":{"type":"string","nullable":true,"description":"The post's ID on the platform itself (e.g. Instagram media ID, tweet ID). Null if the platform returned none."},"error":{"type":"string","description":"Present only on failed targets"}}}}}}}},"InboxParticipant":{"type":"object","properties":{"id":{"type":"string","description":"Platform-scoped id of the other party"},"name":{"type":"string"},"username":{"type":"string"},"profile_picture":{"type":"string","nullable":true}}},"InboxConversation":{"type":"object","properties":{"conversation_id":{"type":"string"},"platform":{"type":"string","enum":["instagram","facebook","linkedin","tiktok","x"]},"type":{"type":"string","enum":["dm","comment","mention"],"description":"dm = direct message; comment/mention = public interaction. LinkedIn is comment/mention only; TikTok is comment only; X is dm only."},"participant":{"$ref":"#/components/schemas/InboxParticipant"},"unread_count":{"type":"integer","description":"Unread incoming messages in this conversation"},"last_message":{"type":"object","properties":{"id":{"type":"string"},"direction":{"type":"string","enum":["incoming","outgoing"]},"text":{"type":"string"},"timestamp":{"type":"string","format":"date-time"},"is_read":{"type":"boolean"}}},"post":{"type":"object","nullable":true,"description":"Present for comment/mention conversations","properties":{"id":{"type":"string","nullable":true},"caption":{"type":"string","nullable":true},"thumbnail":{"type":"string","nullable":true}}}}},"InboxMessage":{"type":"object","properties":{"id":{"type":"string"},"conversation_id":{"type":"string"},"platform":{"type":"string"},"type":{"type":"string","enum":["dm","comment","mention"]},"direction":{"type":"string","enum":["incoming","outgoing"]},"text":{"type":"string"},"timestamp":{"type":"string","format":"date-time"},"is_read":{"type":"boolean"},"is_replied":{"type":"boolean"},"reaction":{"type":"string","nullable":true},"parent_comment_id":{"type":"string","nullable":true},"sender":{"$ref":"#/components/schemas/InboxParticipant"},"post":{"type":"object","nullable":true,"properties":{"id":{"type":"string","nullable":true},"caption":{"type":"string","nullable":true},"thumbnail":{"type":"string","nullable":true}}}}},"InboxCursorPagination":{"type":"object","properties":{"next_cursor":{"type":"string","nullable":true,"description":"Pass as ?cursor= to fetch the next page; null when there are no more results"},"has_more":{"type":"boolean"},"limit":{"type":"integer"}}}}},"security":[{"bearerAuth":[]}],"tags":[{"name":"Posts","description":"Create, schedule, update, publish, and delete social media posts across every connected channel — the core of the OmniSocials API."},{"name":"Media","description":"Upload and manage images, video, and PDFs (up to 50MB) for use in posts, reels, stories, and PDF carousels."},{"name":"Folders","description":"Organize uploaded media into folders — list, create, and delete them to keep large asset libraries manageable."},{"name":"Hashtag Sets","description":"Save reusable, named groups of hashtags per workspace and apply one to a new post in a single call — appended to captions or dropped into the auto first comment."},{"name":"Accounts","description":"List and inspect the social accounts connected to a workspace across all 12 supported channels."},{"name":"Analytics","description":"Fetch post-level and account-level metrics — impressions, engagement, reach, and follower stats — in single or bulk requests."},{"name":"Webhooks","description":"Manage webhook subscriptions for real-time events (post.scheduled, post.published, post.failed). Deliveries are signed with HMAC-SHA256."},{"name":"Locations","description":"Search and attach location tags (such as Instagram place tags) to posts."},{"name":"Audio","description":"Search and attach Instagram audio tracks — including trending music — to reels."},{"name":"Inbox","description":"Read and reply to Instagram and Facebook DMs, comments, and mentions, LinkedIn company-page comments and mentions (LinkedIn has no API DMs), TikTok video comments (comments only; replies capped at 150 characters), and X DMs for opted-in workspaces. Reads require the inbox:read scope; replies and marking read require inbox:write. Replies are limited to existing conversations (no cold outreach) and to the platform messaging window. Each workspace can send up to 1,000 replies per rolling 24 hours; contact robert@omnisocials.com for a higher limit."}],"paths":{"/posts":{"get":{"summary":"List posts","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"status","schema":{"type":"string","enum":["draft","in_approval","scheduled","posting","published","failed","warning"]},"description":"Filter by status. `published` and the legacy alias `posted` are equivalent. `in_approval` matches posts waiting for a reviewer in an approval workflow. `warning` matches partially failed posts (some platforms published, some failed) — these do NOT match `failed`, which only matches posts where every platform failed."},{"in":"query","name":"limit","schema":{"type":"integer","default":20,"maximum":100},"description":"Number of posts to return"},{"in":"query","name":"offset","schema":{"type":"integer","default":0},"description":"Number of posts to skip"}],"responses":{"200":{"description":"List of posts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Post"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}}}}},"/posts/recent-platform":{"get":{"summary":"List the user's recent posts fetched live from the connected platforms","description":"Returns the most recent posts pulled directly from each connected platform's API (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), including content that was published outside OmniSocials. This is the fallback for brand-new workspaces that have not published anything through OmniSocials yet, so GET /posts (which only lists OmniSocials posts) comes back empty. Each post is enriched with normalized `engagement` / `impressions` / `engagement_rate` so you can rank across platforms without re-deriving per-platform rules. Metrics are only present where the platform exposes them for historical posts: X, TikTok (views/likes/comments/shares, plus `average_time_watched`, `full_video_watched_rate`, `total_time_watched`, `favorites`, `reach` when the workspace enabled TikTok comments — the Business API authorization), Bluesky, Mastodon, Instagram (likes/comments/reach/views/saves/shares, via one insights call per post), Facebook, and YouTube return counts, while Threads, Pinterest, and Google Business return captions only (`metrics: {}`). LinkedIn personal profiles cannot be listed live (LinkedIn grants no such permission to apps), so their results contain posts published through OmniSocials with their latest collected stats; TikTok results are backfilled the same way for photo posts the video list omits. Fetched live on every call for most platforms — expect a few seconds of latency across many platforms. X is the exception: because X's API bills every returned post, X results may be served from a snapshot up to 24 hours old. The snapshot refreshes immediately after you publish to X through OmniSocials, so your own new posts always appear. Requires the `analytics:read` scope.\n","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","default":25,"maximum":50},"description":"Max posts to fetch per connected platform (1-50, default 25). When omitted, X returns up to 10 (its API bills per returned post); pass an explicit limit to get more from X.\n"},{"in":"query","name":"platforms","schema":{"type":"string"},"description":"Optional comma-separated platform filter (e.g. \"instagram,tiktok\"). Defaults to every connected platform. Unsupported values return 400.\n"}],"responses":{"200":{"description":"Recent posts across connected platforms, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"platform":{"type":"string"},"id":{"type":"string","nullable":true,"description":"The platform's own native post/media id (NOT the OmniSocials Post id). Use this as the stable key to store and de-duplicate each post when ingesting native content."},"permalink":{"type":"string","nullable":true,"description":"Public URL of the post on the platform. Present for Instagram, Facebook, Threads, TikTok, YouTube, X, Pinterest, Mastodon, Bluesky, and LinkedIn; null where the platform's listing API exposes no URL (e.g. Google Business) or one can't be built."},"text":{"type":"string","description":"Full, untruncated caption / post text (empty string if the post has none)."},"format":{"type":"string","description":"post, carousel, reel, video, image, text, or pin"},"media_count":{"type":"integer"},"timestamp":{"type":"string","format":"date-time","nullable":true},"image_url":{"type":"string","nullable":true,"description":"Display image for the post where the platform exposes one (currently Instagram: photo, video poster, or first carousel frame). Null otherwise."},"duration_seconds":{"type":"integer","nullable":true,"description":"Video length in whole seconds where the platform's listing API reports it (currently TikTok and YouTube). Null for images and for platforms that don't expose it — Instagram's media API has no duration field."},"metrics":{"type":"object","description":"Raw per-platform metrics as exact integers (never rounded/abbreviated); only keys the platform returned are present."},"engagement":{"type":"number"},"impressions":{"type":"number"},"engagement_rate":{"type":"number","description":"Percentage (0-100), suppressed below a minimum impression floor."}}}},"count":{"type":"integer"},"connected_platforms":{"type":"array","items":{"type":"string"}},"errors":{"type":"object","description":"Per-platform error messages for any platform that failed to fetch."},"note":{"type":"string"},"current_date":{"type":"string","format":"date"}}}}}},"400":{"description":"One or more requested platforms are not supported","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/posts/{id}":{"get":{"summary":"Get a post by ID","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"},"description":"Post ID"}],"responses":{"200":{"description":"Post details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Post"}}}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"summary":"Update a post","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"object"},"accounts":{"type":"array","items":{"type":"string"}},"type":{"type":"string","enum":["post","story","reel"],"description":"Change post type (draft only)"},"media":{"$ref":"#/components/schemas/CreatePostRequest/properties/media"},"media_ids":{"$ref":"#/components/schemas/CreatePostRequest/properties/media"},"media_urls":{"$ref":"#/components/schemas/CreatePostRequest/properties/media_urls"},"schedule_at":{"type":"string","format":"date-time","description":"New publish time (ISO 8601, must be in the future). A draft becomes `scheduled`. A post that is waiting for approval keeps its `in_approval` status and only its time moves. Send `null` to unschedule back to `draft`."},"location_id":{"$ref":"#/components/schemas/CreatePostRequest/properties/location_id"},"collaborators":{"$ref":"#/components/schemas/CreatePostRequest/properties/collaborators"},"user_tags":{"$ref":"#/components/schemas/CreatePostRequest/properties/user_tags"},"pinterest":{"$ref":"#/components/schemas/CreatePostRequest/properties/pinterest"},"youtube":{"$ref":"#/components/schemas/CreatePostRequest/properties/youtube"},"instagram":{"allOf":[{"$ref":"#/components/schemas/CreatePostRequest/properties/instagram"}],"description":"Instagram Reel options. MERGED into the stored options: keys you omit keep their current value (so updating share_to_feed cannot silently clear a Trial Reel flag or reel music set elsewhere); pass a key explicitly as null to clear it."},"tiktok":{"$ref":"#/components/schemas/CreatePostRequest/properties/tiktok"},"x":{"$ref":"#/components/schemas/CreatePostRequest/properties/x"},"bluesky":{"$ref":"#/components/schemas/CreatePostRequest/properties/bluesky"},"mastodon":{"$ref":"#/components/schemas/CreatePostRequest/properties/mastodon"},"threads":{"$ref":"#/components/schemas/CreatePostRequest/properties/threads"},"google_business":{"$ref":"#/components/schemas/CreatePostRequest/properties/google_business"},"linkedin":{"allOf":[{"$ref":"#/components/schemas/CreatePostRequest/properties/linkedin"}],"description":"LinkedIn Profile options. MERGED into the stored options: keys you omit keep their current value. `first_comment`: a string sets it, `null`/empty clears it. `carousel_as_images`: `true` publishes 2+ images as a plain image gallery, `false`/`null` reverts to the default PDF document carousel."},"linkedin_page":{"allOf":[{"$ref":"#/components/schemas/CreatePostRequest/properties/linkedin_page"}],"description":"LinkedIn Company Page options. Same merge semantics as `linkedin`."},"linkedin_poll":{"allOf":[{"$ref":"#/components/schemas/CreatePostRequest/properties/linkedin_poll"}],"description":"Replaced wholesale — send the full desired state for BOTH channels, not just the one you're changing. Pass `null` (or `{ \"linkedin\": null, \"linkedin_page\": null }`) to clear every poll and revert to normal posts; set just one channel's key to `null` to clear only that channel's poll while keeping the other."}}}}}},"responses":{"200":{"description":"Post updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Post"}}}}}},"400":{"description":"Common cases:\n- Cannot update posted/failed posts\n- Per-platform media cap exceeded after the update (Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35), or mixed image+video on a platform that doesn't support it (Facebook, LinkedIn, X, Bluesky, Mastodon, TikTok — Instagram/Threads mixed carousels are fine)\n- Pinterest carousel aspect ratios don't all match\n- Character-limit exceeded for one or more selected platforms\nSame `validation_error` code + message shape as `POST /v1/posts/create`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Delete a post","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Post deleted"},"404":{"description":"Post not found"}}}},"/posts/create":{"post":{"summary":"Create a new post","description":"Create a new post. You can attach media by uploading first (media/media_ids) or by passing external URLs (media_urls) which will be downloaded and processed automatically. Both can be combined.\n\n**Required media:** Instagram and TikTok posts need at least one image/video. Pinterest posts need an image and a board_id. Posts without required media will be rejected with a 400 error.\n\n**Stories (`type: \"story\"`):** 1 to 10 media items. Each item is one slide and publishes as its own story on Instagram and Facebook, in the order given. Per-platform objects can give each platform its own slide list. Videos max 60 s per slide. After publishing, `story_slides` lists every slide's native id and URL; `published_urls` keeps the first slide.\n\n**Per-platform media caps (enforced at submit, not at publish time):**\n- `bluesky` — max 4 images per post; cannot mix images and video\n- `mastodon` — max 4 media items per post; cannot mix images and video\n- `x` — max 4 images per post; cannot mix images and video\n- `instagram` — max 10 items in a carousel; images and videos **can** be mixed in one carousel\n- `threads` — max 10 items in a carousel; images and videos **can** be mixed in one carousel\n- `tiktok` — max 35 items per photo post; cannot mix photos and videos in one post\n- `pinterest` — max 5 **images** per carousel pin (videos are exempt — they publish as single video pins)\n- `facebook`, `linkedin`, `linkedin_page` — cannot mix images and video in one post (their APIs accept images only or a single video)\n\nExceeding any cap returns `400 { error: { code: \"validation_error\", message: \"<Platform> allows up to N media items per post. You attached M, please remove M-N.\" } }` (Pinterest wording: \"Pinterest carousel pins support a maximum of 5 images. Please remove some images and try again.\"). The Pinterest carousel aspect-ratio constraint (2–5 same-ratio images) is enforced the same way; see the Pinterest section for the `mismatched_slides` response shape.\n\n**Per-platform video file-size caps (validated via ffprobe; drafts exempt):**\nMastodon 99 MB · Bluesky 100 MB · Instagram 300 MB · X 512 MB (free tier) · Threads / Reddit 1 GB · Pinterest 2 GB · Facebook / TikTok 4 GB · LinkedIn 5 GB · YouTube 256 GB.\nUpload requests are capped at 100 MB on top of these — anything bigger is rejected before media processing with `code: file_too_large`.\n\n**Per-platform video duration caps (validated via ffprobe; drafts exempt):**\nFacebook Post 240 min / Reel **90 s** · Instagram 15 min (Post + Reel) · TikTok 10 min (Post + Reel) · YouTube Short **3 min** · X 140 s · Bluesky 180 s · Threads 5 min · LinkedIn 10 min · Pinterest 15 min · Reddit 15 min · Mastodon (instance-dependent).\n\nCap violations return `400 { error: { code: \"validation_error\", message: \"<Platform> only allows videos up to <cap>; yours is <duration>. Trim the video or deselect <Platform>.\" } }` (duration) or `\"This video (<size>) is too large for <Platform>. ...\"` (size). When multiple platforms reject the same upload, each contributes one sentence to the message. See `API_DEVELOPER_GUIDE.md` → \"Per-Platform Media Limits\" and \"Per-Platform Video Duration Caps\" for the full table with source citations.\n\n**X link-post credits:** X's API bills posts whose text contains a URL at a premium ($0.20 vs $0.015), and OmniSocials passes that fee through as prepaid credits at X's rate card (20 credits per URL-containing tweet; threads are billed per part that contains a link). Credits are purchased in euros: 1 credit = €0.01, top-ups of €5-500 in the dashboard. When a create/schedule targets X and the text contains a URL, the 201 response includes a `warnings` array entry (`code: \"x_url_post_credits\"`) with `credits_required`, the company's current `credits_balance`, and the enforcement state. At publish time the credits are debited; if the balance can't cover it, the **X target alone fails** with an `INSUFFICIENT_CREDITS` message in its `error_message` (other platforms in the post publish normally) and the post can be retried after topping up in Settings → Organisation → Billing → Credits. Posts without links, analytics, and everything else on X stay free.\n\n**Schedule-time gate:** every scheduled X link post reserves its cost until it publishes. Scheduling (or publishing) a new X link post that would push the total reserved past the balance is refused with **402 `x_credits_insufficient`** (`details` carries `credits_required`, `credits_balance`, `credits_reserved`). Drafts are never gated; posts publishing before the enforcement date are never gated.\n","tags":["Posts"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePostRequest"}}}},"responses":{"201":{"description":"Post created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Post"},"warnings":{"type":"array","description":"Non-blocking notices about this post. Currently only `x_url_post_credits` (X link-post fee pass-through).","items":{"type":"object","properties":{"code":{"type":"string","example":"x_url_post_credits"},"message":{"type":"string"},"credits_required":{"type":"integer","example":20},"credits_balance":{"type":"integer","nullable":true},"enforced":{"type":"boolean"},"enforce_from":{"type":"string","example":"2026-08-14"}}}}}}}}},"400":{"description":"Validation error. Common cases (the `error.code` field identifies which):\n- `validation_error` — missing required fields, unknown account/platform name (incl. composite ids with an unknown or mismatched workspace prefix — always source ids from `GET /v1/accounts`), character-limit exceeded, per-platform media cap exceeded, mixed image+video on a platform that doesn't support it (Facebook, LinkedIn, X, Bluesky, Mastodon, TikTok — Instagram and Threads mixed carousels are allowed), Pinterest carousel aspect-ratio mismatch (`mismatched_slides` listed)\n- `platform_not_connected` — a selected platform isn't connected to this workspace\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"`x_credits_insufficient` — this X link post plus the credits already reserved by the company's other scheduled X link posts exceed the balance. `error.details` carries `credits_required`, `credits_balance` and `credits_reserved`. Top up at https://app.omnisocials.com/credits (or remove the link from the X version) and retry the request. Only returned for non-draft creates targeting X with a URL while enforcement applies.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/posts/create-and-publish":{"post":{"summary":"Create and publish a post immediately","description":"Creates a new post and publishes it right away — no scheduling needed. Equivalent to calling POST /posts/create with publish_now: true. The post will be queued for immediate publishing to all selected platforms.","tags":["Posts"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePostRequest"}}}},"responses":{"201":{"description":"Post created and queued for immediate publishing","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Post"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/posts/{id}/publish":{"post":{"summary":"Publish a post immediately","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Post queued for publishing","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","example":"posting"},"message":{"type":"string"}}}}}}}},"400":{"description":"Post cannot be published. Only draft or scheduled posts are publishable: an already published post returns `already_published`, a failed post returns `post_failed`, and any other status (`warning`, `posting`) returns `invalid_status` — use POST /posts/{id}/retry for failed or partially failed posts. Also returned for validation problems (e.g., missing media for Instagram/TikTok, missing board_id for Pinterest, or Pinterest carousel slides at mismatched aspect ratios — see `mismatched_slides` on the error object)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/posts/{id}/retry":{"post":{"summary":"Retry the failed platforms of a failed or partially failed post","description":"Re-publishes ONLY the platforms that failed, on the same post — platforms that already succeeded are never re-published. Works on posts with status `failed` (every platform failed) or `warning` (some platforms failed). The retry is processed asynchronously by the publishing queue, usually within a minute; poll `GET /posts/{id}` to see the outcome (`status` becomes `published` and `published_urls` gains the platform URL on success, or the platform's entry reappears in `errors` on another failure). Each platform can be retried at most 3 times; after that, create a new post. Platforms dismissed with 'ignore' in the dashboard are not retried.","tags":["Posts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Retry queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","example":"posting"},"platforms":{"type":"array","items":{"type":"string"},"description":"The failed platforms being retried"},"message":{"type":"string"}}}}}}}},"400":{"description":"Post is not retryable: `invalid_status` (post is not failed/warning), `nothing_to_retry` (no failed platforms recorded — e.g. legacy posts without per-platform tracking), or `max_retries_reached` (a failed platform has already been retried 3 times; the response lists the exhausted platforms)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/media":{"get":{"summary":"List uploaded media","tags":["Media"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","default":20,"maximum":100}},{"in":"query","name":"offset","schema":{"type":"integer","default":0}},{"in":"query","name":"search","schema":{"type":"string"},"description":"Case-insensitive search across the media name and filename. Use this to find an existing asset by name instead of re-uploading it."},{"in":"query","name":"folder_id","schema":{"type":"string"},"description":"Return only media in this folder. Use \"root\" for unfiled items at the top level."}],"responses":{"200":{"description":"List of media files","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Media"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}}}}},"/media/{id}":{"get":{"summary":"Get a single media item","description":"Fetch one media item by id — use this to poll a large URL upload until its `status` becomes `ready` (or `failed`).\n","tags":["Media"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"},"description":"Media id returned by an upload endpoint."}],"responses":{"200":{"description":"The media item","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Media"}}}}}},"404":{"description":"Media item not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Delete a media file","tags":["Media"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Media deleted"},"404":{"description":"Media not found"},"409":{"description":"Media is attached to a scheduled or publishing post and cannot be deleted yet (error code `media_in_use`). Cancel or reschedule the post(s) first, otherwise deleting the file would make the post fail to publish."}}},"patch":{"summary":"Rename or move a media file","description":"Update the human-readable name and/or move a media file into a folder. Lets you label assets so they can be found later by name instead of re-uploading. Only the fields you send are changed.","tags":["Media"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Human-readable label (e.g. \"pp-play5get50\"). Send an empty string to clear it."},"folder_id":{"type":"string","nullable":true,"description":"Move the file into this folder. Send null to move it to the root (\"All media\")."}}}}}},"responses":{"200":{"description":"Media updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Media"}}}}}},"400":{"description":"No updatable fields provided, or folder not found"},"404":{"description":"Media not found"}}}},"/media/upload":{"post":{"summary":"Upload a media file (image, video, or PDF carousel)","description":"Upload an image or video, or a PDF. A PDF is rasterized into one image slide per page and returned as a carousel (see PdfUploadResult): pass all of `media_ids` to POST /posts. On LinkedIn the slides post as a native swipeable document; on Instagram, TikTok, Threads and Pinterest as an image carousel. PDFs are capped at 20 pages.\n","tags":["Media"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The file to upload — image, video, or PDF (max 100MB; per-platform caps apply at scheduling — Mastodon 99MB, Bluesky 100MB, X 512MB (free tier), Threads/Reddit 1GB, Pinterest 2GB, Facebook/Instagram/TikTok 4GB, LinkedIn 5GB, YouTube 256GB). A PDF is split into image slides (max 20 pages) and returned as a carousel."},"name":{"type":"string","description":"Optional human-readable label (e.g. \"pp-play5get50\") so the asset is findable by name later. If the same bytes were already uploaded, the existing file is returned and labelled with this name."},"folder":{"type":"string","description":"Optional folder name to file the asset under (created at the top level if it does not exist). Use `folder_id` instead to target an existing folder."},"folder_id":{"type":"string","description":"Optional id of an existing folder to file the asset under."}}}}}},"responses":{"201":{"description":"File uploaded successfully. For an image or video the body has a single `data` (Media). For a PDF the body is a PdfUploadResult with `slides` + `media_ids` for the whole carousel.\n","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Media"},"compatibility":{"$ref":"#/components/schemas/MediaCompatibility"},"message":{"type":"string"}}},{"$ref":"#/components/schemas/PdfUploadResult"}]}}}},"400":{"description":"Invalid file or missing file","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Payload too large. This endpoint is hard-capped at 100MB and bodies above that are rejected by our CDN (Cloudflare) BEFORE reaching the API, so the response is a NON-JSON HTML page, not the usual JSON error envelope. To upload files up to 1GB, use POST /media/upload-from-url instead (the server fetches the URL and bypasses the body cap)."}}}},"/media/upload-from-base64":{"post":{"summary":"Upload media from base64 data","description":"Upload a file from base64-encoded data. Useful for MCP integrations where the AI has image data but no URL. Accepts images, video, and PDF (a PDF is split into image slides and returned as a carousel — see PdfUploadResult; pass all of media_ids to POST /posts). Max 100MB after decoding; per-platform caps apply at scheduling (Mastodon 99MB, Bluesky 100MB, X 512MB free tier, Threads/Reddit 1GB, Pinterest 2GB, Facebook/Instagram/TikTok 4GB, LinkedIn 5GB, YouTube 256GB).","tags":["Media"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["data","mime_type"],"properties":{"data":{"type":"string","description":"Base64-encoded file data (without data URI prefix)"},"mime_type":{"type":"string","description":"MIME type of the file (e.g. image/jpeg, image/png, application/pdf)"},"filename":{"type":"string","description":"Optional filename"},"name":{"type":"string","description":"Optional human-readable label so the asset is findable by name later (e.g. \"pp-play5get50\")."},"folder":{"type":"string","description":"Optional folder name to file the asset under (created at the top level if missing)."},"folder_id":{"type":"string","description":"Optional id of an existing folder to file the asset under."}}}}}},"responses":{"201":{"description":"File uploaded successfully. A single `data` (Media) for image/video; a PdfUploadResult (`slides` + `media_ids`) for a PDF.\n","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Media"}}},{"$ref":"#/components/schemas/PdfUploadResult"}]}}}},"400":{"description":"Invalid data or unsupported file type"},"413":{"description":"Payload too large. Decoded files are capped at 100MB, but because base64 inflates the request body ~33%, anything above ~75MB is rejected by our CDN (Cloudflare) BEFORE reaching the API and returns a NON-JSON HTML page. For large videos use POST /media/upload-from-url (up to 1GB) instead of base64."}}}},"/media/upload-from-url":{"post":{"summary":"Upload media from a URL","description":"Download a file from a URL and upload it to your media library — the recommended path for MCP/programmatic uploads. Supports videos up to **1 GB**: files over 100 MB are streamed to storage by a background worker and the response returns `202` with the item in `processing` state (poll `GET /media/{id}` until `status` is `ready`). Files at or under 100 MB upload inline and return `201` ready. A PDF URL is split into image slides and returned as a carousel (see PdfUploadResult; pass all of `media_ids` to POST /posts). Every response also includes a `compatibility` block listing any connected platforms that would reject the file by size or format.\n","tags":["Media"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"HTTP or HTTPS URL of the file to download (max 1 GB for video; per-platform caps apply at scheduling)."},"filename":{"type":"string","description":"Optional filename override"},"name":{"type":"string","description":"Optional human-readable label so the asset is findable by name later (e.g. \"pp-play5get50\")."},"folder":{"type":"string","description":"Optional folder name to file the asset under (created at the top level if missing)."},"folder_id":{"type":"string","description":"Optional id of an existing folder to file the asset under."}}}}}},"responses":{"201":{"description":"File uploaded inline (<= 100 MB) and ready to use. A single `data` (Media) for image/video; a PdfUploadResult (`slides` + `media_ids`) for a PDF.\n","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Media"},"compatibility":{"$ref":"#/components/schemas/MediaCompatibility"},"message":{"type":"string"}}},{"$ref":"#/components/schemas/PdfUploadResult"}]}}}},"202":{"description":"Large file (over 100 MB) accepted and processing. `data.status` is 'processing' — poll GET /media/{id} until 'ready' before using it.\n","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Media"},"compatibility":{"$ref":"#/components/schemas/MediaCompatibility"},"message":{"type":"string"}}}}}},"400":{"description":"Invalid URL or unsupported file type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"File exceeds the 1 GB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/media/check":{"post":{"summary":"Check media compatibility before uploading","description":"Preflight a file against the platforms connected to your workspace and find out which (if any) would reject it by size or format — before you upload or post. Provide ONE of: a public `url` (we read its size/type via a HEAD request), an existing `media_id`, or an explicit `size_bytes` + `mime`. Use this to warn the user (\"this 995 MB video won't post to Instagram\") and confirm before uploading.\n","tags":["Media"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"Public URL of the file to check (HEAD-probed for size + type)."},"media_id":{"type":"string","description":"Id of an already-uploaded library item to check."},"size_bytes":{"type":"integer","description":"File size in bytes (use with `mime` when you already know them)."},"mime":{"type":"string","description":"MIME type, e.g. \"video/mp4\" (use with `size_bytes`)."}}}}}},"responses":{"200":{"description":"Compatibility result","content":{"application/json":{"schema":{"type":"object","properties":{"file":{"type":"object","properties":{"size_bytes":{"type":"integer","nullable":true},"mime":{"type":"string","nullable":true},"size_known":{"type":"boolean"}}},"connected_platforms":{"type":"array","items":{"type":"string"}},"compatible":{"type":"boolean","description":"true when no connected platform would reject the file."},"warnings":{"type":"array","items":{"type":"object","properties":{"platform":{"type":"string"},"display_name":{"type":"string"},"reasons":{"type":"array","items":{"type":"string"}}}}},"summary":{"type":"string","nullable":true,"description":"One-line human summary, or null when fully compatible."}}}}}},"400":{"description":"Missing/invalid parameters"}}}},"/folders":{"get":{"summary":"List folders","description":"Returns all folders in the workspace as a flat list (build the tree client-side via parent_id). Use folder ids with the media endpoints to organize and find assets.","tags":["Folders"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"List of folders","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Folder"}}}}}}}}},"post":{"summary":"Create a folder","tags":["Folders"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"parent_id":{"type":"string","nullable":true,"description":"Parent folder id to nest under, or omit for a top-level folder"}}}}}},"responses":{"201":{"description":"Folder created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Folder"}}}}}},"400":{"description":"Invalid name or parent"}}}},"/folders/{id}":{"patch":{"summary":"Rename or move a folder","tags":["Folders"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"parent_id":{"type":"string","nullable":true,"description":"New parent folder id, or null to move to the top level"}}}}}},"responses":{"200":{"description":"Folder updated"},"400":{"description":"Invalid update (e.g. would create a cycle)"},"404":{"description":"Folder not found"}}},"delete":{"summary":"Delete a folder","description":"Deletes the folder. Its media is NOT deleted - files move to the root (\"All media\"), and any subfolders move up to this folder's parent.","tags":["Folders"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Folder deleted"},"404":{"description":"Folder not found"}}}},"/hashtag-sets":{"get":{"summary":"List hashtag sets","description":"Returns all saved hashtag sets in the workspace. Apply one to a new post by passing its name (`hashtag_set`) or id (`hashtag_set_id`) to POST /posts/create.","tags":["Hashtag Sets"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"List of hashtag sets","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/HashtagSet"}}}}}}}}},"post":{"summary":"Create a hashtag set","description":"Saves a reusable, named group of hashtags. Tags may be passed with or without the leading '#'; they are deduped case-insensitively and stored in order. Max 100 tags per set.","tags":["Hashtag Sets"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","hashtags"],"properties":{"name":{"type":"string","description":"Set name, e.g. \"Fitness Brand\". Unique per workspace."},"hashtags":{"oneOf":[{"type":"array","items":{"type":"string"}},{"type":"string"}],"description":"Array of tags ([\"fitness\", \"#gym\"]) or a single string (\"#fitness"}}}}}},"responses":{"201":{"description":"Hashtag set created","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/HashtagSet"}}}}}},"400":{"description":"Invalid name/hashtags or duplicate name"}}}},"/hashtag-sets/{id}":{"get":{"summary":"Get a hashtag set","tags":["Hashtag Sets"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Hashtag set","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/HashtagSet"}}}}}},"404":{"description":"Hashtag set not found"}}},"patch":{"summary":"Update a hashtag set","description":"Rename the set and/or replace its hashtags. `hashtags` replaces the full list (fetch, modify, resend to add/remove tags).","tags":["Hashtag Sets"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"hashtags":{"oneOf":[{"type":"array","items":{"type":"string"}},{"type":"string"}]}}}}}},"responses":{"200":{"description":"Hashtag set updated"},"400":{"description":"Invalid update"},"404":{"description":"Hashtag set not found"}}},"delete":{"summary":"Delete a hashtag set","description":"Deletes the set. Posts that already used it are unaffected — the tags were merged into their captions at create time.","tags":["Hashtag Sets"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Hashtag set deleted"},"404":{"description":"Hashtag set not found"}}}},"/accounts":{"get":{"summary":"List connected social accounts","tags":["Accounts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"platform","schema":{"type":"string","enum":["instagram","facebook","linkedin","linkedin_page","youtube","tiktok","pinterest","bluesky","threads","mastodon","x","google_business","reddit","snapchat"]},"description":"Filter by platform"}],"responses":{"200":{"description":"List of connected accounts","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Account"}},"workspace_id":{"type":"integer","description":"Stable id of the active workspace this key belongs to.","example":1234},"workspace_name":{"type":"string","nullable":true,"description":"Human-readable name of the active workspace.","example":"Daily Edge Sports"},"workspace_icon":{"type":"string","nullable":true}}}}}}}}},"/accounts/{id}":{"get":{"summary":"Get account details","tags":["Accounts"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"},"description":"Account ID (format workspaceId_platform)"}],"responses":{"200":{"description":"Account details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Account"}}}}}},"404":{"description":"Account not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/analytics/posts":{"get":{"summary":"Get analytics for multiple posts in one call","description":"Batch version of GET /analytics/posts/{id}. Pass a comma-separated list of numeric OmniSocials post ids in `ids` and get each post's latest per-platform metrics back in a single request (thread posts are summed across their parts, same as the single-post endpoint). Built for sync clients that would otherwise call the single-post endpoint once per post and hit the 100 requests/minute rate limit. Up to 100 ids per call; page through larger sets in batches. Every requested id is returned (in request order); ids with no collected analytics come back with an empty `platforms` object rather than being omitted, so clients can map results 1:1 to the ids they sent. Reads stored snapshots from our database, the same data as the single-post endpoint, so it never calls the social platforms live.\n","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"ids","required":true,"schema":{"type":"string"},"description":"Comma-separated numeric OmniSocials post ids (the `id` field from GET /posts), e.g. \"1024,1025,1026\". Max 100 per request.\n"}],"responses":{"200":{"description":"Per-post analytics, one entry per requested id","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PostAnalytics"}},"count":{"type":"integer","description":"Number of entries returned (equals the number of valid ids requested)"}}}}}},"400":{"description":"Missing `ids`, more than 100 ids, or one or more ids that are not numeric OmniSocials post ids (e.g. a platform post id was supplied).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/analytics/posts/{id}":{"get":{"summary":"Get analytics for a specific post","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"},"description":"The numeric OmniSocials post id (the `id` field from GET /posts). Not a platform post id such as a YouTube video id or tweet id."}],"responses":{"200":{"description":"Post analytics data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PostAnalytics"}}}}}},"400":{"description":"Invalid post id (non-numeric, e.g. a platform post id was supplied instead of the OmniSocials post id)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/analytics/overview":{"get":{"summary":"Get workspace analytics overview","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"period","schema":{"type":"string","enum":["7d","30d","90d"],"default":"30d"},"description":"Time period for analytics"},{"in":"query","name":"start_date","schema":{"type":"string"},"description":"Custom start date. Accepts YYYY-MM-DD (e.g. \"2026-04-01\"), YYYY-MM month-shorthand (e.g. \"2026-04\" expands to the first of the month), or a full ISO 8601 datetime."},{"in":"query","name":"end_date","schema":{"type":"string"},"description":"Custom end date. Same formats as start_date; \"YYYY-MM\" expands to the last day of the month."}],"responses":{"200":{"description":"Analytics overview","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AnalyticsOverview"},"period":{"type":"string","description":"Human-readable range label (e.g. \"30d\" or \"2026-04-01 to 2026-04-30\")"},"start_date":{"type":"string","format":"date","description":"Resolved range start (YYYY-MM-DD), useful for clients that want to confirm what was queried"},"end_date":{"type":"string","format":"date","description":"Resolved range end (YYYY-MM-DD)"},"current_date":{"type":"string","format":"date","description":"Server's current date (UTC), included so clients can self-correct relative phrases like \"last month\""}}}}}},"400":{"description":"Invalid date input (e.g. unparseable, or start_date after end_date)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/analytics/accounts":{"get":{"summary":"Get analytics for connected accounts","description":"Latest stored account-level snapshot per connected platform, on or before the requested date. Snapshots are collected daily. Metric semantics vary by platform (see the AccountAnalytics schema) — most notably, LinkedIn impressions are lifetime cumulative totals across all of the account's content, not a windowed count. Each metrics object carries a `note` explaining its scope where semantics are non-obvious.\n","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"platform","schema":{"type":"string"},"description":"Filter by platform"},{"in":"query","name":"date","schema":{"type":"string","format":"date"},"description":"Date to get analytics for (YYYY-MM-DD, defaults to today)"}],"responses":{"200":{"description":"Account analytics data","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AccountAnalytics"}},"date":{"type":"string","format":"date"}}}}}}}}},"/analytics/best-times":{"get":{"summary":"Get recommended posting times for a platform","description":"Recommended day/hour posting slots for one platform, computed from the workspace's own posting history (publish time × engagement of every post published on that platform, recency-weighted, outlier-damped). Times are bucketed in the requesting user's timezone (overridable via `timezone`). When the workspace has fewer than 15 analyzed posts on the platform, a static industry-average table is returned instead — `basis` flips from `own_data` to `defaults` and `posts_needed` says how many more published posts unlock personalized recommendations. Grid scores are relative within the platform (100 = the best slot). Requires the `analytics:read` scope.\n","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"platform","required":true,"schema":{"type":"string"},"description":"Platform identifier (e.g. instagram, tiktok, linkedin, linkedin_page, x)."},{"in":"query","name":"timezone","schema":{"type":"string"},"description":"IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone, then UTC."}],"responses":{"200":{"description":"Best-time grid and top recommendations","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string"},"timezone":{"type":"string","description":"IANA timezone the day/hour buckets are expressed in"},"basis":{"type":"string","enum":["own_data","own_data_and_audience","audience","defaults"],"description":"`own_data` = computed from this workspace's posts; `own_data_and_audience` = own posts blended with when the audience is online (Instagram, TikTok Business); `audience` = not enough posts yet, based on the audience-online profile only; `defaults` = industry averages (not enough history yet)"},"sample_size":{"type":"integer","description":"Number of analyzed posts (published >72h ago, last 365 days)"},"posts_needed":{"type":"integer","description":"How many more published posts unlock personalized recommendations (0 when basis is own_data)"},"metric":{"type":"string","description":"Always `engagement` in v1 (shared cross-platform engagement formula)"},"window_days":{"type":"integer"},"audience_online":{"type":"object","additionalProperties":{"type":"number"},"description":"Hour (0-23, in the response timezone) -> number of followers online, from the platform's audience-activity data. Present for Instagram and TikTok Business accounts with 100+ followers once the daily account snapshot has collected it."},"audience_online_date":{"type":"string","format":"date","description":"Snapshot date of `audience_online`."},"grid":{"type":"array","description":"One entry per day/hour cell that has data (missing cells = no posts there). Scores are relative 0-100.","items":{"type":"object","properties":{"day":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]},"hour":{"type":"integer","minimum":0,"maximum":23},"score":{"type":"integer","minimum":0,"maximum":100},"n":{"type":"integer","description":"Sample count behind the cell (±1h smoothed). Absent on defaults."},"typical_engagement":{"type":"integer","description":"Back-transformed typical engagement for the slot. Absent on defaults."}}}},"recommendations":{"type":"array","description":"Top 3 slots, diversity-enforced (never two on the same day within 3 hours)","items":{"type":"object","properties":{"day":{"type":"string"},"hour":{"type":"integer"},"time":{"type":"string","example":"15:00"},"score":{"type":"integer"}}}}}}}}},"400":{"description":"Missing/unknown platform or invalid timezone"}}}},"/webhooks":{"get":{"summary":"List webhooks","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"List of webhooks","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}}}},"post":{"summary":"Create a webhook","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri","description":"HTTPS URL to receive events"},"events":{"type":"array","items":{"type":"string","enum":["post.scheduled","post.published","post.failed"]}}}}}}},"responses":{"201":{"description":"Webhook created. The secret is only returned once at creation.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/Webhook"},{"type":"object","properties":{"secret":{"type":"string","description":"Webhook signing secret (only shown once)"}}}]},"message":{"type":"string"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks/{id}":{"get":{"summary":"Get webhook details","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Webhook details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Webhook"}}}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"summary":"Update a webhook","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"is_active":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Webhook updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Webhook"}}}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Delete a webhook","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Webhook deleted"},"404":{"description":"Webhook not found"}}}},"/webhooks/{id}/rotate-secret":{"post":{"summary":"Rotate webhook secret","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"New secret generated. Save it — it will only be shown once.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"secret":{"type":"string","description":"New webhook signing secret"}}},"message":{"type":"string"}}}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/locations/search":{"get":{"summary":"Search Instagram-taggable locations","description":"Search physical places by name to get a valid `location_id` for tagging an Instagram post. Returns only real venues with a precise address (the kind Instagram accepts); national brand pages, personal profiles, and Instagram location IDs are not returned because Instagram rejects them. Pass a returned `id` as `location_id` on `POST /posts/create`. Requires a Facebook account connected to the workspace (the lookup runs through Facebook's place data).","tags":["Locations"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"q","required":true,"schema":{"type":"string"},"description":"Place name to search (min 2 chars), e.g. a dealership, café, or venue name. Use a specific name — generic brands return their individual store locations, not the brand.","example":"Griffith Observatory"}],"responses":{"200":{"description":"Matching places","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Use this as `location_id`","example":"313813441"},"name":{"type":"string","example":"Griffith Observatory"},"address":{"type":"string","nullable":true,"example":"2800 E Observatory Rd, Los Angeles, CA, 90027, United States"},"city":{"type":"string","nullable":true},"country":{"type":"string","nullable":true}}}},"error":{"type":"string","nullable":true,"description":"Set when search is unavailable (e.g. no Facebook account connected, or the app lacks place-search permission). data is then empty."},"needsPermission":{"type":"boolean","description":"true when the Facebook app lacks 'Page Public Content Access'."}}}}}}}}},"/locations/validate":{"get":{"summary":"Validate an Instagram location_id","description":"Check whether a Facebook Place ID is a valid Instagram location (a single venue with a precise address) before using it as `location_id`. Returns `{ valid: true, name, address }` for taggable places; `{ valid: false, reason }` for personal profiles, brand pages, or non-places. If the workspace has no Facebook account (or the app can't read the page), returns `{ valid: false, unverified: true }` — the post's publish step is then the final check.","tags":["Locations"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"id","required":true,"schema":{"type":"string"},"description":"Numeric Facebook Place ID to validate.","example":"313813441"}],"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"valid":{"type":"boolean"},"id":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"address":{"type":"string","nullable":true},"unverified":{"type":"boolean","description":"Couldn't be checked now; the publish step will validate."},"reason":{"type":"string","nullable":true,"description":"Why it isn't valid / couldn't be verified."}}}}}}}}},"/audio/search":{"get":{"summary":"Search Instagram's music catalog","description":"Search Meta's licensed audio catalog for tracks that can be attached to an Instagram Reel. Pass a returned `audio_id` as `instagram.audio_id` on `POST /posts/create` (type `reel`). Omit `q` to get currently trending audio. Only tracks Meta has authorized for third-party publishing are returned, so the selection can differ from the native app. Requires a Facebook account connected to the workspace whose Facebook Page is linked to the workspace's Instagram account (music publishing runs through Facebook's side of the Instagram API).","tags":["Audio"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"q","required":false,"schema":{"type":"string"},"description":"Song title, artist, or keyword. Omit for trending audio.","example":"birthday"},{"in":"query","name":"type","required":false,"schema":{"type":"string","enum":["music","original_sound"],"default":"music"},"description":"Catalog to search: licensed music (default) or original sounds created on Instagram."}],"responses":{"200":{"description":"Matching audio tracks","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"audio_id":{"type":"string","description":"Use this as `instagram.audio_id`","example":"587784541076604"},"title":{"type":"string","nullable":true,"example":"Birthday Wish"},"artist":{"type":"string","nullable":true,"example":"Shuba"},"duration_ms":{"type":"integer","nullable":true,"example":153760},"audio_type":{"type":"string","enum":["music","original_sound"]},"cover_url":{"type":"string","nullable":true,"description":"Cover artwork (music) or creator avatar (original sound)."},"preview_url":{"type":"string","nullable":true,"description":"Temporary audio preview URL (expires after ~1.5 days — do not store)."},"ig_username":{"type":"string","nullable":true,"description":"Creator handle (original sounds only)."}}}},"error":{"type":"string","nullable":true,"description":"Set when search is unavailable (e.g. no linked Facebook account). data is then empty."},"needsFacebook":{"type":"boolean","description":"true when the workspace needs a Facebook connection (with a Page linked to this Instagram account) before music is available."}}}}}}}}},"/inbox/conversations":{"get":{"summary":"List social inbox conversations (newest activity first)","description":"Returns one entry per conversation (the latest message in each), across Instagram and Facebook DMs, comments, and mentions, LinkedIn company-page comments and mentions (LinkedIn has no DMs via API), TikTok video comments (comments only; requires the TikTok comments authorization on the channel), and X DMs for workspaces that opted in to X DMs in the dashboard (X is DM-only; no history from before opt-in). Cursor-paginated: follow `pagination.next_cursor` until it is null. Requires the `inbox:read` scope (API keys) or the `access_social_inbox` permission (OAuth/MCP).\n","tags":["Inbox"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","default":25,"maximum":100},"description":"Max conversations per page (1-100, default 25)."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Opaque pagination cursor from a previous response."},{"in":"query","name":"platform","schema":{"type":"string","enum":["instagram","facebook","linkedin","tiktok","x"]},"description":"Filter to a single platform."},{"in":"query","name":"type","schema":{"type":"string","enum":["dm","comment","mention"]},"description":"Filter to a single conversation type."},{"in":"query","name":"unread","schema":{"type":"boolean"},"description":"When true, only conversations with unread incoming messages."}],"responses":{"200":{"description":"A page of conversations","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InboxConversation"}},"pagination":{"$ref":"#/components/schemas/InboxCursorPagination"}}}}}}}}},"/inbox/conversations/{conversationId}/messages":{"get":{"summary":"Get the full message history of a conversation","description":"Returns messages in a conversation, oldest first, cursor-paginated. Requires the `inbox:read` scope (API keys) or the `access_social_inbox` permission (OAuth/MCP).\n","tags":["Inbox"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"conversationId","required":true,"schema":{"type":"string"}},{"in":"query","name":"limit","schema":{"type":"integer","default":50,"maximum":100}},{"in":"query","name":"cursor","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of messages","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InboxMessage"}},"pagination":{"$ref":"#/components/schemas/InboxCursorPagination"}}}}}},"404":{"description":"Conversation not found in this workspace"}}}},"/inbox/conversations/{conversationId}/read":{"post":{"summary":"Mark all incoming messages in a conversation as read","description":"Marks every unread incoming message in the conversation as read and returns how many were updated. Requires the `inbox:write` scope (API keys) or the `access_social_inbox` permission (OAuth/MCP).\n","tags":["Inbox"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"conversationId","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Read state updated","content":{"application/json":{"schema":{"type":"object","properties":{"conversation_id":{"type":"string"},"marked_read":{"type":"integer"}}}}}},"404":{"description":"Conversation not found in this workspace"}}}},"/inbox/conversations/{conversationId}/reply":{"post":{"summary":"Reply to a conversation","description":"Sends a reply in an existing conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok (comments only), or X (DM-only). You can only reply to conversations that already exist in your inbox — the API cannot start new conversations (Instagram requires the user to message first, and cold outreach violates Meta policy). Direct-message replies must be within the platform's messaging window (24 hours since the user's last message); replies outside the window are rejected. TikTok replies are text-only, capped at 150 characters (400 `validation_error` beyond that), and can take a few minutes to appear on TikTok while they pass spam review. Each workspace can send up to 1,000 replies per rolling 24 hours (429 `reply_limit_reached` beyond that) — contact robert@omnisocials.com if you need a higher limit. X DM replies debit 2 credits from the company balance before the send (auto-refunded if the send fails) and can return 402 `insufficient_credits` or 402 `x_inbox_suspended`. Requires the `inbox:write` scope (API keys) or the `access_social_inbox` permission (OAuth/MCP).\n","tags":["Inbox"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"conversationId","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","description":"Reply body (max 2000 characters; TikTok comments max 150)","maxLength":2000},"attachment_url":{"type":"string","description":"Optional media URL (Facebook DMs only)"},"attachment_type":{"type":"string","enum":["image","video","audio","file"],"description":"Required if attachment_url is set"}}}}}},"responses":{"200":{"description":"Reply sent","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/InboxMessage"}}}}}},"400":{"description":"Validation error, `unsupported_platform`, or `unsupported_reply_type` (the conversation's platform/type cannot be replied to, e.g. an Instagram story mention)"},"402":{"description":"X DMs only — `insufficient_credits` (balance cannot cover the 2-credit send) or `x_inbox_suspended` (X inbox paused at zero balance; top up and re-enable)"},"403":{"description":"Platform limitation (e.g. cannot comment on a personal-profile post)"},"404":{"description":"Conversation not found in this workspace"},"422":{"description":"Outside the messaging window"},"429":{"description":"Daily reply limit reached for this workspace"}}}}}}