{
  "openapi": "3.1.0",
  "info": {
    "title": "BulkPublish API",
    "version": "1.0.0",
    "description": "Publish to 15 social media platforms from a single API.\n\n## Using this API from an AI agent\n\nBulkPublish is also an [MCP](https://modelcontextprotocol.io) server, so Claude, ChatGPT, Cursor and other agents can drive it directly. The two servers below expose different amounts of what is documented here.\n\n**Hosted (web-based hosts):** add `https://mcp.bulkpublish.com/mcp` as a custom connector. It supports OAuth 2.1 \u2014 you paste your API key once on the consent screen \u2014 or a key in the URL (`?key=bp_...`) for hosts without OAuth. It exposes a focused set of tools \u2014 channels, posts, media, analytics, and the interactive panels \u2014 not one per endpoint below.\n\n**Local (Claude Code, Cursor, Codex, Windsurf):** `npx -y @bulkpublish/mcp-server`, with `BULKPUBLISH_API_KEY` in the environment. This one exposes the full tool set, including schedules, RSS feeds, labels and quotas.\n\nPer-client config and the full tool list: [BulkPublish AI Toolkit](https://github.com/azeemkafridi/bulkpublish-ai-toolkit). Get an API key at [/developer](https://app.bulkpublish.com/developer)."
  },
  "servers": [
    {
      "url": "https://app.bulkpublish.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "API key from Settings > API Keys. Pass as `Bearer bp_your_key_here`"
      },
      "oauth2": {
        "type": "oauth2",
        "description": "**Beta.** For products that embed BulkPublish and do not want their users pasting an API key. The user approves your app on BulkPublish and you receive a scoped token bound to the workspace they chose. PKCE (S256) is required for every client, `scope` is required (there is no implicit default), authorization codes are single-use, and refresh tokens rotate. Pass the token as `Bearer bpat_...`.\n\nOAuth tokens reach posts, schedules, labels, media, analytics, quota usage and read-only channel data \u2014 and nothing else. Account administration (team, organizations, billing and credit purchases, API keys, OAuth app management) returns 403 for **any** OAuth token, including `full`, because those actions would outlive the user disconnecting the app. Use an API key (`Bearer bp_...`) for those; keys are unaffected by this and remain the default for server-to-server use.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://app.bulkpublish.com/oauth/authorize",
            "tokenUrl": "https://app.bulkpublish.com/api/oauth/token",
            "refreshUrl": "https://app.bulkpublish.com/api/oauth/token",
            "scopes": {
              "posts:read": "View posts and their status",
              "posts:write": "Create, update, schedule and publish posts",
              "media:read": "View uploaded media",
              "media:write": "Upload and delete media",
              "analytics:read": "View analytics and post metrics",
              "channels:read": "See which social accounts are connected",
              "full": "Everything an app may do: read and write posts, schedules, labels and media, and read analytics and channels"
            }
          }
        }
      }
    },
    "schemas": {
      "Post": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "content": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "scheduled",
              "publishing",
              "published",
              "processing",
              "failed",
              "partial"
            ]
          },
          "scheduledAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "timezone": {
            "type": "string"
          },
          "postFormat": {
            "type": "string",
            "enum": [
              "post",
              "video",
              "reel",
              "story",
              "carousel",
              "thread"
            ]
          },
          "postTypeOverrides": {
            "type": "object",
            "description": "Per-platform post type override. E.g. { \"instagram\": \"reel\", \"facebook\": \"story\" }. A type the platform does not offer is rejected with 400 VALIDATION_ERROR (previously it silently fell back to the platform default). Instagram: feed_photo, feed_video, reel, story, carousel. Facebook: post, reel, story. TikTok: video, photo_slideshow. YouTube: video, short. LinkedIn: post, multi_image. Pinterest: pin, video_pin, carousel. GMB: standard, event, offer. Snapchat: story, saved_story, spotlight. `repost` (X, Threads, Bluesky, Mastodon) reposts an existing post at the scheduled time: the post's own text and media are ignored and `platformSpecific.<platform>.repostId` must hold the target (X: status URL or ID; Mastodon: status URL or ID; Bluesky: bsky.app post URL or at:// URI; Threads: the numeric post ID; Threads links cannot be used, and for posts published through BulkPublish the ID is in the post details). A missing or unparsable target is rejected with 400 VALIDATION_ERROR. Rolling out: until it is switched on for your account the composer does not offer it."
          },
          "platformSpecific": {
            "type": "object",
            "description": "Per-platform options. Keys are platform names. Facebook: { shareToStory }. Instagram: { collaborators, shareToStory, trialReel, graduationStrategy ('manual'|'auto' \u2014 any other value is rejected with 400 VALIDATION_ERROR), thumbnailTimestamp (seconds) }. TikTok: { privacyLevel ('PUBLIC_TO_EVERYONE'|'MUTUAL_FOLLOW_FRIENDS'|'FOLLOWER_OF_CREATOR'|'SELF_ONLY' \u2014 any other value is rejected with 400 VALIDATION_ERROR), disableDuet, disableStitch, disableComment, isAigc, brandContentToggle, brandOrganicToggle, thumbnailTimestamp }. YouTube: { title, privacyStatus ('public'|'unlisted'|'private' \u2014 any other value is rejected with 400 VALIDATION_ERROR), categoryId, madeForKids, playlistId, thumbnailUrl }. Pinterest: { title, description, link, [channelId]: { boardId } }. GMB: { ctaType ('BOOK'|'ORDER'|'SHOP'|'LEARN_MORE'|'SIGN_UP'|'CALL' \u2014 any other value is rejected with 400 VALIDATION_ERROR), ctaUrl, eventTitle, startDate, startTime, endDate, endTime, couponCode, redeemOnlineUrl, termsConditions }. X: { replySettings ('everyone'|'following'|'verified'|'subscribers'|'mentionedUsers' \u2014 any other value is rejected with 400 VALIDATION_ERROR) }. Threads: { quotePostId, topicTag, locationId }. topicTag is one topic per post, sent without a leading '#'; a value containing a period or an ampersand is rejected with 400 VALIDATION_ERROR. locationId is a numeric place ID from GET /api/channels/{id}/options?q=<place> on a Threads channel (any other value is rejected with 400 VALIDATION_ERROR); location search is available only once the connected Threads account has granted location tagging, and the options endpoint answers { unavailable: true } until then. Both apply to the root post of a thread, never to its replies. Snapchat: { [channelId]: { title (Saved Story title, max 45 chars \u2014 defaults to the caption's first line), locale (Spotlight locale, default 'en_US'), saveToProfile (Spotlight only, default true) } }. Use _firstComment (string) for auto-posted reply after publish."
          },
          "platformContent": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Per-platform content overrides mapping platform name to a plain string, e.g. { \"x\": \"Short version\" }. Values must be strings \u2014 nested objects are rejected with 400 VALIDATION_ERROR."
          },
          "deleteMediaAfterPublish": {
            "type": "boolean",
            "default": false,
            "description": "Whether uploaded media is deleted right after publishing. Defaults to false \u2014 media is kept and reclaimed by the 3-month retention sweep. Always false for recurring-schedule posts and bulk-created posts."
          },
          "autoPlugEnabled": {
            "type": "boolean"
          },
          "autoPlugText": {
            "type": [
              "string",
              "null"
            ]
          },
          "autoPlugThreshold": {
            "type": "integer"
          },
          "autoPlugFired": {
            "type": "boolean"
          },
          "autoRepostEnabled": {
            "type": "boolean"
          },
          "autoRepostThreshold": {
            "type": "integer"
          },
          "autoRepostFired": {
            "type": "boolean"
          },
          "recurringScheduleId": {
            "type": [
              "integer",
              "null"
            ]
          },
          "recurringSchedule": {
            "type": [
              "object",
              "null"
            ],
            "description": "Full recurring schedule details when linked"
          },
          "mediaFiles": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaFile"
            }
          },
          "postPlatforms": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PostPlatform"
            }
          },
          "labels": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Label"
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "publishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "approvalStatus": {
            "type": "string",
            "enum": [
              "none",
              "pending",
              "approved",
              "rejected"
            ],
            "default": "none",
            "description": "Team approval state, orthogonal to status. 'pending' and 'rejected' posts are skipped by the scheduler even when scheduled and overdue; approving via POST /api/posts/{id}/approve releases them (an overdue post publishes immediately on approval). Members whose role lacks post:publish (contributors) always get 'pending' when scheduling; others can opt in with requestApproval."
          },
          "approvedBy": {
            "type": [
              "string",
              "null"
            ],
            "description": "User ID of the approver (set when approvalStatus is 'approved')."
          },
          "approvedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "rejectionReason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Reviewer's reason when approvalStatus is 'rejected'."
          },
          "threadParts": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "object",
              "properties": {
                "content": {
                  "type": "string"
                },
                "mediaFileIds": {
                  "type": "array",
                  "items": {
                    "type": "integer"
                  }
                }
              }
            },
            "description": "The ordered parts of a thread post. Null unless postFormat is 'thread'. Accepted on create and update; returned here since 2026-09."
          },
          "threadMediaFiles": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaFile"
            },
            "description": "Media referenced by threadParts[].mediaFileIds, resolved to full objects and de-duplicated across parts. Distinct from mediaFiles, which is the media on the post itself: a thread post can carry both, and a client that reads only mediaFiles will render a thread without its images."
          },
          "hasShareLink": {
            "type": "boolean",
            "description": "Whether a read-only review link exists for the post. The token itself is never returned on reads; POST /api/posts/{id}/share returns the URL to the roles allowed to manage links."
          }
        }
      },
      "PostPlatform": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "channelId": {
            "type": "integer"
          },
          "platform": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "publishing",
              "published",
              "failed",
              "processing",
              "unconfirmed"
            ],
            "description": "Per-platform publish state. 'unconfirmed' is terminal: the publish request may have reached the platform but its response was lost, or the platform was still processing the upload when we stopped polling (video processing can outlast the check window) \u2014 check the account, then retry with republish: true if the post is not live."
          },
          "platformPostId": {
            "type": [
              "string",
              "null"
            ]
          },
          "platformUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "errorMessage": {
            "type": [
              "string",
              "null"
            ]
          },
          "retryCount": {
            "type": "integer"
          }
        }
      },
      "Channel": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "platform": {
            "type": "string",
            "enum": [
              "facebook",
              "instagram",
              "x",
              "tiktok",
              "youtube",
              "threads",
              "bluesky",
              "pinterest",
              "gmb",
              "linkedin",
              "mastodon",
              "reddit",
              "discord",
              "telegram",
              "tumblr",
              "snapchat"
            ]
          },
          "accountName": {
            "type": "string"
          },
          "accountId": {
            "type": "string"
          },
          "accountType": {
            "type": "string"
          },
          "profileImage": {
            "type": [
              "string",
              "null"
            ]
          },
          "isActive": {
            "type": "boolean"
          },
          "tokenStatus": {
            "type": "string",
            "enum": [
              "valid",
              "expiring_soon",
              "expired"
            ]
          },
          "tokenExpiresAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "metadata": {
            "type": "object"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "platformAvailable": {
            "type": "boolean",
            "description": "Whether this channel can publish right now. `false` means it is switched off server-side \u2014 the channel itself is healthy, but its scheduled posts are being held.\n\nResolved for THIS channel: the `PLATFORM_<NAME>` flag narrowed by any variant flag matching its `accountType`. LinkedIn company pages (`accountType: \"organization\"`) are gated separately from personal profiles, so two LinkedIn channels in the same response can differ here."
          },
          "platformState": {
            "type": "string",
            "enum": [
              "on",
              "connect_off",
              "off"
            ],
            "description": "Current availability of the platform, controlled by the `PLATFORM_<NAME>` environment flag on the server.\n\n- `on` \u2014 fully available.\n- `connect_off` \u2014 new channels cannot be connected, but channels already connected keep publishing normally (used while a platform app review is pending).\n- `off` \u2014 kill switch: the platform is unavailable, and posts targeting it are **held**, not failed. They publish automatically once the platform is switched back on.\n\nResolved for THIS channel: the `PLATFORM_<NAME>` flag narrowed by any variant flag matching its `accountType`. LinkedIn company pages (`accountType: \"organization\"`) are gated separately from personal profiles, so two LinkedIn channels in the same response can differ here."
          },
          "platformMessage": {
            "type": [
              "string",
              "null"
            ],
            "description": "User-facing explanation when the platform is not fully available; `null` otherwise. When a variant rather than the whole platform is what is switched off, this names the variant (e.g. \"LinkedIn Company Pages\")."
          }
        }
      },
      "MediaFile": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "fileName": {
            "type": "string"
          },
          "mimeType": {
            "type": "string"
          },
          "sizeBytes": {
            "type": "integer"
          },
          "width": {
            "type": [
              "integer",
              "null"
            ]
          },
          "height": {
            "type": [
              "integer",
              "null"
            ]
          },
          "duration": {
            "type": [
              "number",
              "null"
            ]
          },
          "originalUrl": {
            "type": "string"
          },
          "thumbnailUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "160x160 square crop (webp). For videos this is generated from an extracted poster frame; null until the derivative job has run."
          },
          "previewUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "400px-wide derivative (webp) for grids. For videos, generated from the poster frame."
          },
          "largeUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "1200px-wide derivative (webp) for lightboxes and large preview panes. For videos, generated from the poster frame. Null on media uploaded before this derivative existed until the backfill runs."
          },
          "altText": {
            "type": "string",
            "nullable": true,
            "description": "Accessibility description sent to Instagram, LinkedIn and Bluesky. Set with PATCH /api/media/{id}."
          }
        }
      },
      "Label": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "color": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "post",
              "media"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Schedule": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "frequency": {
            "type": "string",
            "enum": [
              "daily",
              "weekly",
              "biweekly",
              "monthly"
            ]
          },
          "dayOfWeek": {
            "type": [
              "integer",
              "null"
            ]
          },
          "dayOfMonth": {
            "type": [
              "integer",
              "null"
            ]
          },
          "timeOfDay": {
            "type": "string"
          },
          "timezone": {
            "type": "string"
          },
          "channelIds": {
            "type": "array",
            "items": {
              "type": "integer"
            }
          },
          "contentTemplate": {
            "type": "string"
          },
          "isActive": {
            "type": "boolean"
          },
          "nextRunAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "requireApproval": {
            "type": "boolean",
            "default": false,
            "description": "Optional. Hold every occurrence this schedule generates for team approval \u2014 each generated post lands with approvalStatus 'pending' and the scheduler skips it until an approver releases it via POST /api/posts/{id}/approve. Default false."
          }
        }
      },
      "Notification": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "userId": {
            "type": "string"
          },
          "organizationId": {
            "type": "integer",
            "nullable": true
          },
          "organizationName": {
            "type": "string",
            "nullable": true,
            "description": "Name of the organization it belongs to, when it has one."
          },
          "type": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "data": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true,
            "description": "Payload for this notification type."
          },
          "isRead": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "NotificationPreferences": {
        "type": "object",
        "description": "Which notifications are delivered and by which route. Email categories are opt-in and default off; in-app categories default on.",
        "properties": {
          "emailOnFailure": {
            "type": "boolean"
          },
          "emailOnTokenExpiry": {
            "type": "boolean"
          },
          "emailOnChannelSlots": {
            "type": "boolean"
          },
          "inAppPublished": {
            "type": "boolean"
          },
          "inAppFailed": {
            "type": "boolean"
          },
          "inAppScheduleReminder": {
            "type": "boolean"
          },
          "inAppTokenExpiry": {
            "type": "boolean"
          },
          "inAppInbox": {
            "type": "boolean"
          }
        }
      },
      "Organization": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "pro",
              "business"
            ]
          },
          "ownerId": {
            "type": "string"
          },
          "role": {
            "type": "string",
            "enum": [
              "owner",
              "admin",
              "member",
              "approver",
              "contributor",
              "viewer"
            ],
            "description": "The caller's role in this organization. 'member' is legacy; no new code assigns it."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "message": {
                "type": "string"
              },
              "code": {
                "type": "string"
              }
            }
          }
        }
      },
      "PlatformSpecific": {
        "type": "object",
        "description": "Per-platform options. Keys are platform names. Facebook: { shareToStory }. Instagram: { collaborators, shareToStory, trialReel, graduationStrategy ('manual'|'auto' \u2014 any other value is rejected with 400 VALIDATION_ERROR), thumbnailTimestamp (seconds) }. TikTok: { privacyLevel ('PUBLIC_TO_EVERYONE'|'MUTUAL_FOLLOW_FRIENDS'|'FOLLOWER_OF_CREATOR'|'SELF_ONLY' \u2014 any other value is rejected with 400 VALIDATION_ERROR), disableDuet, disableStitch, disableComment, isAigc, brandContentToggle, brandOrganicToggle, thumbnailTimestamp }. YouTube: { title, privacyStatus ('public'|'unlisted'|'private' \u2014 any other value is rejected with 400 VALIDATION_ERROR), categoryId, madeForKids, playlistId, thumbnailUrl }. Pinterest: { title, description, link, [channelId]: { boardId } }. GMB: { ctaType ('BOOK'|'ORDER'|'SHOP'|'LEARN_MORE'|'SIGN_UP'|'CALL' \u2014 any other value is rejected with 400 VALIDATION_ERROR), ctaUrl, eventTitle, startDate, startTime, endDate, endTime, couponCode, redeemOnlineUrl, termsConditions }. X: { replySettings ('everyone'|'following'|'verified'|'subscribers'|'mentionedUsers' \u2014 any other value is rejected with 400 VALIDATION_ERROR) }. Threads: { quotePostId, topicTag, locationId }. topicTag is one topic per post, sent without a leading '#'; a value containing a period or an ampersand is rejected with 400 VALIDATION_ERROR. locationId is a numeric place ID from GET /api/channels/{id}/options?q=<place> on a Threads channel (any other value is rejected with 400 VALIDATION_ERROR); location search is available only once the connected Threads account has granted location tagging, and the options endpoint answers { unavailable: true } until then. Both apply to the root post of a thread, never to its replies. Reddit: { [channelId]: { subreddit (required), title, type, url, flairId, thumbnailUrl } }; subreddit falls back to the one stored on the channel, and title falls back to the first line of content truncated to 300 chars. thumbnailUrl is optional on video posts \u2014 when omitted the server falls back to the video's auto-extracted poster frame, and the publish fails only if neither exists. Unlike Pinterest's coverImageUrl there is no attached-image fallback, because a Reddit media post accepts exactly one file. Discord: { [channelId]: { channelId } } \u2014 the outer key is the BulkPublish channel id, the inner channelId is the target Discord text channel. Telegram: no options \u2014 the destination chat is fixed when the channel is connected. Tumblr: { [channelId]: { blogName, title, tags (array of strings, no leading '#'), link, sourceUrl } }; blogName defaults to the blog the channel was connected as. Snapchat: { [channelId]: { title (Saved Story title, max 45 chars \u2014 defaults to the caption's first line), locale (Spotlight locale, default 'en_US'), saveToProfile (Spotlight only \u2014 false sends skip_save_to_profile) } }; post types are story (default; caption not sent to Snapchat), saved_story and spotlight (video only, caption becomes the 160-char description); every Snapchat post requires exactly one image or video. Reddit, Discord, Tumblr and Snapchat also accept a flat (non channel-id-keyed) object that applies to every channel of that platform on the post. Use _firstComment (string), at the TOP level of platformSpecific rather than inside a platform key, for an auto-posted reply after publish. Supported on x, instagram, facebook, linkedin, youtube, threads, bluesky, mastodon, reddit and telegram; NOT supported on discord, pinterest, tiktok, gmb or tumblr, where the main post still publishes and the comment is recorded as failed. Snapchat does not support first comments either.",
        "properties": {
          "facebook": {
            "type": "object",
            "properties": {
              "shareToStory": {
                "type": "boolean",
                "description": "Also share to Facebook Story"
              }
            }
          },
          "x": {
            "type": "object",
            "properties": {
              "replySettings": {
                "type": "string",
                "enum": [
                  "everyone",
                  "following",
                  "verified",
                  "subscribers",
                  "mentionedUsers"
                ],
                "description": "Who can reply to this tweet"
              }
            }
          },
          "tiktok": {
            "type": "object",
            "properties": {
              "privacyLevel": {
                "type": "string",
                "enum": [
                  "PUBLIC_TO_EVERYONE",
                  "MUTUAL_FOLLOW_FRIENDS",
                  "FOLLOWER_OF_CREATOR",
                  "SELF_ONLY"
                ]
              },
              "disableDuet": {
                "type": "boolean"
              },
              "disableStitch": {
                "type": "boolean"
              },
              "disableComment": {
                "type": "boolean"
              },
              "isAigc": {
                "type": "boolean",
                "description": "AI-generated content disclosure"
              },
              "brandContentToggle": {
                "type": "boolean",
                "description": "Paid partnership"
              },
              "brandOrganicToggle": {
                "type": "boolean",
                "description": "Brand promotional content"
              },
              "thumbnailTimestamp": {
                "type": "number",
                "description": "Thumbnail timestamp in seconds"
              }
            }
          },
          "youtube": {
            "type": "object",
            "properties": {
              "title": {
                "type": "string"
              },
              "privacyStatus": {
                "type": "string",
                "enum": [
                  "public",
                  "unlisted",
                  "private"
                ]
              },
              "categoryId": {
                "type": "string"
              },
              "madeForKids": {
                "type": "boolean"
              },
              "playlistId": {
                "type": "string"
              },
              "thumbnailUrl": {
                "type": "string",
                "description": "URL of custom thumbnail image"
              }
            }
          },
          "pinterest": {
            "type": "object",
            "properties": {
              "title": {
                "type": "string"
              },
              "description": {
                "type": "string"
              },
              "link": {
                "type": "string",
                "description": "Destination URL"
              },
              "dominantColor": {
                "type": "string",
                "description": "Hex color e.g. #FF5733"
              },
              "coverImageUrl": {
                "type": "string",
                "description": "Cover image URL for video pins. Optional \u2014 when omitted, an image attached alongside the video is used, otherwise the auto-extracted poster frame of the video. Publishing fails only if none of the three is available."
              }
            }
          },
          "gmb": {
            "type": "object",
            "properties": {
              "ctaType": {
                "type": "string",
                "enum": [
                  "LEARN_MORE",
                  "BOOK",
                  "ORDER",
                  "SHOP",
                  "SIGN_UP",
                  "CALL"
                ]
              },
              "ctaUrl": {
                "type": "string"
              },
              "eventTitle": {
                "type": "string"
              },
              "startDate": {
                "type": "string",
                "description": "YYYY-MM-DD"
              },
              "startTime": {
                "type": "string",
                "description": "HH:MM"
              },
              "endDate": {
                "type": "string"
              },
              "endTime": {
                "type": "string"
              },
              "couponCode": {
                "type": "string"
              },
              "redeemOnlineUrl": {
                "type": "string"
              },
              "termsConditions": {
                "type": "string"
              }
            }
          },
          "instagram": {
            "type": "object",
            "properties": {
              "collaborators": {
                "type": "string",
                "description": "Comma-separated usernames without @"
              },
              "shareToStory": {
                "type": "boolean",
                "description": "Also share to Instagram Story"
              },
              "trialReel": {
                "type": "boolean",
                "description": "Post as trial reel"
              },
              "graduationStrategy": {
                "type": "string",
                "enum": [
                  "manual",
                  "auto"
                ],
                "description": "Trial reel graduation strategy"
              },
              "thumbnailTimestamp": {
                "type": "number",
                "description": "Thumbnail timestamp in seconds"
              }
            }
          },
          "threads": {
            "type": "object",
            "properties": {
              "quotePostId": {
                "type": "string",
                "description": "Threads post ID to quote"
              }
            }
          },
          "reddit": {
            "type": "object",
            "description": "Keyed by BulkPublish channel id (e.g. \"12\"), or flat to apply to every Reddit channel on the post. The fields below are those of the inner object.",
            "properties": {
              "subreddit": {
                "type": "string",
                "description": "Required. Target subreddit. Accepts 'webdev', 'r/webdev' or '/r/webdev' \u2014 normalized to a bare lowercase name. Falls back to the subreddit stored on the channel."
              },
              "title": {
                "type": "string",
                "description": "Post title. Defaults to the first line of content, truncated to 300 characters."
              },
              "type": {
                "type": "string",
                "description": "Set to 'link' to force a link submission. The submission kind is otherwise resolved from the attached media: image file -> image, video file -> video, url set -> link, else self."
              },
              "url": {
                "type": "string",
                "description": "Destination URL for a link post. Supplying it implies type 'link'."
              },
              "flairId": {
                "type": "string",
                "description": "Link-flair id. List a subreddit's flairs via GET /api/channels/{id}/options."
              },
              "thumbnailUrl": {
                "type": "string",
                "description": "Poster image URL for video posts. Optional \u2014 when omitted the server falls back to the video's auto-extracted poster frame, and publishing fails only when neither is available. Unlike Pinterest's coverImageUrl there is no attached-image fallback: a Reddit media post accepts exactly one file, so a video post cannot also carry a cover image."
              }
            }
          },
          "discord": {
            "type": "object",
            "description": "Keyed by BulkPublish channel id (e.g. \"12\"), or flat to apply to every Discord channel on the post. The fields below are those of the inner object.",
            "properties": {
              "channelId": {
                "type": "string",
                "description": "Required. The target Discord text channel id (a snowflake) within the connected server. Note this is a Discord channel id, not the BulkPublish channel id used as the outer key. List postable channels via GET /api/channels/{id}/options."
              }
            }
          },
          "telegram": {
            "type": "object",
            "description": "Telegram accepts no platform-specific options \u2014 the destination chat is fixed when the channel is connected. Content is sent as plain text (no parse mode). Text longer than the 1024-character caption limit is posted as a second message alongside captionless media.",
            "properties": {}
          },
          "tumblr": {
            "type": "object",
            "description": "Keyed by BulkPublish channel id (e.g. \"12\"), or flat to apply to every Tumblr channel on the post. The fields below are those of the inner object.",
            "properties": {
              "blogName": {
                "type": "string",
                "description": "Which blog to publish to. Defaults to the blog the channel was connected as (the account's primary blog). List the account's blogs via GET /api/channels/{id}/options."
              },
              "title": {
                "type": "string",
                "description": "Rendered as a heading block above the post body."
              },
              "tags": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Tumblr tags, without the leading '#'."
              },
              "link": {
                "type": "string",
                "description": "Appended to the post as a link block."
              },
              "sourceUrl": {
                "type": "string",
                "description": "Attribution URL stored as the post's source."
              }
            }
          },
          "snapchat": {
            "type": "object",
            "description": "Keyed by BulkPublish channel id (e.g. \"12\"), or flat to apply to every Snapchat channel on the post. Post types: story (default), saved_story, spotlight. Every Snapchat post requires exactly one image or video; spotlight is video-only (6\u201360s). The caption is used only for Spotlight (160-char description) and as the Saved Story title fallback \u2014 plain stories carry no text.",
            "properties": {
              "title": {
                "type": "string",
                "maxLength": 45,
                "description": "Saved Story title (max 45 chars). Defaults to the first line of the caption, truncated."
              },
              "locale": {
                "type": "string",
                "description": "Spotlight locale, e.g. 'en_US'. Defaults to 'en_US' when omitted."
              },
              "saveToProfile": {
                "type": "boolean",
                "description": "Spotlight only. Default true; false sends skip_save_to_profile to Snap."
              }
            }
          }
        }
      },
      "PlatformAvailability": {
        "type": "object",
        "description": "Availability of one social platform. Disabled platforms are always included in responses with `enabled: false` \u2014 never omitted \u2014 so clients can tell \"switched off right now\" apart from \"not supported\".",
        "properties": {
          "platform": {
            "type": "string",
            "enum": [
              "facebook",
              "instagram",
              "x",
              "tiktok",
              "youtube",
              "threads",
              "bluesky",
              "pinterest",
              "gmb",
              "linkedin",
              "mastodon",
              "reddit",
              "discord",
              "telegram",
              "tumblr",
              "snapchat"
            ]
          },
          "displayName": {
            "type": "string",
            "example": "LinkedIn"
          },
          "color": {
            "type": [
              "string",
              "null"
            ],
            "description": "Brand colour hex, for UI rendering.",
            "example": "#0A66C2"
          },
          "enabled": {
            "type": "boolean",
            "description": "Convenience flag; equivalent to `state == \"on\"`."
          },
          "state": {
            "type": "string",
            "enum": [
              "on",
              "connect_off",
              "off"
            ],
            "description": "Current availability of the platform, controlled by the `PLATFORM_<NAME>` environment flag on the server.\n\n- `on` \u2014 fully available.\n- `connect_off` \u2014 new channels cannot be connected, but channels already connected keep publishing normally (used while a platform app review is pending).\n- `off` \u2014 kill switch: the platform is unavailable, and posts targeting it are **held**, not failed. They publish automatically once the platform is switched back on."
          },
          "reason": {
            "type": "string",
            "enum": [
              "enabled",
              "flag_connect_off",
              "flag_off",
              "unconfigured"
            ],
            "description": "Why the platform is in this state. `unconfigured` means the server has no OAuth app credentials for it yet."
          },
          "canConnect": {
            "type": "boolean",
            "description": "Whether a NEW channel can be connected right now."
          },
          "canPublish": {
            "type": "boolean",
            "description": "Whether already-connected channels can publish right now."
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "description": "User-facing explanation. `null` when the platform is fully enabled."
          },
          "envVar": {
            "type": "string",
            "description": "The server env var controlling this platform. Only returned to organization owners and admins.",
            "example": "PLATFORM_LINKEDIN"
          },
          "variants": {
            "type": "object",
            "description": "Sub-platforms gated independently of their parent, keyed by the channel `accountType` they cover. Present only for platforms that have one. Today: `linkedin.organization` \u2014 LinkedIn company pages, which run on a separate LinkedIn app (Community Management API) reviewed separately from personal profiles, so pages can be paused while personal-profile posting stays fully live. A variant is never more permissive than its parent: `state: \"off\"` on the platform means every variant is off too.",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "label": {
                  "type": "string",
                  "example": "LinkedIn Company Pages"
                },
                "enabled": {
                  "type": "boolean",
                  "description": "Convenience flag; equivalent to `state == \"on\"`."
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "on",
                    "connect_off",
                    "off"
                  ],
                  "description": "Same semantics as the platform-level `state`, resolved for this variant."
                },
                "reason": {
                  "type": "string",
                  "enum": [
                    "enabled",
                    "flag_connect_off",
                    "flag_off",
                    "unconfigured"
                  ]
                },
                "canConnect": {
                  "type": "boolean",
                  "description": "Whether a NEW channel of this account type can be connected right now."
                },
                "canPublish": {
                  "type": "boolean",
                  "description": "Whether already-connected channels of this account type can publish right now."
                },
                "message": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "User-facing explanation. `null` when the variant is fully enabled."
                },
                "envVar": {
                  "type": "string",
                  "description": "The server env var controlling this variant. Only returned to organization owners and admins.",
                  "example": "PLATFORM_LINKEDIN_PAGES"
                }
              }
            }
          }
        }
      },
      "HashtagGroup": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "hashtags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Stored normalised: leading '#', no spaces, deduplicated case-insensitively, in the order given."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PostTemplate": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "content": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CalendarNote": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "body": {
            "type": "string"
          },
          "color": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Posts",
      "description": "Create, schedule, publish, and manage posts across platforms"
    },
    {
      "name": "Channels",
      "description": "Connect and manage social media accounts"
    },
    {
      "name": "Channel Sets",
      "description": "Saved channel groups for one-click targeting"
    },
    {
      "name": "RSS Autopost",
      "description": "Turn RSS/Atom feed items into posts automatically"
    },
    {
      "name": "Media",
      "description": "Upload and manage images and videos"
    },
    {
      "name": "Analytics",
      "description": "View engagement metrics and performance data"
    },
    {
      "name": "Labels",
      "description": "Organize posts and media with labels"
    },
    {
      "name": "Hashtag Groups",
      "description": "Saved sets of hashtags to drop into a post"
    },
    {
      "name": "Templates",
      "description": "Saved post text to start a new post from"
    },
    {
      "name": "Calendar Notes",
      "description": "Free-text notes pinned to calendar days"
    },
    {
      "name": "Schedules",
      "description": "Set up recurring post schedules"
    },
    {
      "name": "Organizations",
      "description": "Manage workspaces and teams"
    },
    {
      "name": "Notifications",
      "description": "View and manage in-app notifications"
    },
    {
      "name": "API Keys",
      "description": "Manage API authentication keys. Available on all plans (Free: 1 key, Pro: 5, Business: 10)."
    },
    {
      "name": "OAuth",
      "description": "Beta: let your users connect their own BulkPublish account instead of pasting an API key. Register an app under Settings > API to get a client ID."
    },
    {
      "name": "Utilities",
      "description": "Link previews, quotas, activity log"
    }
  ],
  "paths": {
    "/api/posts": {
      "get": {
        "tags": [
          "Posts"
        ],
        "summary": "List posts",
        "description": "Retrieve posts with pagination, filtering, and search. Results are ordered newest-first by the timestamp that applies to each post \u2014 publishedAt if it is live, else scheduledAt if it is due, else createdAt \u2014 with id descending as a tiebreaker. A post written weeks before it publishes therefore sorts by when it went live, not when it was drafted.",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Page number (default: 1)",
            "schema": {
              "type": "integer",
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Items per page, max 500 (default: 20)",
            "schema": {
              "type": "integer",
              "default": 20,
              "maximum": 500
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by post status",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "scheduled",
                "publishing",
                "published",
                "processing",
                "failed",
                "partial"
              ]
            }
          },
          {
            "name": "channelId",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by channel ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "labelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated label IDs to filter by",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "labelId",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by single label ID (legacy, prefer labelIds)",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "labelMode",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 How to combine label filters (default: or)",
            "schema": {
              "type": "string",
              "enum": [
                "or",
                "and"
              ],
              "default": "or"
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Search in post content",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter posts created on or after this date",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter posts created on or before this date",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "scheduledFrom",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter posts scheduled on or after this date",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "scheduledTo",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter posts scheduled on or before this date",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "recurring",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Set to \"true\" to filter recurring posts only",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "approvalStatus",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "none",
                "pending",
                "approved",
                "rejected"
              ]
            },
            "description": "Filter by team approval state (e.g. 'pending' for the approval queue)."
          }
        ],
        "responses": {
          "200": {
            "description": "Posts list with pagination",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "posts": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Post"
                      }
                    },
                    "total": {
                      "type": "integer"
                    },
                    "page": {
                      "type": "integer"
                    },
                    "limit": {
                      "type": "integer"
                    },
                    "totalPages": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "listPosts",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      },
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Create post",
        "description": "Create a new post as draft or scheduled. Validates character limits per platform, media ownership, channel ownership, and plan quotas.\n\n**Character limits:** X: 280, Bluesky: 300, Threads: 500, Mastodon: 500, Pinterest: 500, GMB: 1500, Instagram: 2200, TikTok: 2200, LinkedIn: 3000, YouTube: 5000, Facebook: 63206. On X and Mastodon every URL counts as a flat 23 characters (t.co wrapping / Mastodon link counting), regardless of its real length.\n\n**Link cards:** for a text-only post (no media) containing a URL, the first URL is unfurled at publish time. Facebook receives it as the `link` param, Bluesky as an external embed (with thumbnail), and LinkedIn as a `content.article` card (LinkedIn's API never scrapes URLs itself; if the page yields no title, the post publishes as plain text without a card). Other platforms unfurl the URL themselves or render it as plain text (links in Instagram/TikTok captions are not clickable).\n\nReturns **403** with `code: \"PLATFORM_DISABLED\"` when a target channel's platform has been switched off server-side (`PLATFORM_<NAME>=off`). This is distinct from `FEATURE_DISABLED`, which means the platform is not on the organization's plan. Check `GET /api/platforms` for current availability. A channel's *variant* can be off on its own \u2014 LinkedIn company pages are gated separately from personal profiles \u2014 in which case the error also carries `accountType` (e.g. `\"organization\"`) naming the variant that is unavailable.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "channels"
                ],
                "properties": {
                  "content": {
                    "type": "string",
                    "description": "Post text content (optional if media attached). LinkedIn mentions: write @[Display Name](urn:li:organization:ID) to mention a Page or @[Display Name](urn:li:person:ID) to mention a person; the token publishes as a real mention on LinkedIn and as plain @Display Name on every other channel of the same post, and counts as @Display Name toward every character limit. GET /api/channels/{id}/mentions?q=<vanity-name> on a LinkedIn channel returns ready-made tokens for Pages (exact match on the Page's URL slug; a personal channel borrows a connected Page's access for the lookup). Reserved LinkedIn formatting characters in the rest of the text are escaped for you at publish time, so send plain text and never pre-escape it yourself."
                  },
                  "channels": {
                    "type": "array",
                    "description": "Required. Target channels to publish to",
                    "items": {
                      "type": "object",
                      "properties": {
                        "channelId": {
                          "type": "integer",
                          "description": "Channel ID from GET /api/channels"
                        },
                        "platform": {
                          "type": "string",
                          "description": "Platform name. Optional \u2014 the server always resolves the platform from the channel record; a supplied value is ignored."
                        }
                      },
                      "required": [
                        "channelId"
                      ]
                    }
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "draft",
                      "scheduled"
                    ],
                    "default": "draft",
                    "description": "Optional. Default: draft"
                  },
                  "mediaFiles": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Optional. Media file IDs from POST /api/media"
                  },
                  "scheduledAt": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Optional. Required when status is 'scheduled'"
                  },
                  "timezone": {
                    "type": "string",
                    "default": "UTC",
                    "description": "Optional. IANA timezone (default: UTC)"
                  },
                  "labels": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Optional. Label IDs to attach"
                  },
                  "postFormat": {
                    "type": "string",
                    "enum": [
                      "post",
                      "video",
                      "reel",
                      "story",
                      "carousel",
                      "thread"
                    ],
                    "default": "post",
                    "description": "Optional. Default: post"
                  },
                  "postTypeOverrides": {
                    "type": "object",
                    "description": "Optional. Per-platform post type override. E.g. { \"instagram\": \"reel\", \"facebook\": \"story\" }. A type the platform does not offer is rejected with 400 VALIDATION_ERROR (previously it silently fell back to the platform default). Instagram: feed_photo, feed_video, reel, story, carousel. Facebook: post, reel, story. TikTok: video, photo_slideshow. YouTube: video, short. LinkedIn: post, multi_image. Pinterest: pin, video_pin, carousel. GMB: standard, event, offer. Snapchat: story, saved_story, spotlight. `repost` (X, Threads, Bluesky, Mastodon) reposts an existing post at the scheduled time: the post's own text and media are ignored and `platformSpecific.<platform>.repostId` must hold the target (X: status URL or ID; Mastodon: status URL or ID; Bluesky: bsky.app post URL or at:// URI; Threads: the numeric post ID; Threads links cannot be used, and for posts published through BulkPublish the ID is in the post details). A missing or unparsable target is rejected with 400 VALIDATION_ERROR. Rolling out: until it is switched on for your account the composer does not offer it."
                  },
                  "platformSpecific": {
                    "$ref": "#/components/schemas/PlatformSpecific",
                    "description": "Optional. Platform-specific options"
                  },
                  "platformContent": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "Optional. Per-platform content overrides mapping platform name to a plain string, e.g. { \"x\": \"Short version\" }. Values must be strings \u2014 nested objects are rejected with 400 VALIDATION_ERROR."
                  },
                  "deleteMediaAfterPublish": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Delete uploaded media right after publishing (default: false \u2014 media is kept and reclaimed by the 3-month retention sweep). Forced to false when the post has a repeatSchedule."
                  },
                  "threadParts": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "content": {
                          "type": "string"
                        },
                        "mediaFileIds": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "description": "Optional. Required when postFormat is 'thread' (min 2 parts)"
                  },
                  "platformThreadParts": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "content": {
                            "type": "string"
                          },
                          "mediaFileIds": {
                            "type": "array",
                            "items": {
                              "type": "integer"
                            }
                          }
                        }
                      }
                    },
                    "description": "Optional. Per-platform thread overrides, e.g. { \"x\": [{ \"content\": \"Part 1\" }] }. Part content must be a plain string \u2014 non-string shapes are rejected with 400 VALIDATION_ERROR."
                  },
                  "repeatSchedule": {
                    "type": "object",
                    "description": "Optional. Recurring schedule config",
                    "properties": {
                      "frequency": {
                        "type": "string",
                        "enum": [
                          "daily",
                          "weekly",
                          "biweekly",
                          "monthly"
                        ]
                      },
                      "daysOfWeek": {
                        "type": "array",
                        "items": {
                          "type": "integer"
                        },
                        "description": "0=Sun, 6=Sat"
                      },
                      "dayOfMonth": {
                        "type": "integer"
                      },
                      "timeOfDay": {
                        "type": "string",
                        "description": "HH:MM format"
                      },
                      "timezone": {
                        "type": "string"
                      }
                    }
                  },
                  "autoPlugEnabled": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Auto-post a comment when the post reaches a like threshold"
                  },
                  "autoPlugText": {
                    "type": "string",
                    "description": "Optional. The promotional comment text for auto-plug"
                  },
                  "autoPlugThreshold": {
                    "type": "integer",
                    "default": 50,
                    "description": "Optional. Number of likes to trigger auto-plug (default: 50)"
                  },
                  "autoRepostEnabled": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Auto-repost/reshare when the post reaches a like threshold"
                  },
                  "autoRepostThreshold": {
                    "type": "integer",
                    "default": 100,
                    "description": "Optional. Number of likes to trigger auto-repost (default: 100)"
                  },
                  "requestApproval": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Set true to hold a scheduled post for team approval (approvalStatus becomes 'pending'). Forced on server-side for roles without post:publish (contributors), regardless of this flag."
                  },
                  "linkTrackingOverride": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "default": null,
                    "description": "Per-post override for link tracking (bulkpubli.sh). `true` forces links in this post to be shortened and their clicks counted, `false` forces them to publish as written, and `null` (the default) inherits the organization's Link Tracking setting. Shortening happens at publish time, per channel, so two accounts on the same platform get distinct codes; it is skipped for a channel when the rewrite would push the post past that platform's character limit."
                  }
                }
              },
              "examples": {
                "simple": {
                  "summary": "Simple text post to X",
                  "value": {
                    "content": "Hello world!",
                    "channels": [
                      {
                        "channelId": 1,
                        "platform": "x"
                      }
                    ],
                    "status": "draft"
                  }
                },
                "scheduled-multi": {
                  "summary": "Scheduled post to multiple platforms",
                  "value": {
                    "content": "Check out our new feature!",
                    "mediaFiles": [
                      1,
                      2
                    ],
                    "status": "scheduled",
                    "scheduledAt": "2026-03-27T14:00:00Z",
                    "timezone": "America/New_York",
                    "channels": [
                      {
                        "channelId": 1,
                        "platform": "x"
                      },
                      {
                        "channelId": 3,
                        "platform": "linkedin"
                      }
                    ]
                  }
                },
                "linkedin-carousel": {
                  "summary": "LinkedIn PDF carousel",
                  "value": {
                    "content": "10 tips for social media growth",
                    "mediaFiles": [
                      10,
                      11,
                      12,
                      13,
                      14
                    ],
                    "channels": [
                      {
                        "channelId": 3,
                        "platform": "linkedin"
                      }
                    ],
                    "postTypeOverrides": {
                      "linkedin": "pdf_carousel"
                    },
                    "platformSpecific": {
                      "linkedin": {
                        "carouselTitle": "10 Tips"
                      }
                    },
                    "status": "draft"
                  }
                },
                "tiktok-with-controls": {
                  "summary": "TikTok video with content controls",
                  "value": {
                    "content": "Tutorial video",
                    "mediaFiles": [
                      20
                    ],
                    "channels": [
                      {
                        "channelId": 5,
                        "platform": "tiktok"
                      }
                    ],
                    "platformSpecific": {
                      "tiktok": {
                        "privacyLevel": "PUBLIC_TO_EVERYONE",
                        "disableComment": false,
                        "disableDuet": true,
                        "disableStitch": true,
                        "isAigc": false,
                        "brandContentToggle": false,
                        "brandOrganicToggle": false
                      }
                    },
                    "status": "scheduled",
                    "scheduledAt": "2026-03-28T18:00:00Z"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Post created"
          },
          "400": {
            "description": "Validation error (character limit, missing fields)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Platform not on the plan (`FEATURE_DISABLED`) or switched off server-side (`PLATFORM_DISABLED`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Quota exceeded"
          }
        },
        "operationId": "createPost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}": {
      "get": {
        "tags": [
          "Posts"
        ],
        "summary": "Get post",
        "description": "Returns full post details including recurringScheduleId and recurringSchedule when the post is linked to a recurring schedule.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Post details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Post"
                }
              }
            }
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "getPost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      },
      "put": {
        "tags": [
          "Posts"
        ],
        "summary": "Update post",
        "description": "Update a draft, scheduled, failed, or partial post. Note: the per-platform object fields (platformSpecific, platformContent, postTypeOverrides, platformThreadParts) are replaced wholesale, not merged \u2014 send the complete object including keys for every platform you want to keep. Sending a partial object (e.g. only one platform's content) removes the stored values for all other platforms. Editing a FAILED post silently resets its status to draft and clears its failed platform results (they revert to pending). Editing a PARTIAL post keeps it partial and leaves every platform row untouched \u2014 it is live on some channels; re-send the failed ones with POST /api/posts/{id}/retry. Platforms in 'published', 'processing', or 'unconfirmed' are never reset by an edit. When `channels` is sent, rows for channels that remain keep their state (a published channel is NOT re-published on the next publish); removed channels are dropped and new ones added as pending. Send the optional status field ('draft' or 'scheduled') to move a post between draft and scheduled; other status values are rejected. To publish immediately, use POST /api/posts/{id}/publish instead.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "content": {
                    "type": "string"
                  },
                  "mediaFiles": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "scheduledAt": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "draft",
                      "scheduled"
                    ],
                    "description": "Move the post between draft and scheduled. Setting 'scheduled' requires a future scheduledAt (in this body or already stored) and at least one channel; setting 'draft' unschedules it. Any other value is rejected. Omit to leave the status unchanged (failed/partial posts still auto-reset to draft on edit). To publish immediately, use POST /api/posts/{id}/publish instead."
                  },
                  "channels": {
                    "type": "array",
                    "description": "Full target channel list ({ channelId, platform } items). Rows for channels already on the post are kept with their current publish state; removed channels are deleted; new channels are added as pending."
                  },
                  "labels": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "platformSpecific": {
                    "type": "object",
                    "description": "Per-platform options, keyed by platform. Replaced wholesale on update (not merged)."
                  },
                  "platformContent": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "Per-platform content overrides, keyed by platform; values must be plain strings (nested objects are rejected with 400 VALIDATION_ERROR). Replaced wholesale on update."
                  },
                  "postTypeOverrides": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "description": "Per-platform post type overrides, keyed by platform. Replaced wholesale on update. `repost` (X, Threads, Bluesky, Mastodon) reposts an existing post at the scheduled time: the post's own text and media are ignored and `platformSpecific.<platform>.repostId` must hold the target (X: status URL or ID; Mastodon: status URL or ID; Bluesky: bsky.app post URL or at:// URI; Threads: the numeric post ID; Threads links cannot be used, and for posts published through BulkPublish the ID is in the post details). A missing or unparsable target is rejected with 400 VALIDATION_ERROR. Rolling out: until it is switched on for your account the composer does not offer it."
                  },
                  "postFormat": {
                    "type": "string",
                    "enum": [
                      "post",
                      "thread"
                    ],
                    "description": "Post format. Defaults to 'post'."
                  },
                  "threadParts": {
                    "type": "array",
                    "description": "Thread segments (required when postFormat is 'thread'; at least 2). Each part: { content, mediaFileIds }.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "content": {
                          "type": "string"
                        },
                        "mediaFileIds": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  },
                  "requestApproval": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Set true to hold a scheduled post for team approval (approvalStatus becomes 'pending'). Forced on server-side for roles without post:publish (contributors), regardless of this flag."
                  },
                  "linkTrackingOverride": {
                    "type": [
                      "boolean",
                      "null"
                    ],
                    "default": null,
                    "description": "Per-post override for link tracking (bulkpubli.sh). `true` forces links in this post to be shortened and their clicks counted, `false` forces them to publish as written, and `null` (the default) inherits the organization's Link Tracking setting. Shortening happens at publish time, per channel, so two accounts on the same platform get distinct codes; it is skipped for a channel when the rewrite would push the post past that platform's character limit."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated post"
          },
          "400": {
            "description": "Validation error"
          }
        },
        "operationId": "updatePost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "patch": {
        "tags": [
          "Posts"
        ],
        "summary": "Attach or detach a post's recurring schedule",
        "description": "Narrow companion to PUT. This endpoint ONLY accepts `recurringScheduleId` \u2014 passing `null` detaches the post from its repeat schedule (and deactivates that schedule if it is left orphaned).\n\nAny other field (including `status` and `scheduledAt`) is rejected with **400** and `unsupportedFields` listing what was sent. **To edit a post \u2014 including moving a draft to `scheduled` \u2014 use `PUT /api/posts/{id}` with `{ \"status\": \"scheduled\", \"scheduledAt\": \"<future ISO 8601>\" }`.**",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "recurringScheduleId": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Repeat-schedule id to attach, or null to detach."
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated post",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Post"
                }
              }
            }
          },
          "400": {
            "description": "A field other than recurringScheduleId was sent; use PUT instead",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Post not found"
          }
        },
        "operationId": "patchPost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Posts"
        ],
        "summary": "Delete post",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "409": {
            "description": "Post is being published right now (code PUBLISH_IN_PROGRESS)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "deletePost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Returns 409 with code PUBLISH_IN_PROGRESS while the post is 'publishing' or 'processing' \u2014 a post mid-publish cannot be deleted (the platform post would still go live with no record of it). Wait for it to settle, then delete."
      }
    },
    "/api/posts/{id}/publish": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Publish immediately",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Publishing started"
          },
          "403": {
            "description": "Role cannot publish, or a target platform is switched off server-side (`PLATFORM_DISABLED`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "description": "Validation error, or only unconfirmed platforms remain (code UNCONFIRMED_REQUIRES_REPUBLISH)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "A publish for this post is already in flight (code ALREADY_PUBLISHING)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "description": "Requires a role with post:publish \u2014 contributors get 403 APPROVAL_REQUIRED and must submit the post for approval instead (create/update with requestApproval, then a teammate approves). Publishing a pending/rejected post as an approver implicitly approves it.\n\nReturns **403** with `code: \"PLATFORM_DISABLED\"` when one of the post's platforms has been switched off server-side (`PLATFORM_<NAME>=off`); the post is left untouched. Check `GET /api/platforms` for current availability. A channel's *variant* can be off on its own \u2014 LinkedIn company pages are gated separately from personal profiles \u2014 in which case the error also carries `accountType` (e.g. `\"organization\"`) naming the variant that is unavailable.\n\nOn a failed or partial post this re-sends exactly the platforms in status 'failed' (their retryCount is incremented); 'published', 'processing' and 'pending' rows are never re-sent. Platforms in 'unconfirmed' are never touched here \u2014 if they are the only rows left the request fails with **400** `code: \"UNCONFIRMED_REQUIRES_REPUBLISH\"`; check the account, then use POST /api/posts/{id}/retry with `republish: true`. Returns **409** `ALREADY_PUBLISHING` when a publish is already in flight.",
        "operationId": "publishPost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/retry": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Retry failed platforms",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Retry queued"
          },
          "400": {
            "description": "No failed platforms, or unconfirmed platforms present without republish: true (code UNCONFIRMED_REQUIRES_REPUBLISH)"
          }
        },
        "description": "Re-queues this post's failed platforms. Platforms in status 'unconfirmed' (the publish request may have reached the platform but its response was lost \u2014 the post may already be live) are NOT retried unless the body passes republish: true; without it the request fails with code UNCONFIRMED_REQUIRES_REPUBLISH. Check the account before passing republish: true \u2014 retrying an unconfirmed platform can duplicate the post.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "republish": {
                    "type": "boolean",
                    "default": false,
                    "description": "Explicit opt-in to also retry platforms in status 'unconfirmed'. Their publish may have already gone through; retrying can create a duplicate post."
                  }
                }
              }
            }
          }
        },
        "operationId": "retryPost",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/metrics": {
      "get": {
        "tags": [
          "Posts"
        ],
        "summary": "Get post metrics",
        "description": "Per-platform engagement metrics with history snapshots.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Metrics per platform.\n\nEach entry in `platforms` carries `metricsSupported` (false when the platform exposes no readable per-post statistics API \u2014 Google Business, Telegram, Tumblr (which reports only a combined note count that cannot be split into likes/reblogs/replies), and LinkedIn personal/profile channels) and `supportedMetrics`, the list of metric keys that platform's API can populate. Every other key on `latest` is stored as 0 by the metrics sync regardless of what the platform returned, so those zeroes mean \u201cnot reported\u201d and must not be presented as measurements.\n\nEach platform entry carries `linkClicks`, and `totals.linkClicks` sums them: clicks on bulkpubli.sh short links in this post, measured by BulkPublish. It sits OUTSIDE `latest` because it is not a platform snapshot \u2014 a platform that reports nothing still has link clicks \u2014 and is distinct from the platform-reported `clicks` field. `supportedMetrics` therefore always includes `linkClicks`, even when `metricsSupported` is false.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "postId": {
                      "type": "integer"
                    },
                    "platforms": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "platform": {
                            "type": "string"
                          },
                          "platformPostId": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "platformUrl": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "status": {
                            "type": "string"
                          },
                          "metricsSupported": {
                            "type": "boolean",
                            "description": "false => this channel can never have per-post metrics."
                          },
                          "supportedMetrics": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "enum": [
                                "impressions",
                                "reach",
                                "likes",
                                "comments",
                                "shares",
                                "saves",
                                "clicks",
                                "videoViews",
                                "engagementRate"
                              ]
                            },
                            "description": "Metric keys this platform's API can report. Keys not listed are stored zeroes, not measurements."
                          },
                          "latest": {
                            "type": [
                              "object",
                              "null"
                            ]
                          },
                          "history": {
                            "type": "array"
                          }
                        }
                      }
                    },
                    "totals": {
                      "type": "object"
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getPostMetrics",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/engagement": {
      "get": {
        "tags": [
          "Posts"
        ],
        "summary": "Get post engagement (commenters + reactors)",
        "description": "Returns per-platform lists of the people/pages who commented on or reacted to a published post \u2014 with name, handle/headline, profile photo and profile URL when the platform exposes them.\n\nEleven of the sixteen platforms return engagement: Facebook, Instagram, X, LinkedIn, YouTube, Threads, Bluesky, Mastodon, Reddit, Discord and Tumblr. Platforms that expose commenters but never individual likers (Instagram, YouTube, Threads, Reddit) populate `comments` only and set `reactionsUnsupported: true` plus an explanatory `notice`.\n\nExactly five platforms have no comment API at all and return `unsupported: true` with a `notice` saying why: **TikTok** (comments are Research-API only), **Pinterest** (v5 exposes no Pin comments), **Google Business** (local posts have no comment surface; reviews attach to the location, not the post), **Telegram** (a bot learns of comments only through pushed updates, so they cannot be read on request) and **Snapchat** (the Public Profile API reports reply counts only, never individual comments).\n\nPlatform-specific behaviour worth knowing: **X** bills for every read, so replies and likers are returned only when that channel has `metadata.metricsSyncEnabled` set (the same opt-in that governs X metrics) and the org is within its daily read budget \u2014 otherwise `comments`/`reactions` are empty and `notice` says which gate declined; X replies also come from recent search, which only covers the last 7 days. **Reddit** keeps votes anonymous, so it reports commenters only. **Tumblr** has no comments endpoint \u2014 replies and reblogs-with-commentary map to `comments`, and likes plus bare reblogs to `reactions`. **Discord** returns reactors plus the replies in a thread started from the message; plain channel replies are ordinary messages elsewhere in the channel and are not fetched.\n\n`engagement` is `null` (rather than an empty object) when the post was never published to that channel, or when the channel has no stored access token.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "commentsLimit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Max comments per platform (default 25, max 100). Counts replies as well as top-level comments; when replies push a platform past the cap, `hasMoreComments` is true.",
            "schema": {
              "type": "integer",
              "default": 25
            }
          },
          {
            "name": "reactionsLimit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Max reactors per platform (default 25, max 100)",
            "schema": {
              "type": "integer",
              "default": 25
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Engagement per platform",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "postId": {
                      "type": "integer"
                    },
                    "platforms": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "platform": {
                            "type": "string"
                          },
                          "platformPostId": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "platformUrl": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "engagement": {
                            "type": [
                              "object",
                              "null"
                            ],
                            "properties": {
                              "comments": {
                                "type": "array",
                                "items": {
                                  "type": "object",
                                  "properties": {
                                    "id": {
                                      "type": "string"
                                    },
                                    "text": {
                                      "type": "string"
                                    },
                                    "createdAt": {
                                      "type": "string"
                                    },
                                    "likeCount": {
                                      "type": "integer"
                                    },
                                    "actor": {
                                      "type": "object",
                                      "properties": {
                                        "id": {
                                          "type": "string"
                                        },
                                        "name": {
                                          "type": "string"
                                        },
                                        "handle": {
                                          "type": "string"
                                        },
                                        "headline": {
                                          "type": "string"
                                        },
                                        "profileImage": {
                                          "type": "string"
                                        },
                                        "profileUrl": {
                                          "type": "string"
                                        }
                                      }
                                    },
                                    "parentId": {
                                      "type": "string",
                                      "description": "Set when this comment is a reply \u2014 the id of the comment it replies to. Absent for top-level comments. Clients can use it to nest replies; a parentId not present in the returned page should be rendered at top level."
                                    }
                                  }
                                }
                              },
                              "reactions": {
                                "type": "array",
                                "items": {
                                  "type": "object",
                                  "properties": {
                                    "id": {
                                      "type": "string"
                                    },
                                    "type": {
                                      "type": "string"
                                    },
                                    "createdAt": {
                                      "type": "string"
                                    },
                                    "actor": {
                                      "type": "object"
                                    }
                                  }
                                }
                              },
                              "hasMoreComments": {
                                "type": "boolean"
                              },
                              "hasMoreReactions": {
                                "type": "boolean"
                              },
                              "unsupported": {
                                "type": "boolean"
                              },
                              "notice": {
                                "type": "string"
                              },
                              "reactionsUnsupported": {
                                "type": "boolean",
                                "description": "True when the platform exposes commenters but never individual likers (Threads, Instagram, YouTube). `reactions` is always empty; clients should hide any reactors UI rather than render an empty list."
                              },
                              "commentsNotice": {
                                "type": "string",
                                "description": "Why the COMMENT list specifically is empty, set whenever the comment read did not succeed (missing token, a gate declined \u2014 e.g. X analytics not enabled \u2014 the platform API errored, or the platform has no thread for this post, like a Discord message without a thread). `notice` mixes comment- and reaction-scoped messages; a client that renders only comments should show this field and treat an empty `comments` with `commentsNotice` absent as a genuine measured zero."
                              }
                            }
                          },
                          "error": {
                            "type": "string",
                            "description": "Route-level failure for this platform: the live read threw, or the post is published (`platformPostId` set) but the channel has no stored access token (needs reconnecting). When set alongside `engagement: null` the post IS live on this platform \u2014 the data simply could not be read."
                          },
                          "accountName": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "Name of the connected account/Page the content was read from. Shown in the UI so the source is identifiable."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Post not found"
          }
        },
        "operationId": "getPostEngagement",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/story": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Publish as Story",
        "description": "Publish the post's first media as a Facebook or Instagram Story (disappears after 24h). The post must have media and a published platform entry for the target platform. Stories publish inline and are single-flight per post+platform: a second call within ~2 minutes of a success returns **409** `code: \"STORY_IN_PROGRESS\"`. If the request reached the platform but the response was lost, the call returns **422** `code: \"STORY_UNCONFIRMED\"` and stays locked for the same window \u2014 check the account before trying again, the story may already be live. Each attempt is recorded in the activity log (post.story_published / post.story_failed).",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "platform"
                ],
                "properties": {
                  "platform": {
                    "type": "string",
                    "enum": [
                      "facebook",
                      "instagram"
                    ],
                    "description": "**required** \u2014 Target platform for the story"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Story published",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "postId": {
                      "type": "string"
                    },
                    "url": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid platform, no media, or no matching platform entry"
          },
          "404": {
            "description": "Post not found"
          },
          "409": {
            "description": "A story for this post+platform is already being published (code STORY_IN_PROGRESS)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "The platform rejected the story, or its outcome could not be confirmed (code STORY_UNCONFIRMED)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "publishStory",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/posts/bulk": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Bulk actions",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action",
                  "postIds"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "delete",
                      "retry",
                      "reschedule"
                    ]
                  },
                  "postIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "scheduledAt": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Bulk action completed"
          }
        },
        "operationId": "bulkCreatePosts",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "`retry` re-sends only platforms in status 'failed' that still have retry budget (retryCount < maxRetries), skips posts already mid-publish, and never touches 'unconfirmed' platforms (use the single retry endpoint with republish: true). `delete` skips posts that are 'publishing' or 'processing' and reports them in `skippedInProgress`."
      }
    },
    "/api/posts/queue-slot": {
      "get": {
        "tags": [
          "Posts"
        ],
        "summary": "Get next available queue slot",
        "description": "Returns the next available time slot for scheduling a post based on your queue settings.",
        "parameters": [
          {
            "name": "timezone",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 IANA timezone (default: UTC)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "position",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `next` (default) returns the next free slot. `end` returns the slot after the last pending scheduled post, i.e. the bottom of the queue; with nothing queued it equals `next`. Any other value is rejected with 400 VALIDATION_ERROR.",
            "schema": {
              "type": "string",
              "enum": [
                "next",
                "end"
              ],
              "default": "next"
            }
          },
          {
            "name": "excludePostId",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Leave this post's current slot out of the booked set, for rescheduling it: the queue's \"move to top/bottom\" passes the post being moved, then updates it with PUT /api/posts/{id}. Must be a numeric post ID.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Next available queue slot"
          }
        },
        "operationId": "getQueueSlot",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      }
    },
    "/api/channels": {
      "get": {
        "tags": [
          "Channels"
        ],
        "summary": "List channels",
        "description": "Returns all connected channels including tokenExpiresAt and metadata per channel.",
        "parameters": [
          {
            "name": "active",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by active status (default: true)",
            "schema": {
              "type": "boolean",
              "default": true
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Channel list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "channels": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Channel"
                      }
                    },
                    "capabilities": {
                      "type": "object",
                      "description": "What the CALLING member may do in this organization, resolved from their team role. Returned alongside the channel list so a client does not have to re-derive permissions from a role string.",
                      "properties": {
                        "canCreatePosts": {
                          "type": "boolean"
                        },
                        "canPublishPosts": {
                          "type": "boolean"
                        },
                        "canApprovePosts": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "listChannels",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      }
    },
    "/api/channels/{id}": {
      "get": {
        "tags": [
          "Channels"
        ],
        "summary": "Get channel",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Channel details"
          }
        },
        "operationId": "getChannel",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Channels"
        ],
        "summary": "Disconnect channel",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Disconnected"
          }
        },
        "operationId": "deleteChannel",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/channels/{id}/health": {
      "get": {
        "tags": [
          "Channels"
        ],
        "summary": "Check channel health",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Health status"
          }
        },
        "operationId": "getChannelHealth",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      }
    },
    "/api/channels/{id}/options": {
      "get": {
        "tags": [
          "Channels"
        ],
        "summary": "Get platform-specific options for a channel",
        "description": "Returns boards (Pinterest), playlists (YouTube), or organizations (LinkedIn) depending on the channel's platform. Returns empty items for platforms without sub-resources. Threads: `?q=<place>` returns `{ type: 'locations', items: [{ id, name, address? }] }`; until the connected account has granted location tagging the response is `{ type: 'locations', items: [], unavailable: true }`.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Channel ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Search term. Reddit: subreddit name search. Threads: place search for `platformSpecific.threads.locationId`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "subreddit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Reddit only: return that subreddit's post flairs instead of searching.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Platform options",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "boards",
                        "playlists",
                        "organizations",
                        null
                      ]
                    },
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getChannelOptions",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      }
    },
    "/api/channels/{id}/mentions": {
      "get": {
        "tags": [
          "Channels"
        ],
        "summary": "Search users for @mention",
        "description": "Search accounts on a channel's platform to @mention in post content. Supported on X, Bluesky and LinkedIn; other platforms return an empty array. LinkedIn matches Pages by their exact URL slug (linkedin.com/company/<slug>, a pasted URL works) and each result's `handle` is the ready-to-insert token `@[Name](urn:li:organization:ID)` and `vanityName` is the slug it matched on, so a caller can confirm the right Page before inserting the token (a display name alone is ambiguous); people cannot be searched. A personal LinkedIn profile channel uses a connected, active Page in the same workspace for the lookup; when none is connected the response is `{ users: [], notice }` with a sentence to show the user. Queries under 2 characters return an empty list without a lookup; results are cached for about a minute.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Channel ID",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Search query (max 100 chars)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{ users: [{ id, handle, name, profileImage?, vanityName? }], notice? }. `vanityName` is present on LinkedIn results only: it is the Page's URL slug (linkedin.com/company/<slug>), which is what the search matched on. `notice` is present only when nothing could be searched (LinkedIn personal channel without a connected Page).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "users": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "handle": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "profileImage": {
                            "type": [
                              "string",
                              "null"
                            ]
                          },
                          "vanityName": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "LinkedIn only: the Page's URL slug, i.e. the value the search matched on."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "searchChannelMentions",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      }
    },
    "/api/media": {
      "get": {
        "tags": [
          "Media"
        ],
        "summary": "List media files",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Page number (default: 1)",
            "schema": {
              "type": "integer",
              "default": 1
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Items per page (default: 20)",
            "schema": {
              "type": "integer",
              "default": 20
            }
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Search by filename",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "labelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated label IDs",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by media kind: `image` or `video` (matches the MIME type prefix)",
            "schema": {
              "type": "string",
              "enum": [
                "image",
                "video"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Media list \u2014 `{ files, page, limit, total }`, where `total` counts every file matching the filters across all pages"
          }
        },
        "operationId": "listMedia",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:read"
            ]
          }
        ]
      },
      "post": {
        "tags": [
          "Media"
        ],
        "summary": "Upload media (single request)",
        "description": "Upload an image or video file in one multipart request (server relays to storage). Max 100MB (for videos up to 1GB use the multipart endpoints: /api/media/multipart/create \u2192 PUT each part \u2192 /api/media/multipart/complete). Formats: JPEG, PNG, WebP, GIF, MP4, QuickTime, WebM. Browser clients should prefer the faster direct-to-storage flow: POST /api/media/presign then PUT the file to the returned URL then POST /api/media/finalize. MP4/QuickTime files must be complete (contain a moov atom) \u2014 a video captured mid-encode or truncated in transit is rejected with 400 at upload instead of failing at publish time. The same check runs on /api/media/finalize (via ranged reads) and on remote URL imports.",
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "File uploaded"
          }
        },
        "operationId": "uploadMedia",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/media/presign": {
      "post": {
        "tags": [
          "Media"
        ],
        "summary": "Get a direct upload URL",
        "description": "Step 1 of the direct-to-storage upload. Checks the storage quota and returns a presigned PUT URL plus the object key. PUT the file (max 100MB \u2014 videos up to 1GB go through the multipart endpoints instead) to `uploadUrl` with a matching `Content-Type` header, then call POST /api/media/finalize.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "contentType",
                  "sizeBytes"
                ],
                "properties": {
                  "contentType": {
                    "type": "string",
                    "description": "MIME type (JPEG, PNG, WebP, GIF, MP4, QuickTime, WebM)"
                  },
                  "sizeBytes": {
                    "type": "integer",
                    "description": "Size of the file to upload in bytes"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Presigned URL",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "uploadUrl": {
                      "type": "string"
                    },
                    "r2Key": {
                      "type": "string"
                    },
                    "expiresIn": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid type or size"
          },
          "403": {
            "description": "Storage quota exceeded"
          }
        },
        "operationId": "presignMediaUpload",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/media/finalize": {
      "post": {
        "tags": [
          "Media"
        ],
        "summary": "Finalize a direct upload",
        "description": "Step 2 of the direct-to-storage upload. After the file is PUT to the presigned URL, this records the media file and queues thumbnail generation. The stored object is verified server-side (existence, size, magic bytes) before the record is created.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "r2Key",
                  "fileName",
                  "mimeType",
                  "sizeBytes"
                ],
                "properties": {
                  "r2Key": {
                    "type": "string",
                    "description": "Object key returned by /api/media/presign"
                  },
                  "fileName": {
                    "type": "string"
                  },
                  "mimeType": {
                    "type": "string"
                  },
                  "sizeBytes": {
                    "type": "integer"
                  },
                  "width": {
                    "type": "integer",
                    "description": "**optional** \u2014 image/video width"
                  },
                  "height": {
                    "type": "integer",
                    "description": "**optional** \u2014 image/video height"
                  },
                  "duration": {
                    "type": "integer",
                    "description": "**optional** \u2014 video duration in seconds"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Media file created"
          },
          "400": {
            "description": "Invalid key, missing object, or content mismatch"
          },
          "403": {
            "description": "Storage quota exceeded"
          }
        },
        "operationId": "finalizeMediaUpload",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/media/{id}": {
      "get": {
        "tags": [
          "Media"
        ],
        "summary": "Get media file",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Media details"
          }
        },
        "operationId": "getMedia",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:read"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Media"
        ],
        "summary": "Delete media file",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          }
        },
        "operationId": "deleteMedia",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      },
      "patch": {
        "tags": [
          "Media"
        ],
        "summary": "Set alt text",
        "description": "Sets the accessibility description of a media file. Alt text belongs to the file, so one value covers every post that reuses it. It is sent wherever the destination accepts a description: Instagram (feed photos and carousel images), LinkedIn (single and multi-image posts) and Bluesky. Video files accept a value but no platform receives it yet. Send `null` or an empty string to clear. Trimmed to 1000 characters. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "altText"
                ],
                "properties": {
                  "altText": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 1000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated: { file: { id, altText } }"
          },
          "400": {
            "description": "Validation error"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "updateMedia",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/media/{id}/thumbnail": {
      "get": {
        "tags": [
          "Media"
        ],
        "summary": "Get media thumbnail",
        "description": "Redirects to the thumbnail URL for the given media file.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to thumbnail URL"
          },
          "404": {
            "description": "Media not found or no thumbnail available"
          }
        },
        "operationId": "getMediaThumbnail",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:read"
            ]
          }
        ]
      }
    },
    "/api/media/{id}/labels": {
      "get": {
        "tags": [
          "Media"
        ],
        "summary": "Get media labels",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Labels"
          }
        },
        "operationId": "getMediaLabels",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:read"
            ]
          }
        ]
      },
      "put": {
        "tags": [
          "Media"
        ],
        "summary": "Set media labels",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "labelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          }
        },
        "operationId": "setMediaLabels",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/analytics/summary": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Summary stats",
        "description": "Post counts by status, platform, and day. The date window is capped at 30 days: `from` is silently clamped so that it is never more than 30 days before `to`. Every response echoes the window in `from`/`to`. Channel/platform filters also narrow `byPlatform` and the per-day platform breakdown to the matching rows of a cross-post, not just the matching posts.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 Start date (YYYY-MM-DD)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 End date (YYYY-MM-DD)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "tz",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 IANA timezone (e.g. `Asia/Karachi`) used for day boundaries and per-day bucketing. Defaults to `UTC`. Invalid names fall back to UTC.",
            "schema": {
              "type": "string",
              "default": "UTC"
            }
          },
          {
            "name": "heatmap",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 set to `1` to include `publishedTimes` (raw publish timestamps for the posting-activity heatmap, up to 5000 entries). Omitted by default; `publishedTimes` is then an empty array.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          },
          {
            "name": "channelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated channel ids. Only post_platform rows on these channels are counted. Combines with `platforms`, `labelIds`, `postFormat` and `mediaType` (all must match).",
            "schema": {
              "type": "string",
              "example": "12,15"
            }
          },
          {
            "name": "platforms",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated platform keys (`x,youtube,\u2026`). Unknown keys are ignored.",
            "schema": {
              "type": "string",
              "example": "x,linkedin"
            }
          },
          {
            "name": "labelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated label ids. A post matches when it carries ANY of them.",
            "schema": {
              "type": "string",
              "example": "3,7"
            }
          },
          {
            "name": "postFormat",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `post` (single) or `thread`. Reads `posts.post_format`.",
            "schema": {
              "type": "string",
              "enum": [
                "post",
                "thread"
              ]
            }
          },
          {
            "name": "mediaType",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `text` (no media), `image` or `video`, decided by the post's FIRST media file (`media_files.mime_type`).",
            "schema": {
              "type": "string",
              "enum": [
                "text",
                "image",
                "video"
              ]
            }
          },
          {
            "name": "compare",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Set to `1` to also compute the equal-length window immediately before `from`. The result carries `previous` (same totals for that window, plus `byDay` on the engagement endpoint) and `previousWindow` (`{from, to, days, available}`). `previous` is `null` and `available` is `false` when the earlier window would reach past the 30-day statistics-retention floor \u2014 in practice comparison works for windows of 15 days or fewer.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Summary data: `totalPosts`, `published`, `scheduled`, `failed`, `partial`, `byPlatform`, `byDay`, `publishedTimes`, `from`, `to`, plus `previous`/`previousWindow` when `compare=1`."
          }
        },
        "operationId": "getAnalyticsSummary",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/analytics/engagement": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Engagement metrics",
        "description": "Impressions, reach, likes, comments, shares, saves, clicks, video views with per-platform breakdown and top posts. Results are cached for 5 minutes. The date window is capped at 30 days: `from` is silently clamped so that it is never more than 30 days before `to`.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 Start date (YYYY-MM-DD)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 End date (YYYY-MM-DD)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "channelId",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Legacy single-channel filter; equivalent to `channelIds=<id>` and merged with it.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "top",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Set to `1` to return only the ranked `topPosts` list; `allPosts` comes back as an empty array. Used by dashboards that render a short leaderboard and would otherwise download every post in the window.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "date",
                "impressions",
                "reach",
                "likes",
                "comments",
                "shares",
                "saves",
                "clicks",
                "videoViews",
                "linkClicks",
                "engagements",
                "engagementRate"
              ],
              "default": "date"
            },
            "description": "Sort field for `allPosts`. `engagements` = likes + comments + shares + clicks; `linkClicks` sorts by bulkpubli.sh click count; `engagementRate` by the post-level rate."
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            },
            "description": "Sort direction for `allPosts`."
          },
          {
            "name": "topBy",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Metric `topPosts` is ranked by. Same values as `sort` except `date`. Default `impressions`.",
            "schema": {
              "type": "string",
              "enum": [
                "impressions",
                "reach",
                "likes",
                "comments",
                "shares",
                "saves",
                "clicks",
                "videoViews",
                "linkClicks",
                "engagements",
                "engagementRate"
              ],
              "default": "impressions"
            }
          },
          {
            "name": "topOrder",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `desc` (default) for the best posts, `asc` for the worst.",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          },
          {
            "name": "heatmap",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Set to `1` to include `postTimes`: one `{t, e, i}` per published post_platform (publish instant, engagements, impressions; up to 5000), for engagement-weighted best-time-to-post views. Empty array otherwise.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          },
          {
            "name": "channelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated channel ids. Only post_platform rows on these channels are counted. Combines with `platforms`, `labelIds`, `postFormat` and `mediaType` (all must match).",
            "schema": {
              "type": "string",
              "example": "12,15"
            }
          },
          {
            "name": "platforms",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated platform keys (`x,youtube,\u2026`). Unknown keys are ignored.",
            "schema": {
              "type": "string",
              "example": "x,linkedin"
            }
          },
          {
            "name": "labelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated label ids. A post matches when it carries ANY of them.",
            "schema": {
              "type": "string",
              "example": "3,7"
            }
          },
          {
            "name": "postFormat",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `post` (single) or `thread`. Reads `posts.post_format`.",
            "schema": {
              "type": "string",
              "enum": [
                "post",
                "thread"
              ]
            }
          },
          {
            "name": "mediaType",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `text` (no media), `image` or `video`, decided by the post's FIRST media file (`media_files.mime_type`).",
            "schema": {
              "type": "string",
              "enum": [
                "text",
                "image",
                "video"
              ]
            }
          },
          {
            "name": "compare",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Set to `1` to also compute the equal-length window immediately before `from`. The result carries `previous` (same totals for that window, plus `byDay` on the engagement endpoint) and `previousWindow` (`{from, to, days, available}`). `previous` is `null` and `available` is `false` when the earlier window would reach past the 30-day statistics-retention floor \u2014 in practice comparison works for windows of 15 days or fewer.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Engagement data including totalImpressions, totalReach, totalLikes, totalComments, totalShares, totalSaves, totalClicks, totalVideoViews, avgEngagementRate, byPlatform, byDay, topPosts, allPosts, and unmeasuredPlatforms.\n\n`unmeasuredPlatforms` lists platforms present in the window that cannot report per-post metrics at all \u2014 either the platform has no readable metrics API (Google Business, Telegram, Tumblr) or, for LinkedIn, the channel is a personal/profile account (share statistics are exposed only for organization pages). Their rows still appear in every total with zeroes, so a zero for one of these platforms means \"not reported\", not \"measured zero\". Each entry in a post's `platformMetrics` carries the same signal as `metricsSupported`.\n\nAll figures come from the stored `post_metrics` snapshot, synced every 6 hours (or on demand via `POST /api/analytics/refresh`) \u2014 not a live read of the platform.\n\n`metricSupport` maps each platform present in the window to the list of metric keys its API can actually report (`impressions`, `reach`, `likes`, `comments`, `shares`, `saves`, `clicks`, `videoViews`, `engagementRate`). Anything not listed is stored as 0 by the metrics sync because the platform has no such field \u2014 it is NOT a measurement. For example X reports impressions/likes/comments/shares/saves (bookmarks), so `reach`, `clicks` and `videoViews` are always 0 for X rows; Bluesky and Mastodon report no impressions, so `engagementRate` is always 0 for them; Pinterest reports no reach; YouTube reports no shares or reach; Reddit reports likes (score), comments and shares (crossposts) only; Discord reports likes (reaction counts) and comments (thread replies) only.\n\n`supportedTotals` is the union of `metricSupport` across the window \u2014 the org-wide `total*` fields are only measurements for keys listed here. A key absent from `supportedTotals` should be rendered as \u201cnot available\u201d (a dash), never as 0. `partialTotals` maps a supported metric key to the platforms in the window that do NOT report it, so a consumer can note that the total covers only a subset of posts.\n\nEach entry of a post's `platformMetrics` also carries `supportedMetrics` (same per-platform list) alongside `metricsSupported`.\n\nFacebook's `impressions`, `reach`, `clicks` and `engagementRate` come from the Page Insights edge and require the `read_insights` permission; without it they stay 0 even though the metric is listed as supported.\n\n`metricsDisabledChannels` lists channels published to in the window whose metrics sync is switched off, so their posts contribute zeroes to every total. X is the only platform this applies to today: its reads are billed, so per-post metrics sync is opt-in per channel (`metadata.metricsSyncEnabled`, toggled on the Channels page) and runs at most once every 7 days. `POST /api/analytics/refresh` will not produce X figures for a channel that has not opted in.\n\n`conditionalMetrics` maps a platform in the window to metrics that are supported but permission-gated \u2014 a 0 there means \"may not be readable\", not \"no such metric\". Today only Facebook appears, for `impressions`, `reach`, `clicks` and `engagementRate`.\n\nEach post in `topPosts`/`allPosts` carries both a post-level `engagementRate` and, inside `platformMetrics[]`, a per-channel `engagementRate`. They are NOT the same number: the post-level value is the mean of the post's channels that reported a non-zero rate, while `platformMetrics[].engagementRate` is that one channel's own rate (engagements \u00f7 impressions). Render the per-channel value next to per-channel counters \u2014 using the post-level average there shows one network's percentage beside another network's zeros. Both are basis points (325 = 3.25%).\n\n`totalLinkClicks` (and `linkClicks` on each entry of `byPlatform`, `byDay`, `topPosts`, `allPosts` and `platformMetrics`) counts clicks on bulkpubli.sh short links, measured by BulkPublish rather than reported by the platform. It is deliberately NOT folded into `totalClicks`/`clicks`, which are the platform's own click figures: one visit can register in both, so adding them would double-count. Because we measure it ourselves it is available on every platform, including those listed in `unmeasuredPlatforms`, and it appears in `supportedTotals`/`metricSupport` for all of them. Bot and link-preview traffic is excluded. Zero for organizations that have not enabled Link Tracking.\n\n**Added 2026-09-03.** `byDay[]` entries now carry every metric (`impressions`, `reach`, `likes`, `comments`, `shares`, `saves`, `clicks`, `videoViews`, `linkClicks`, `engagements`, `posts`) plus `platforms`: the same bucket per platform for that day. `byPlatform` entries carry the same full set. `byChannel[]` lists `{channelId, platform, accountName, \u2026same metrics\u2026}` per channel. Each post in `topPosts`/`allPosts` also has `reach`, `engagements`, `postFormat` (`post`|`thread`), `mediaType` (`text`|`image`|`video`) and `labels[] {id, name, color}`; each `platformMetrics[]` entry has `channelId`, `accountName`, `reach`, `engagements` and `fetchedAt`. `publishedCount` is the number of published post_platform rows in the window. `filters` echoes the parsed filter set. With `compare=1`, `previous` holds the `total*`/`avgEngagementRate`/`publishedCount` fields and `byDay` for the preceding window (or `null` if unavailable) and `previousWindow` says which window that is."
          }
        },
        "operationId": "getAnalyticsEngagement",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/analytics/refresh": {
      "post": {
        "tags": [
          "Analytics"
        ],
        "summary": "Trigger metrics sync",
        "description": "Queue a background job to fetch latest metrics from all platforms and invalidate analytics cache.",
        "responses": {
          "200": {
            "description": "Queued"
          }
        },
        "operationId": "refreshAnalytics",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/analytics/account": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Account-level analytics",
        "description": "Daily snapshots of followers, impressions, reach, and profile views per connected channel. Data is synced automatically every 4 hours. Accepts the shared `channelIds` / `platforms` filters; the post-level filters (`labelIds`, `postFormat`, `mediaType`, `compare`) have no meaning for account snapshots and are ignored.",
        "parameters": [
          {
            "name": "channelId",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Legacy single-channel filter; equivalent to `channelIds=<id>`. A non-numeric value is a 400.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Start date (YYYY-MM-DD, default 30 days ago). Clamped to at most 30 days before the end date.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 End date (YYYY-MM-DD, default today)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "channelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated channel ids. Only post_platform rows on these channels are counted. Combines with `platforms`, `labelIds`, `postFormat` and `mediaType` (all must match).",
            "schema": {
              "type": "string",
              "example": "12,15"
            }
          },
          {
            "name": "platforms",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated platform keys (`x,youtube,\u2026`). Unknown keys are ignored.",
            "schema": {
              "type": "string",
              "example": "x,linkedin"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Account metrics with channel list.\n\n`engagementRate` on each row is ALWAYS `null`: no platform handler computes an account-level engagement rate (the `getAccountAnalytics` contract has no such field), so the stored column is its default 0 for every row. It is returned as null rather than 0 so it cannot be mistaken for a measured 0%. For a real rate, use the per-post `engagementRate` inside `platformMetrics[]` on `GET /api/analytics/engagement`.\n\n`websiteClicks` is populated by Google Business only; `profileViews` by Facebook only (`page_views_total`). Both are 0 for every other platform because no other handler reports them \u2014 not because the value was measured as zero.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "metrics": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "date": {
                            "type": "string"
                          },
                          "channelId": {
                            "type": "integer"
                          },
                          "platform": {
                            "type": "string"
                          },
                          "followers": {
                            "type": "integer"
                          },
                          "following": {
                            "type": "integer"
                          },
                          "impressions": {
                            "type": "integer"
                          },
                          "reach": {
                            "type": "integer"
                          },
                          "profileViews": {
                            "type": "integer"
                          },
                          "websiteClicks": {
                            "type": "integer"
                          },
                          "engagementRate": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "channels": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "platform": {
                            "type": "string"
                          },
                          "accountName": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getAccountAnalytics",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/analytics/post-history": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Per-post metric history",
        "description": "Every metrics snapshot stored for one post, per platform, oldest first \u2014 the series behind the \"since publish\" trend sparklines. `post_metrics` is append-only (one row per metrics sync per post_platform), so the history is whatever the sync cadence produced: roughly every 6 hours for most platforms, at most weekly for X channels that opted in. Snapshots older than the 30-day retention floor are excluded. Returns `{postId, publishedAt, series[]}` where each series is `{postPlatformId, platform, channelId, accountName, supportedMetrics, metricsSupported, points[]}` and each point is `{fetchedAt, impressions, reach, likes, comments, shares, saves, clicks, videoViews, engagementRate}`. A platform whose `metricsSupported` is `false` contributes stored zeroes, not measurements.",
        "parameters": [
          {
            "name": "postId",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 The post id.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "History series"
          },
          "400": {
            "description": "postId missing or not a positive integer"
          },
          "404": {
            "description": "Post not found in this organization"
          }
        },
        "operationId": "getAnalyticsPostHistory",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/analytics/links": {
      "get": {
        "tags": [
          "Analytics"
        ],
        "summary": "Tracked-link performance",
        "description": "Every bulkpubli.sh short link minted for a post published in the window, with its click count. Clicks are measured by BulkPublish's own redirector (bot and link-preview traffic excluded) rather than reported by the platform, so they exist for every network \u2014 including those in `unmeasuredPlatforms`. Totals sync from the redirector every 15 minutes; referrer and country splits are not stored. Ordered by clicks desc, capped at 2000 links. The date window is capped at 30 days like every other analytics endpoint. Returns `{from, to, totalClicks, totalLinks, links[]}`; each link is `{id, shortUrl, code, originalUrl, finalUrl, destinationHost, clicks, lastClickAt, createdAt, platform, channelId, accountName, postId, postContent, publishedAt}`. Empty for organizations that have not enabled Link Tracking.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 Start date (YYYY-MM-DD)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 End date (YYYY-MM-DD)",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "channelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated channel ids. Only post_platform rows on these channels are counted. Combines with `platforms`, `labelIds`, `postFormat` and `mediaType` (all must match).",
            "schema": {
              "type": "string",
              "example": "12,15"
            }
          },
          {
            "name": "platforms",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated platform keys (`x,youtube,\u2026`). Unknown keys are ignored.",
            "schema": {
              "type": "string",
              "example": "x,linkedin"
            }
          },
          {
            "name": "labelIds",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Comma-separated label ids. A post matches when it carries ANY of them.",
            "schema": {
              "type": "string",
              "example": "3,7"
            }
          },
          {
            "name": "postFormat",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `post` (single) or `thread`. Reads `posts.post_format`.",
            "schema": {
              "type": "string",
              "enum": [
                "post",
                "thread"
              ]
            }
          },
          {
            "name": "mediaType",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 `text` (no media), `image` or `video`, decided by the post's FIRST media file (`media_files.mime_type`).",
            "schema": {
              "type": "string",
              "enum": [
                "text",
                "image",
                "video"
              ]
            }
          },
          {
            "name": "compare",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 set to `1` to also return `previous`: `totalClicks` and `totalLinks` for the equal-length window immediately before this one. Only computed when that window falls entirely inside the 30-day statistics limit, i.e. for ranges of 15 days or fewer; otherwise `previous` is `null` and `previousWindow.available` is `false`.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Link performance\n\nWith `compare=1`, `previous` carries `totalClicks` and `totalLinks` for the preceding window of equal length (null when that window is outside the 30-day retention limit), and `previousWindow` states that window and whether it was available."
          }
        },
        "operationId": "getAnalyticsLinks",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/labels": {
      "get": {
        "tags": [
          "Labels"
        ],
        "summary": "List labels",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by label type. Omitted: every label the caller may see. Labels are org-wide and one quota counts every type together (see `GET /api/quotas/usage`).",
            "schema": {
              "type": "string",
              "enum": [
                "post",
                "media"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Labels"
          }
        },
        "operationId": "listLabels",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ],
        "description": "Sorted by name. Response is `Cache-Control: private, no-store`. Each label: `{ id, name, color, type, createdAt }`. Add `?counts=1` to include `usageCount` (posts or media files carrying the label) on each row."
      },
      "post": {
        "tags": [
          "Labels"
        ],
        "summary": "Create label",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "1\u2013100 characters, trimmed. Must be unique within the org for the same `type` (409 DUPLICATE_NAME otherwise).",
                    "maxLength": 100
                  },
                  "color": {
                    "type": "string",
                    "default": "#6366f1",
                    "description": "Hex colour, `#RGB` or `#RRGGBB`, e.g. `#3B82F6`. Default `#6366f1`. Any other format is rejected with 400.",
                    "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "post",
                      "media"
                    ],
                    "default": "post",
                    "description": "Which surface the label organizes. Default `post`. Any value outside this list is rejected with 400."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          }
        },
        "operationId": "createLabel",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Counts against the org's label quota (every type together)."
      }
    },
    "/api/labels/{id}": {
      "put": {
        "tags": [
          "Labels"
        ],
        "summary": "Update label",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "1\u2013100 characters, trimmed. Renaming to a name already used by another label of the same type returns 409 DUPLICATE_NAME.",
                    "maxLength": 100
                  },
                  "color": {
                    "type": "string",
                    "description": "Hex colour, `#RGB` or `#RRGGBB`, e.g. `#3B82F6`. Any other format is rejected with 400.",
                    "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          }
        },
        "operationId": "updateLabel",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "At least one of `name` or `color` is required. `type` cannot be changed."
      },
      "delete": {
        "tags": [
          "Labels"
        ],
        "summary": "Delete label",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          }
        },
        "operationId": "deleteLabel",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Detaches the label from every post and media file that carries it; the posts and files themselves are kept."
      }
    },
    "/api/schedules": {
      "get": {
        "tags": [
          "Schedules"
        ],
        "summary": "List recurring schedules",
        "responses": {
          "200": {
            "description": "Schedules"
          }
        },
        "operationId": "listSchedules",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ],
        "description": "Returns every recurring schedule in the organization, newest first. Schedules created here are fully visible in the web app: they are listed and manageable (pause/resume/delete) on the Repeat Posts page, and each active schedule's upcoming runs are projected onto the Calendar. An occurrence materialises as a real post (carrying recurringScheduleId) only at its run time - GET /api/posts?recurring=true returns those generated posts, not the schedules themselves."
      },
      "post": {
        "tags": [
          "Schedules"
        ],
        "summary": "Create schedule",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "frequency",
                  "timeOfDay",
                  "channelIds"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "frequency": {
                    "type": "string",
                    "enum": [
                      "daily",
                      "weekly",
                      "biweekly",
                      "monthly"
                    ]
                  },
                  "dayOfWeek": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 6
                  },
                  "dayOfMonth": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 31
                  },
                  "timeOfDay": {
                    "type": "string",
                    "description": "HH:MM format"
                  },
                  "timezone": {
                    "type": "string"
                  },
                  "channelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "contentTemplate": {
                    "type": "string"
                  },
                  "isActive": {
                    "type": "boolean",
                    "default": true
                  },
                  "postFormat": {
                    "type": "string",
                    "enum": [
                      "post",
                      "thread"
                    ],
                    "description": "Post format. Defaults to 'post'."
                  },
                  "threadParts": {
                    "type": "array",
                    "description": "Thread segments (required when postFormat is 'thread'; at least 2). Each part: { content, mediaFileIds }.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "content": {
                          "type": "string"
                        },
                        "mediaFileIds": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  },
                  "mediaFileIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Media file IDs to attach to each generated post."
                  },
                  "requireApproval": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Hold every occurrence this schedule generates for team approval \u2014 each generated post lands with approvalStatus 'pending' and the scheduler skips it until an approver releases it via POST /api/posts/{id}/approve. Default false."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          }
        },
        "operationId": "createSchedule",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Creates a recurring schedule that generates and publishes a post from contentTemplate at each run time. No post exists until the first run: the schedule itself appears on the web app's Repeat Posts page (with pause/resume/delete) and its upcoming runs are projected onto the Calendar, while each fired occurrence becomes a post carrying recurringScheduleId."
      }
    },
    "/api/schedules/{id}": {
      "put": {
        "tags": [
          "Schedules"
        ],
        "summary": "Update schedule",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Updated"
          }
        },
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "frequency": {
                    "type": "string",
                    "enum": [
                      "daily",
                      "weekly",
                      "biweekly",
                      "monthly"
                    ]
                  },
                  "dayOfWeek": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 6
                  },
                  "dayOfMonth": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 31
                  },
                  "timeOfDay": {
                    "type": "string",
                    "description": "HH:MM format"
                  },
                  "timezone": {
                    "type": "string"
                  },
                  "channelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "contentTemplate": {
                    "type": "string"
                  },
                  "isActive": {
                    "type": "boolean",
                    "default": true
                  },
                  "postFormat": {
                    "type": "string",
                    "enum": [
                      "post",
                      "thread"
                    ],
                    "description": "Post format. Defaults to 'post'."
                  },
                  "threadParts": {
                    "type": "array",
                    "description": "Thread segments (required when postFormat is 'thread'; at least 2). Each part: { content, mediaFileIds }.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "content": {
                          "type": "string"
                        },
                        "mediaFileIds": {
                          "type": "array",
                          "items": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  },
                  "mediaFileIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "description": "Media file IDs to attach to each generated post."
                  },
                  "requireApproval": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Hold every occurrence this schedule generates for team approval \u2014 each generated post lands with approvalStatus 'pending' and the scheduler skips it until an approver releases it via POST /api/posts/{id}/approve. Default false."
                  }
                }
              }
            }
          }
        },
        "operationId": "updateSchedule",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Schedules"
        ],
        "summary": "Delete schedule",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          }
        },
        "operationId": "deleteSchedule",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/organizations": {
      "get": {
        "tags": [
          "Organizations"
        ],
        "summary": "List organizations",
        "responses": {
          "200": {
            "description": "Organizations"
          }
        },
        "operationId": "listOrganizations",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "post": {
        "tags": [
          "Organizations"
        ],
        "summary": "Create organization",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          }
        },
        "operationId": "createOrganization",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/organizations/{id}": {
      "get": {
        "tags": [
          "Organizations"
        ],
        "summary": "Get organization details",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Organization details with user role"
          }
        },
        "operationId": "getOrganization",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "put": {
        "tags": [
          "Organizations"
        ],
        "summary": "Rename organization",
        "description": "Owner only. Updates the organization name.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated organization"
          }
        },
        "operationId": "updateOrganization",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "delete": {
        "tags": [
          "Organizations"
        ],
        "summary": "Delete organization",
        "description": "Owner only. Requires confirmation by sending the organization name in the request body. Permanently deletes the organization and all associated data (posts, media, channels, schedules, metrics). Cannot delete the user's last organization. Does NOT cancel Polar subscription.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "confirmName"
                ],
                "properties": {
                  "confirmName": {
                    "type": "string",
                    "description": "Must exactly match the organization name"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "400": {
            "description": "Confirmation required or cannot delete last organization"
          }
        },
        "operationId": "deleteOrganization",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/organizations/switch": {
      "post": {
        "tags": [
          "Organizations"
        ],
        "summary": "Switch active organization",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "organizationId"
                ],
                "properties": {
                  "organizationId": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Switched"
          }
        },
        "operationId": "switchOrganization",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/organizations/leave": {
      "post": {
        "tags": [
          "Organizations"
        ],
        "summary": "Leave an organization",
        "description": "Remove yourself from an organization you're a member of. The owner cannot leave (delete the organization instead). Your posts are reassigned to the owner.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "organizationId"
                ],
                "properties": {
                  "organizationId": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Left the organization"
          },
          "400": {
            "description": "Owner cannot leave, or invalid request"
          }
        },
        "operationId": "leaveOrganization",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/notifications": {
      "get": {
        "tags": [
          "Notifications"
        ],
        "summary": "List notifications",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Page number (default: 1)",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Items per page (default: 20)",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "unreadOnly",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Show only unread notifications",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Notifications"
          }
        },
        "operationId": "listNotifications",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "patch": {
        "tags": [
          "Notifications"
        ],
        "summary": "Mark as read",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "all": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Marked"
          }
        },
        "operationId": "updateNotifications",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "delete": {
        "tags": [
          "Notifications"
        ],
        "summary": "Delete notifications",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "all": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Deleted"
          }
        },
        "operationId": "deleteNotifications",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/notifications/preferences": {
      "get": {
        "tags": [
          "Notifications"
        ],
        "summary": "Get notification preferences",
        "responses": {
          "200": {
            "description": "Preferences"
          }
        },
        "operationId": "getNotificationPreferences",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "put": {
        "tags": [
          "Notifications"
        ],
        "summary": "Update preferences",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "emailOnFailure": {
                    "type": "boolean"
                  },
                  "emailDailyDigest": {
                    "type": "boolean"
                  },
                  "emailOnTokenExpiry": {
                    "type": "boolean"
                  },
                  "inAppPublished": {
                    "type": "boolean"
                  },
                  "inAppFailed": {
                    "type": "boolean"
                  },
                  "inAppScheduleReminder": {
                    "type": "boolean"
                  },
                  "inAppTokenExpiry": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          }
        },
        "operationId": "updateNotificationPreferences",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/link-preview": {
      "get": {
        "tags": [
          "Utilities"
        ],
        "summary": "Preview link metadata",
        "description": "Fetch OpenGraph metadata from a URL. SSRF-protected.",
        "parameters": [
          {
            "name": "url",
            "in": "query",
            "required": true,
            "description": "**required** \u2014 HTTP/HTTPS URL to preview",
            "schema": {
              "type": "string",
              "format": "uri"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Link metadata"
          }
        },
        "operationId": "getLinkPreview",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/quotas/usage": {
      "get": {
        "tags": [
          "Utilities"
        ],
        "summary": "Get quota usage",
        "description": "Current usage vs limits for all resource types on your plan.",
        "responses": {
          "200": {
            "description": "Current usage vs limits for all resource types on your plan.\n\n`usage.scheduledToday` counts posts scheduled FOR the current day (excluding `draft` and `failed`) and pairs with `limits.scheduledPerDay` \u2014 distinct from `usage.pendingScheduled`, which is every pending scheduled post regardless of date and pairs with `limits.maxPendingScheduled`. `usage.postsToday` counts posts *created* today and pairs with `limits.postsPerDay`.\n\nThe response also carries `channelSlots` \u2014 the org's purchased extra-channel add-on state: `{ active, slots: [{id, expiresAt, autoRenews}], baseChannelLimit, effectiveChannelLimit }`. Slots are granted by a seat-based Polar subscription at $2.99 per slot per month (seats = slot count, one subscription per organization). Each active slot raises the effective total channel limit by one and allows one channel above the per-platform cap (shared across platforms). A slot's `autoRenews` says whether it rolls forward with the subscription (`expiresAt` moves each cycle) or lapses at `expiresAt`; slots no connected channel is using are auto-canceled shortly before renewal, so the org is never billed for unused headroom. `effectiveChannelLimit` (base plan limit + active slots; `-1` stays `-1`) is what channel-connect quota checks actually enforce."
          }
        },
        "parameters": [
          {
            "name": "tz",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 IANA timezone name (e.g. `Asia/Karachi`) used only for the day boundary of `usage.scheduledToday`, so it matches the same calendar day the `scheduledPerDay` limit is enforced against at write time. Defaults to `UTC`; a malformed value falls back to `UTC` rather than erroring.",
            "schema": {
              "type": "string",
              "default": "UTC"
            }
          }
        ],
        "operationId": "getQuotaUsage",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/quotas/x-usage": {
      "get": {
        "tags": [
          "Utilities"
        ],
        "summary": "X (Twitter) API spend",
        "description": "Per-org X API call costs. Writes AND metered reads (metrics, engagement, user search, health checks) draw from ONE monthly budget \u2014 plan allowance first, purchased credits as overage; there is no separate read quota, and metered reads are unavailable on the Free plan (credits unlock Free-plan publishing only). `absorbedDcents` is retained for compatibility and covers only historical pre-2026-08 read usage. Returns today's breakdown, monthly budget status, and 30-day history.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 History window in days (default 30, max 90)",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "X usage data"
          }
        },
        "operationId": "getXUsage",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "analytics:read"
            ]
          }
        ]
      }
    },
    "/api/quotas/x-credits/checkout": {
      "post": {
        "tags": [
          "Utilities"
        ],
        "summary": "Create X credits checkout",
        "description": "Create a Polar checkout session to top up X (Twitter) API credits. The user pays the chosen amount; the `onOrderPaid` webhook increments their credit balance 1:1.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "amountCents"
                ],
                "properties": {
                  "amountCents": {
                    "type": "integer",
                    "description": "Amount to charge in USD cents. Minimum 500 ($5), maximum 100000 ($1000)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout URL"
          }
        },
        "operationId": "createXCreditsCheckout",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/quotas/ai-credits/checkout": {
      "post": {
        "tags": [
          "Utilities"
        ],
        "summary": "Create AI credits checkout",
        "description": "Create a Polar checkout session to top up AI caption credits. The user pays the chosen amount; the `onOrderPaid` webhook increments their credit balance 1:1 (1 cent = 1 AI run).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "amountCents"
                ],
                "properties": {
                  "amountCents": {
                    "type": "integer",
                    "description": "Amount to charge in USD cents. Minimum 500 ($5), maximum 100000 ($1000)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout URL"
          }
        },
        "operationId": "createAiCreditsCheckout",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/quotas/channel-slots/checkout": {
      "post": {
        "tags": [
          "Utilities"
        ],
        "summary": "Add extra channel slots",
        "description": "Purchase extra channel slots \u2014 a seat-based Polar subscription at $2.99 per slot per month (seats = slot count, one subscription per organization). Each active slot raises the organization's effective total channel limit by one and allows one channel above the per-platform cap (slots are generic and shared across platforms). Body: `{ count?: number }` \u2014 slots to add, default 1, clamped to 1..100. With no existing slot subscription this returns `{ url, checkoutId }` \u2014 redirect the user to `url` to subscribe. With an existing slot subscription the seats are increased server-side (prorated) and the response is `{ updated: true, seats }` with no redirect. Purchase is available on every plan, including Free; already-granted slots keep working on any tier until they lapse. Slots renew automatically with the subscription; shortly before each renewal, slots no connected channel is using are automatically canceled or reduced so the organization is never billed for unused headroom (an email explains how to keep them). When slots lapse (cancellation, refund, or payment failure) and the organization is left over its limit, the most recently connected excess channels are suspended (paused, tokens kept) after 7-day and 1-day warning emails. Channel-limit `403 QUOTA_EXCEEDED` errors on every plan include `addon: \"channel_slot\"` to signal this purchase as a remedy.",
        "responses": {
          "200": {
            "description": "New subscription: `{ url, checkoutId }` \u2014 redirect the user to `url` to pay. Existing subscription: `{ updated: true, seats }` \u2014 slots added by prorated seat increase, no redirect."
          },
          "403": {
            "description": "Reserved: organization is on a plan that cannot purchase slots (`code: SLOT_PLAN_INELIGIBLE`). Currently unused \u2014 every plan may purchase slots."
          },
          "503": {
            "description": "Payments are disabled (`code: PAYMENTS_DISABLED`) or the product is not configured."
          },
          "400": {
            "description": "Already at the maximum of 100 extra slots (`code: SLOT_MAX_REACHED`)."
          }
        },
        "operationId": "createChannelSlotsCheckout",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/ai/caption": {
      "post": {
        "tags": [
          "Utilities"
        ],
        "summary": "Generate an AI caption",
        "description": "Generate, rephrase, improve, shorten, expand, or restyle a caption \u2014 or caption an image via vision (Cloudflare Workers AI). Consumes one AI run-unit (image = 2) from the plan's monthly allowance, then from purchased credits. Returns up to several variants plus a usage snapshot.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "generate",
                      "improve",
                      "rephrase",
                      "shorten",
                      "expand",
                      "hashtags",
                      "emojis",
                      "fix",
                      "tone",
                      "image"
                    ],
                    "description": "What to do."
                  },
                  "text": {
                    "type": "string",
                    "description": "Working caption draft (context for generate; target for edit actions)."
                  },
                  "selection": {
                    "type": "string",
                    "description": "Selected substring to act on (Wordtune-style)."
                  },
                  "prompt": {
                    "type": "string",
                    "description": "Topic/instruction. Required for action=generate."
                  },
                  "platform": {
                    "type": "string",
                    "description": "Target platform key (drives tone + character budget)."
                  },
                  "tone": {
                    "type": "string",
                    "enum": [
                      "professional",
                      "casual",
                      "funny",
                      "inspirational",
                      "informative"
                    ],
                    "description": "Required for action=tone."
                  },
                  "imageUrl": {
                    "type": "string",
                    "format": "uri",
                    "description": "Public image URL. Required for action=image."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Caption variants + usage"
          },
          "402": {
            "description": "Out of AI runs \u2014 buy credits"
          },
          "429": {
            "description": "Rate limited"
          }
        },
        "operationId": "generateCaption",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/activity": {
      "get": {
        "tags": [
          "Utilities"
        ],
        "summary": "Activity log",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Page number (default: 1)",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Items per page (default: 20)",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "action",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by action type",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resource",
            "in": "query",
            "required": false,
            "description": "**optional** \u2014 Filter by resource type",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Activities"
          }
        },
        "operationId": "listActivity",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/channel-sets": {
      "get": {
        "tags": [
          "Channel Sets"
        ],
        "summary": "List channel sets",
        "description": "Saved channel groupings for one-click multi-channel targeting.",
        "responses": {
          "200": {
            "description": "Channel sets"
          }
        },
        "operationId": "listChannelSets",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      },
      "post": {
        "tags": [
          "Channel Sets"
        ],
        "summary": "Create channel set",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "channelIds"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "channelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "minItems": 1,
                    "description": "IDs of channels in your organization"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "400": {
            "description": "Invalid name or channelIds"
          },
          "409": {
            "description": "Duplicate name"
          }
        },
        "operationId": "createChannelSet",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/channel-sets/{id}": {
      "put": {
        "tags": [
          "Channel Sets"
        ],
        "summary": "Update channel set",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "channelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "minItems": 1,
                    "description": "IDs of channels in your organization"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "Duplicate name"
          }
        },
        "operationId": "updateChannelSet",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "delete": {
        "tags": [
          "Channel Sets"
        ],
        "summary": "Delete channel set",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "deleteChannelSet",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/rss-feeds": {
      "get": {
        "tags": [
          "RSS Autopost"
        ],
        "summary": "List RSS feeds",
        "description": "RSS/Atom feeds polled every 15 minutes; new items become posts. Polling uses conditional GET (ETag/Last-Modified) and exponential backoff on errors (15 minutes doubling to 24 hours); a feed is automatically disabled after 20 consecutive failed polls (`enabled` becomes false and `lastError` explains why). The first successful poll baselines the feed's existing backlog without posting it.",
        "responses": {
          "200": {
            "description": "Feeds"
          }
        },
        "operationId": "listRssFeeds",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "post": {
        "tags": [
          "RSS Autopost"
        ],
        "summary": "Add RSS feed",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "feedUrl",
                  "channelIds"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "feedUrl": {
                    "type": "string",
                    "description": "Public RSS 2.0 or Atom feed URL"
                  },
                  "channelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "minItems": 1
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "draft",
                      "publish"
                    ],
                    "default": "draft",
                    "description": "draft = new items become drafts for review; publish = auto-published"
                  },
                  "fieldMapping": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "description": "Per-feed field mapping controlling how an RSS/Atom item becomes post content. null (default) uses the built-in mapping: template \"{title}\\n\\n{link}\", no media, stripHtml true, smart truncation. Media selection is post-level (media attaches to the post, not a single channel); per-channel overrides cover text only, and channels on the same platform share one rendered text (written to the post's platformContent, as in the composer). Channels whose platform requires media (e.g. Instagram, TikTok, YouTube) are skipped for items lacking a usable enclosure, with the reason recorded in the activity log.",
                    "properties": {
                      "template": {
                        "type": "string",
                        "maxLength": 2000,
                        "default": "{title}\n\n{link}",
                        "description": "Caption template. Standard tokens: {title} {link} {description} {content} {author} {categories} {feedName}. Any of a feed item's own extra leaf fields (namespaced or not, e.g. {publisher}) can also be used as {fieldName} (lowercased localName); the editor surfaces a feed's real fields as pills after a preview. A line whose tokens all render empty is dropped."
                      },
                      "mediaField": {
                        "type": "string",
                        "enum": [
                          "none",
                          "image",
                          "video",
                          "auto"
                        ],
                        "default": "none",
                        "description": "Which item enclosure to import and attach: none, the item image, the item video, or auto (video if present, else image). The file is re-hosted to your media library; if the import fails the post is created without media."
                      },
                      "stripHtml": {
                        "type": "boolean",
                        "default": true,
                        "description": "Strip HTML tags/entities from {title}/{description}/{content}."
                      },
                      "truncate": {
                        "type": "string",
                        "enum": [
                          "smart",
                          "hard",
                          "skip"
                        ],
                        "default": "smart",
                        "description": "When rendered text exceeds the platform character limit: smart = trim at a word boundary keeping a trailing link line; hard = cut at the limit; skip = don't post to that channel."
                      },
                      "hashtags": {
                        "type": "string",
                        "maxLength": 500,
                        "default": "",
                        "description": "Appended after the rendered template."
                      },
                      "channelOverrides": {
                        "type": "object",
                        "description": "Per-channel text overrides keyed by channel id (as a string). Each override may set template, hashtags, stripHtml, truncate; mediaField cannot be overridden per channel.",
                        "additionalProperties": {
                          "type": "object",
                          "properties": {
                            "template": {
                              "type": "string",
                              "maxLength": 2000
                            },
                            "hashtags": {
                              "type": "string",
                              "maxLength": 500
                            },
                            "stripHtml": {
                              "type": "boolean"
                            },
                            "truncate": {
                              "type": "string",
                              "enum": [
                                "smart",
                                "hard",
                                "skip"
                              ]
                            }
                          }
                        }
                      }
                    }
                  },
                  "requireApproval": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Hold items auto-published from this feed for team approval \u2014 each generated post lands with approvalStatus 'pending' and waits for POST /api/posts/{id}/approve. Only meaningful when mode is 'publish' (draft items never publish on their own). Default false."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "400": {
            "description": "Invalid input"
          }
        },
        "description": "Create an RSS/Atom feed. Plan-gated: max feeds per org (Free 1, Pro 10, Business 50) and auto-publish mode (`mode: \"publish\"`) is paid-only \u2014 Free is draft-only and a `publish` request returns 403. Over the feed limit returns 403. Feed poll cadence is also per-plan (Free 60 min, Pro 30 min, Business 15 min).",
        "operationId": "createRssFeed",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/rss-feeds/{id}": {
      "put": {
        "tags": [
          "RSS Autopost"
        ],
        "summary": "Update RSS feed",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100
                  },
                  "feedUrl": {
                    "type": "string",
                    "description": "Public RSS 2.0 or Atom feed URL"
                  },
                  "channelIds": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    },
                    "minItems": 1
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "draft",
                      "publish"
                    ],
                    "default": "draft",
                    "description": "draft = new items become drafts for review; publish = auto-published"
                  },
                  "enabled": {
                    "type": "boolean"
                  },
                  "fieldMapping": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "description": "Per-feed field mapping controlling how an RSS/Atom item becomes post content. null (default) uses the built-in mapping: template \"{title}\\n\\n{link}\", no media, stripHtml true, smart truncation. Media selection is post-level (media attaches to the post, not a single channel); per-channel overrides cover text only, and channels on the same platform share one rendered text (written to the post's platformContent, as in the composer). Channels whose platform requires media (e.g. Instagram, TikTok, YouTube) are skipped for items lacking a usable enclosure, with the reason recorded in the activity log. Pass null to clear back to the default mapping.",
                    "properties": {
                      "template": {
                        "type": "string",
                        "maxLength": 2000,
                        "default": "{title}\n\n{link}",
                        "description": "Caption template. Standard tokens: {title} {link} {description} {content} {author} {categories} {feedName}. Any of a feed item's own extra leaf fields (namespaced or not, e.g. {publisher}) can also be used as {fieldName} (lowercased localName); the editor surfaces a feed's real fields as pills after a preview. A line whose tokens all render empty is dropped."
                      },
                      "mediaField": {
                        "type": "string",
                        "enum": [
                          "none",
                          "image",
                          "video",
                          "auto"
                        ],
                        "default": "none",
                        "description": "Which item enclosure to import and attach: none, the item image, the item video, or auto (video if present, else image). The file is re-hosted to your media library; if the import fails the post is created without media."
                      },
                      "stripHtml": {
                        "type": "boolean",
                        "default": true,
                        "description": "Strip HTML tags/entities from {title}/{description}/{content}."
                      },
                      "truncate": {
                        "type": "string",
                        "enum": [
                          "smart",
                          "hard",
                          "skip"
                        ],
                        "default": "smart",
                        "description": "When rendered text exceeds the platform character limit: smart = trim at a word boundary keeping a trailing link line; hard = cut at the limit; skip = don't post to that channel."
                      },
                      "hashtags": {
                        "type": "string",
                        "maxLength": 500,
                        "default": "",
                        "description": "Appended after the rendered template."
                      },
                      "channelOverrides": {
                        "type": "object",
                        "description": "Per-channel text overrides keyed by channel id (as a string). Each override may set template, hashtags, stripHtml, truncate; mediaField cannot be overridden per channel.",
                        "additionalProperties": {
                          "type": "object",
                          "properties": {
                            "template": {
                              "type": "string",
                              "maxLength": 2000
                            },
                            "hashtags": {
                              "type": "string",
                              "maxLength": 500
                            },
                            "stripHtml": {
                              "type": "boolean"
                            },
                            "truncate": {
                              "type": "string",
                              "enum": [
                                "smart",
                                "hard",
                                "skip"
                              ]
                            }
                          }
                        }
                      }
                    }
                  },
                  "requireApproval": {
                    "type": "boolean",
                    "default": false,
                    "description": "Optional. Hold items auto-published from this feed for team approval \u2014 each generated post lands with approvalStatus 'pending' and waits for POST /api/posts/{id}/approve. Only meaningful when mode is 'publish' (draft items never publish on their own). Default false."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "updateRssFeed",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "delete": {
        "tags": [
          "RSS Autopost"
        ],
        "summary": "Delete RSS feed",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "deleteRssFeed",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/media/multipart/create": {
      "post": {
        "tags": [
          "Media"
        ],
        "summary": "Start multipart upload (large videos, up to 1GB)",
        "description": "Step 1 of the chunked direct-to-storage upload for large files. Returns an uploadId, the fixed part size (10MB), and a presigned PUT URL per part. Upload each 10MB slice of the file to its URL (a failed part can be retried on its own \u2014 a network drop never restarts the whole file), collect each response's ETag header, then call /api/media/multipart/complete.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "contentType",
                  "sizeBytes"
                ],
                "properties": {
                  "contentType": {
                    "type": "string",
                    "description": "One of the allowed media MIME types"
                  },
                  "sizeBytes": {
                    "type": "integer",
                    "description": "Exact file size. Videos up to 1GB; images up to 100MB."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "uploadId, r2Key, partSize, and presigned partUrls"
          },
          "400": {
            "description": "Disallowed type or file too large"
          }
        },
        "operationId": "createMultipartUpload",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/media/multipart/complete": {
      "post": {
        "tags": [
          "Media"
        ],
        "summary": "Complete multipart upload",
        "description": "Step 2: assembles the uploaded parts, verifies the stored object (existence, size, content magic bytes, storage quota) and records the media file. Same verification and response shape as /api/media/finalize.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "r2Key",
                  "uploadId",
                  "parts",
                  "fileName",
                  "mimeType",
                  "sizeBytes"
                ],
                "properties": {
                  "r2Key": {
                    "type": "string"
                  },
                  "uploadId": {
                    "type": "string"
                  },
                  "parts": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "object",
                      "required": [
                        "partNumber",
                        "etag"
                      ],
                      "properties": {
                        "partNumber": {
                          "type": "integer",
                          "minimum": 1
                        },
                        "etag": {
                          "type": "string",
                          "description": "ETag response header from the part PUT"
                        }
                      }
                    }
                  },
                  "fileName": {
                    "type": "string"
                  },
                  "mimeType": {
                    "type": "string"
                  },
                  "sizeBytes": {
                    "type": "integer"
                  },
                  "width": {
                    "type": "integer"
                  },
                  "height": {
                    "type": "integer"
                  },
                  "duration": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Media file recorded"
          },
          "400": {
            "description": "Assembly or verification failed"
          }
        },
        "operationId": "completeMultipartUpload",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/media/multipart/abort": {
      "post": {
        "tags": [
          "Media"
        ],
        "summary": "Abort multipart upload",
        "description": "Cancel an in-progress chunked upload and free its stored parts.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "r2Key",
                  "uploadId"
                ],
                "properties": {
                  "r2Key": {
                    "type": "string"
                  },
                  "uploadId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Aborted"
          }
        },
        "operationId": "abortMultipartUpload",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "media:write"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/approve": {
      "post": {
        "tags": [
          "Posts"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "summary": "Approve a pending post",
        "description": "Requires a role with post:approve (owner, admin, approver). Releases a post with approvalStatus 'pending': it publishes at its scheduled time, or immediately if that time has already passed. The author is notified in-app.",
        "operationId": "approvePost",
        "responses": {
          "200": {
            "description": "The approved post",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Post"
                }
              }
            }
          },
          "400": {
            "description": "Post is not awaiting approval"
          },
          "403": {
            "description": "Role lacks post:approve"
          },
          "404": {
            "description": "Post not found"
          }
        },
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/reject": {
      "post": {
        "tags": [
          "Posts"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "summary": "Reject a pending post",
        "description": "Requires a role with post:approve. The post returns to draft with approvalStatus 'rejected' and the optional reason; the author is notified and can edit + reschedule to resubmit for approval.",
        "operationId": "rejectPost",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 2000,
                    "description": "Optional. Shown to the author (in-app notification + on the post)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The rejected post",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Post"
                }
              }
            }
          },
          "400": {
            "description": "Post is not awaiting approval"
          },
          "403": {
            "description": "Role lacks post:approve"
          },
          "404": {
            "description": "Post not found"
          }
        },
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/platforms": {
      "get": {
        "tags": [
          "Channels"
        ],
        "summary": "List platforms and their availability",
        "description": "Returns every social platform BulkPublish supports, together with its current availability. Use this to render an accurate \"temporarily unavailable\" or \"connections paused\" state rather than assuming every platform can be connected or published to.\n\nPlatforms that are switched off are still listed, with `enabled: false` and a `reason`. `envVar` is only included for organization owners and admins.\n\nA platform may also expose `variants` \u2014 sub-platforms gated separately because the vendor reviews them as a separate app. LinkedIn reports `variants.organization` for company pages; personal-profile availability is the platform-level state.",
        "responses": {
          "200": {
            "description": "Platform list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "platforms": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformAvailability"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        },
        "operationId": "listPlatforms",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "channels:read"
            ]
          }
        ]
      }
    },
    "/api/oauth/token": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "Exchange an authorization code or refresh token",
        "description": "Token endpoint (RFC 6749). No user authentication \u2014 the client authenticates itself. Confidential clients send `client_secret` (form field or HTTP Basic); public clients send none and rely on PKCE. Authorization codes are single-use and refresh tokens rotate: presenting either one twice revokes every token issued under that authorization, per OAuth 2.1. Failures return a single `invalid_grant` message for missing, expired and already-used credentials alike, so the endpoint cannot be used to probe which case applies.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type",
                  "client_id"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "authorization_code",
                      "refresh_token"
                    ],
                    "description": "**required**"
                  },
                  "client_id": {
                    "type": "string",
                    "description": "**required** \u2014 e.g. `bpc_...`"
                  },
                  "client_secret": {
                    "type": "string",
                    "description": "**optional** \u2014 confidential clients only; may also be sent as HTTP Basic"
                  },
                  "code": {
                    "type": "string",
                    "description": "**required for authorization_code** \u2014 the code from the redirect"
                  },
                  "redirect_uri": {
                    "type": "string",
                    "description": "**required for authorization_code** \u2014 must byte-match the one used to authorize"
                  },
                  "code_verifier": {
                    "type": "string",
                    "description": "**required for authorization_code** \u2014 PKCE verifier, 43-128 chars"
                  },
                  "refresh_token": {
                    "type": "string",
                    "description": "**required for refresh_token**"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tokens issued",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string",
                      "description": "`bpat_...`, valid 1 hour"
                    },
                    "token_type": {
                      "type": "string",
                      "example": "Bearer"
                    },
                    "expires_in": {
                      "type": "integer",
                      "example": 3600
                    },
                    "refresh_token": {
                      "type": "string",
                      "description": "`bprt_...`, valid 30 days, rotated on every use"
                    },
                    "scope": {
                      "type": "string",
                      "description": "Space-delimited scopes actually granted"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request, invalid_grant or unsupported_grant_type"
          },
          "401": {
            "description": "invalid_client \u2014 unknown client or bad secret"
          },
          "429": {
            "description": "Too many token requests from this IP"
          }
        },
        "operationId": "exchangeOauthToken"
      }
    },
    "/api/oauth/revoke": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "Revoke a token",
        "description": "Token revocation (RFC 7009). Revoking either the access or the refresh token disconnects the whole authorization \u2014 the user sees the app disappear from Connected apps. Returns 200 whether or not the token existed, so it cannot be used to probe for valid tokens.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "client_id"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "**required** \u2014 an access or refresh token"
                  },
                  "client_id": {
                    "type": "string",
                    "description": "**required**"
                  },
                  "client_secret": {
                    "type": "string",
                    "description": "**optional** \u2014 confidential clients only"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Revoked (or the token did not exist)"
          },
          "401": {
            "description": "invalid_client \u2014 client authentication failed"
          }
        },
        "operationId": "revokeOauthToken"
      }
    },
    "/api/push/tokens": {
      "post": {
        "tags": [
          "Notifications"
        ],
        "summary": "Register a mobile push token",
        "description": "Registers (or refreshes) an Expo push token for the authenticated user. Used by the BulkPublish mobile app; tokens are upserted by value, so a device that switches accounts is reassigned to the current user.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "platform"
                ],
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "Expo push token, e.g. ExponentPushToken[xxxx]"
                  },
                  "platform": {
                    "type": "string",
                    "enum": [
                      "ios",
                      "android"
                    ]
                  },
                  "deviceName": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Registered"
          },
          "400": {
            "description": "Invalid token or platform"
          }
        },
        "operationId": "registerPushToken",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "delete": {
        "tags": [
          "Notifications"
        ],
        "summary": "Unregister a mobile push token",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Unregistered"
          }
        },
        "operationId": "unregisterPushToken",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/api-keys": {
      "get": {
        "tags": [
          "API Keys"
        ],
        "summary": "List API keys",
        "responses": {
          "200": {
            "description": "API keys (secrets masked)"
          }
        },
        "operationId": "listApiKeys",
        "security": [
          {
            "apiKey": []
          }
        ]
      },
      "post": {
        "tags": [
          "API Keys"
        ],
        "summary": "Create API key",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "expiresAt": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created (full key shown once)"
          }
        },
        "operationId": "createApiKey",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/api-keys/usage": {
      "get": {
        "tags": [
          "API Keys"
        ],
        "summary": "API usage stats",
        "description": "Returns today's API call count, daily limit based on plan, and per-key breakdown. Daily limits: Free 100, Pro 5,000, Business 50,000.",
        "responses": {
          "200": {
            "description": "Usage data with today's count, limit, plan, and per-key breakdown"
          }
        },
        "operationId": "getApiKeyUsage",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/api-keys/usage/history": {
      "get": {
        "tags": [
          "API Keys"
        ],
        "summary": "API usage history",
        "description": "Returns daily API call counts for the last N days (default 30, max 90). Includes today's live count from Redis + historical data from DB.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "Number of days (default 30, max 90)",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Array of { date, count } entries"
          }
        },
        "operationId": "getApiKeyUsageHistory",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/api-keys/{id}": {
      "delete": {
        "tags": [
          "API Keys"
        ],
        "summary": "Delete API key",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          }
        },
        "operationId": "deleteApiKey",
        "security": [
          {
            "apiKey": []
          }
        ]
      }
    },
    "/api/hashtag-groups": {
      "get": {
        "tags": [
          "Hashtag Groups"
        ],
        "summary": "List hashtag groups",
        "description": "Every saved hashtag group in the organization, sorted by name. Returns { groups: HashtagGroup[] }. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "responses": {
          "200": {
            "description": "Hashtag groups"
          }
        },
        "operationId": "listHashtagGroups",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      },
      "post": {
        "tags": [
          "Hashtag Groups"
        ],
        "summary": "Create hashtag group",
        "description": "Saves a named set of hashtags the composer can insert in one click. An organization may keep up to 100 groups (400 LIMIT_REACHED beyond that). Returns 201 with { group }. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "hashtags"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "1-100 characters, trimmed. Unique per organization (case-insensitive); a clash returns 409 CONFLICT."
                  },
                  "hashtags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 30,
                    "description": "1-30 hashtags, with or without the leading '#'. Letters, digits and underscores in any script, up to 100 characters each; anything else (spaces, hyphens) is rejected with 400 VALIDATION_ERROR. Duplicates are dropped case-insensitively."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "400": {
            "description": "Validation error"
          },
          "409": {
            "description": "Duplicate name"
          }
        },
        "operationId": "createHashtagGroup",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/hashtag-groups/{id}": {
      "get": {
        "tags": [
          "Hashtag Groups"
        ],
        "summary": "Get hashtag group",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Hashtag group"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "getHashtagGroup",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ],
        "description": "Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED."
      },
      "put": {
        "tags": [
          "Hashtag Groups"
        ],
        "summary": "Update hashtag group",
        "description": "At least one of `name` or `hashtags` is required. `hashtags` replaces the whole list. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "1-100 characters, trimmed. Unique per organization (case-insensitive); a clash returns 409 CONFLICT."
                  },
                  "hashtags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 30,
                    "description": "1-30 hashtags, with or without the leading '#'. Letters, digits and underscores in any script, up to 100 characters each; anything else (spaces, hyphens) is rejected with 400 VALIDATION_ERROR. Duplicates are dropped case-insensitively."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "400": {
            "description": "Validation error"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "Duplicate name"
          }
        },
        "operationId": "updateHashtagGroup",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Hashtag Groups"
        ],
        "summary": "Delete hashtag group",
        "description": "Removes the group. Posts that already contain its hashtags are untouched. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "deleteHashtagGroup",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/posts/{id}/share": {
      "post": {
        "tags": [
          "Posts"
        ],
        "summary": "Create or get the post's review link",
        "description": "Returns a read-only review link (https://app.bulkpublish.com/p/<token>) that anyone holding it can open without signing in. It shows the post's text, media, destinations and timing, always the current version. The first call creates the token (201); later calls return the same one (200). Body `{ \"regenerate\": true }` mints a new token and kills the old link. Posts expose the token as `shareToken` (null when no link exists). Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED. Requires a role that can edit posts (owner, admin, approver, contributor); viewers get 403 FORBIDDEN.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "regenerate": {
                    "type": "boolean",
                    "default": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Existing link: { shareToken, url, created: false }"
          },
          "201": {
            "description": "New link: { shareToken, url, created: true }"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "createPostShareLink",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Posts"
        ],
        "summary": "Revoke the post's review link",
        "description": "Idempotent. Returns { success: true, revoked } where revoked says whether a link existed. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "revokePostShareLink",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/templates": {
      "get": {
        "tags": [
          "Templates"
        ],
        "summary": "List post templates",
        "description": "Every saved template in the organization, sorted by name. Returns { templates: PostTemplate[] }. Templates are text only; media, channels and per-platform options belong to the post. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "responses": {
          "200": {
            "description": "Templates"
          }
        },
        "operationId": "listTemplates",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      },
      "post": {
        "tags": [
          "Templates"
        ],
        "summary": "Create post template",
        "description": "Up to 200 templates per organization (400 LIMIT_REACHED beyond that). Returns 201 { template }. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "content"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "1-100 characters, trimmed; unique per organization (409 CONFLICT on a clash)."
                  },
                  "content": {
                    "type": "string",
                    "maxLength": 10000,
                    "description": "Template text. CRLF is normalised to LF and surrounding whitespace trimmed."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created"
          },
          "400": {
            "description": "Validation error"
          },
          "409": {
            "description": "Duplicate name"
          }
        },
        "operationId": "createTemplate",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      }
    },
    "/api/templates/{id}": {
      "get": {
        "tags": [
          "Templates"
        ],
        "summary": "Get post template",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{ template }"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "getTemplate",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ],
        "description": "Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED."
      },
      "put": {
        "tags": [
          "Templates"
        ],
        "summary": "Update post template",
        "description": "At least one of `name` or `content`. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "1-100 characters, trimmed; unique per organization (409 CONFLICT on a clash)."
                  },
                  "content": {
                    "type": "string",
                    "maxLength": 10000,
                    "description": "Template text. CRLF is normalised to LF and surrounding whitespace trimmed."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "400": {
            "description": "Validation error"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "Duplicate name"
          }
        },
        "operationId": "updateTemplate",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Templates"
        ],
        "summary": "Delete post template",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "deleteTemplate",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED."
      }
    },
    "/api/calendar-notes": {
      "get": {
        "tags": [
          "Calendar Notes"
        ],
        "summary": "List calendar notes",
        "description": "Notes whose date falls in [from, to] (both inclusive, at most a year apart), ordered by date. Returns { notes: CalendarNote[] }. Notes are free text pinned to a day on the calendar (a campaign, a holiday, \"no posts this week\"); they never publish anywhere. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Notes"
          },
          "400": {
            "description": "Missing or invalid range"
          }
        },
        "operationId": "listCalendarNotes",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:read"
            ]
          }
        ]
      },
      "post": {
        "tags": [
          "Calendar Notes"
        ],
        "summary": "Create calendar note",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "date",
                  "body"
                ],
                "properties": {
                  "date": {
                    "type": "string",
                    "format": "date",
                    "description": "Calendar date, YYYY-MM-DD. A plain date: it reads the same in every timezone."
                  },
                  "body": {
                    "type": "string",
                    "maxLength": 2000
                  },
                  "color": {
                    "type": "string",
                    "pattern": "^#[0-9a-fA-F]{6}$",
                    "description": "Six-digit hex colour. Default #F59E0B."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created: { note }"
          },
          "400": {
            "description": "Validation error"
          }
        },
        "operationId": "createCalendarNote",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED."
      }
    },
    "/api/calendar-notes/{id}": {
      "put": {
        "tags": [
          "Calendar Notes"
        ],
        "summary": "Update calendar note",
        "description": "At least one of `date`, `body`, `color`. Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "date": {
                    "type": "string",
                    "format": "date",
                    "description": "Calendar date, YYYY-MM-DD. A plain date: it reads the same in every timezone."
                  },
                  "body": {
                    "type": "string",
                    "maxLength": 2000
                  },
                  "color": {
                    "type": "string",
                    "pattern": "^#[0-9a-fA-F]{6}$",
                    "description": "Six-digit hex colour. Default #F59E0B."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated: { note }"
          },
          "400": {
            "description": "Validation error"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "updateCalendarNote",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ]
      },
      "delete": {
        "tags": [
          "Calendar Notes"
        ],
        "summary": "Delete calendar note",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "404": {
            "description": "Not found"
          }
        },
        "operationId": "deleteCalendarNote",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "posts:write"
            ]
          }
        ],
        "description": "Rolling out: until it is switched on for your account, this endpoint answers 403 FEATURE_DISABLED."
      }
    }
  }
}
